Merge pull request #2589 from openchamber/feat/opencode-argv-0-path-a7e7
fix(desktop): strip AppImage ARGV0 leak corrupting zsh argv[0] (#2588)
This commit is contained in:
@@ -127,7 +127,9 @@ The runtime maintains active-session count incrementally from idempotent activit
|
||||
Managed OpenCode launch also merges the environment returned by the agent-tool
|
||||
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
|
||||
be replaced by injected values. External OpenCode processes receive no
|
||||
OpenChamber tool injection.
|
||||
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
|
||||
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
|
||||
path (#2588).
|
||||
|
||||
Set `OPENCHAMBER_STARTUP_PERF=1` to emit bounded startup phase records for server listen, managed OpenCode preparation/readiness, and proxy readiness holds. Every OpenCode bootstrap emits one terminal `opencode.bootstrap.ready` or `opencode.bootstrap.error` event, including reused and external server paths. Records contain controlled phase/outcome/route labels and timing values only; they never contain request URLs, runtime keys, directories, session IDs, credentials, or content.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js';
|
||||
import { mergePathValues } from './path-utils.js';
|
||||
|
||||
export const createOpenCodeEnvRuntime = (deps) => {
|
||||
@@ -227,12 +228,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
};
|
||||
|
||||
const applyLoginShellEnvSnapshot = () => {
|
||||
// Always clear AppImage ARGV0, even when no login-shell snapshot is available.
|
||||
// Otherwise a leaked process.env.ARGV0 survives into later child spawns (#2588).
|
||||
clearAppImageArgv0FromProcessEnv();
|
||||
|
||||
const snapshot = getLoginShellEnvSnapshot();
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
|
||||
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']);
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (skipKeys.has(key)) {
|
||||
continue;
|
||||
|
||||
@@ -131,6 +131,43 @@ describe('OpenCode env runtime', () => {
|
||||
expect(process.env.PATH).toBe(defaultDir);
|
||||
});
|
||||
|
||||
it('clears AppImage ARGV0 when applying a login-shell env snapshot', () => {
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
|
||||
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
|
||||
const { runtime, state } = createRuntime({});
|
||||
state.cachedLoginShellEnvSnapshot = {
|
||||
PATH: '/usr/bin',
|
||||
ARGV0: '/leaked/from/shell.AppImage',
|
||||
OPENCHAMBER_ARGV0_TEST_MARKER: '1',
|
||||
};
|
||||
|
||||
try {
|
||||
runtime.applyLoginShellEnvSnapshot();
|
||||
expect(process.env.ARGV0).toBeUndefined();
|
||||
expect(process.env.OPENCHAMBER_ARGV0_TEST_MARKER).toBe('1');
|
||||
} finally {
|
||||
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
}
|
||||
});
|
||||
|
||||
it('clears AppImage ARGV0 even when no login-shell snapshot is available', () => {
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
|
||||
const { runtime, state } = createRuntime({});
|
||||
state.cachedLoginShellEnvSnapshot = null;
|
||||
|
||||
try {
|
||||
runtime.applyLoginShellEnvSnapshot();
|
||||
expect(process.env.ARGV0).toBeUndefined();
|
||||
} finally {
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
|
||||
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { stripAppImageArgv0Leak } from '../inherited-env.js';
|
||||
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
|
||||
import { recordStartupPerformance } from './startup-performance.js';
|
||||
|
||||
@@ -526,13 +527,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
timeout: 30000,
|
||||
cwd: state.openCodeWorkingDirectory,
|
||||
shellEnvKeysCount: Object.keys(shellEnv).length,
|
||||
env: {
|
||||
env: stripAppImageArgv0Leak({
|
||||
...shellEnv,
|
||||
...process.env,
|
||||
...managedOpenCodeEnv,
|
||||
PATH: envPath,
|
||||
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!serverInstance || !serverInstance.url) {
|
||||
|
||||
@@ -312,6 +312,40 @@ describe('OpenCode lifecycle', () => {
|
||||
expect(server.signalCode).toBe('SIGTERM');
|
||||
});
|
||||
|
||||
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
|
||||
delete process.env.OPENCODE_BINARY;
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
||||
const child = createMockChild();
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
try {
|
||||
const runtime = createRuntime({
|
||||
getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
|
||||
PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
|
||||
ARGV0: '/leaked/from/shell/snapshot.AppImage',
|
||||
SHELL_ONLY: 'yes',
|
||||
})),
|
||||
});
|
||||
const server = await runtime.startOpenCode();
|
||||
const [, , options] = spawnMock.mock.calls[0];
|
||||
|
||||
expect(options.env).not.toHaveProperty('ARGV0');
|
||||
expect(options.env.SHELL_ONLY).toBe('yes');
|
||||
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
|
||||
|
||||
await server.close();
|
||||
} finally {
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
|
||||
const child = createMockChild();
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
|
||||
Reference in New Issue
Block a user