fix(desktop): strip AppImage ARGV0 before child shells (#2588)

AppImage exports ARGV0 into the process environment. zsh treats that as
argv[0] for every external command, which broke Python venv detection in
the integrated terminal and managed OpenCode sessions.

Clear ARGV0 in Electron before login-shell probing, refuse to re-apply it
from shell snapshots, and strip it from terminal PTY and managed OpenCode
launch environments.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 08:54:39 +00:00
co-authored by Serhii Dziupin
parent 2ba8ae8bd4
commit be38fb8cf4
12 changed files with 148 additions and 5 deletions
@@ -120,7 +120,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.
@@ -232,7 +232,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
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;
@@ -244,6 +244,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
process.env[key] = value;
}
// AppImage ARGV0 must never remain on process.env (zsh rewrites argv[0]; #2588).
delete process.env.ARGV0;
const currentPath = process.env.PATH || '';
const shellPath = snapshot.PATH || '';
if (!shellPath) {
@@ -131,6 +131,28 @@ 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('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';
@@ -518,13 +519,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) {
@@ -285,6 +285,40 @@ describe('OpenCode lifecycle', () => {
await server.close();
});
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(() => {