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
+2
View File
@@ -85,6 +85,8 @@ After packaging, run `bun run --cwd packages/electron verify:linux-appimage`. Th
Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libfuse2` / `libfuse2t64` on Debian/Ubuntu). Without FUSE, start with `APPIMAGE_EXTRACT_AND_RUN=1`. Keep the AppImage on a writable path so in-app updates can replace it. Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libfuse2` / `libfuse2t64` on Debian/Ubuntu). Without FUSE, start with `APPIMAGE_EXTRACT_AND_RUN=1`. Keep the AppImage on a writable path so in-app updates can replace it.
Desktop clears AppImage `ARGV0` from `process.env` before probing the login shell and starting the in-process server. Leaving it set makes zsh rewrite argv[0] for integrated-terminal and managed-OpenCode child commands to the AppImage path.
Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet).
### Updater End-to-End Fixture ### Updater End-to-End Fixture
+6 -1
View File
@@ -1374,11 +1374,16 @@ const loadShellEnv = () => {
// Merge the user's login-shell env (PATH, etc.) into this process before we // Merge the user's login-shell env (PATH, etc.) into this process before we
import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js'; import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js';
import { stripAppImageArgv0Leak } from '@openchamber/web/server/lib/inherited-env.js';
// import/start the server in-process. The server and its children (opencode // import/start the server in-process. The server and its children (opencode
// CLI, git, etc.) inherit process.env directly now — there is no sidecar // CLI, git, etc.) inherit process.env directly now — there is no sidecar
// subprocess to hand a custom env to. // subprocess to hand a custom env to.
const inheritUserShellEnv = () => { const inheritUserShellEnv = () => {
// Clear before probing/merging so login-shell snapshots and children never
// inherit the AppImage path as argv[0] via zsh's ARGV0 parameter (#2588).
stripAppImageArgv0Leak(process.env);
const shellEnv = loadShellEnv(); const shellEnv = loadShellEnv();
if (!shellEnv) return; if (!shellEnv) return;
@@ -1388,7 +1393,7 @@ const inheritUserShellEnv = () => {
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter); const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter);
for (const [key, value] of Object.entries(shellEnv)) { for (const [key, value] of Object.entries(shellEnv)) {
if (key === 'PATH') continue; if (key === 'PATH' || key === 'ARGV0') continue;
if (typeof process.env[key] === 'undefined') { if (typeof process.env[key] === 'undefined') {
process.env[key] = value; process.env[key] = value;
} }
+23
View File
@@ -0,0 +1,23 @@
/**
* Sanitize environment objects inherited by user-facing child processes.
*
* Linux AppImage runtimes export `ARGV0` as the AppImage path before launching
* the packaged app. zsh treats an exported `ARGV0` as the argv[0] for every
* external command it spawns, which corrupts Python venv detection and any
* other program that reads argv[0]/$0 while leaving `/proc/self/exe` correct.
*
* See openchamber/openchamber#2588 and pingdotgg/t3code#2509.
*/
/**
* Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
* @param {NodeJS.ProcessEnv | Record<string, string | undefined> | null | undefined} env
* @returns {typeof env}
*/
export function stripAppImageArgv0Leak(env) {
if (!env || typeof env !== 'object') return env;
if (Object.prototype.hasOwnProperty.call(env, 'ARGV0')) {
delete env.ARGV0;
}
return env;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { stripAppImageArgv0Leak } from './inherited-env.js';
describe('stripAppImageArgv0Leak', () => {
it('removes ARGV0 from a child env object', () => {
const env = {
PATH: '/usr/bin',
ARGV0: '/path/to/OpenChamber-1.17.2-linux-x86_64.AppImage',
SHELL: '/bin/zsh',
};
expect(stripAppImageArgv0Leak(env)).toBe(env);
expect(env).toEqual({
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
});
it('is a no-op when ARGV0 is absent', () => {
const env = { PATH: '/usr/bin', SHELL: '/bin/bash' };
stripAppImageArgv0Leak(env);
expect(env).toEqual({ PATH: '/usr/bin', SHELL: '/bin/bash' });
});
it('tolerates nullish env values', () => {
expect(stripAppImageArgv0Leak(null)).toBeNull();
expect(stripAppImageArgv0Leak(undefined)).toBeUndefined();
});
});
@@ -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 Managed OpenCode launch also merges the environment returned by the agent-tool
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
be replaced by injected values. External OpenCode processes receive no 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. 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; return;
} }
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']); const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']);
for (const [key, value] of Object.entries(snapshot)) { for (const [key, value] of Object.entries(snapshot)) {
if (skipKeys.has(key)) { if (skipKeys.has(key)) {
continue; continue;
@@ -244,6 +244,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
process.env[key] = value; 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 currentPath = process.env.PATH || '';
const shellPath = snapshot.PATH || ''; const shellPath = snapshot.PATH || '';
if (!shellPath) { if (!shellPath) {
@@ -131,6 +131,28 @@ describe('OpenCode env runtime', () => {
expect(process.env.PATH).toBe(defaultDir); 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 () => { it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' }); const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
@@ -1,5 +1,6 @@
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net'; import net from 'node:net';
import { stripAppImageArgv0Leak } from '../inherited-env.js';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
import { recordStartupPerformance } from './startup-performance.js'; import { recordStartupPerformance } from './startup-performance.js';
@@ -518,13 +519,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
timeout: 30000, timeout: 30000,
cwd: state.openCodeWorkingDirectory, cwd: state.openCodeWorkingDirectory,
shellEnvKeysCount: Object.keys(shellEnv).length, shellEnvKeysCount: Object.keys(shellEnv).length,
env: { env: stripAppImageArgv0Leak({
...shellEnv, ...shellEnv,
...process.env, ...process.env,
...managedOpenCodeEnv, ...managedOpenCodeEnv,
PATH: envPath, PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword, OPENCODE_SERVER_PASSWORD: openCodePassword,
}, }),
}); });
if (!serverInstance || !serverInstance.url) { if (!serverInstance || !serverInstance.url) {
@@ -285,6 +285,40 @@ describe('OpenCode lifecycle', () => {
await server.close(); 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 () => { it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
const child = createMockChild(); const child = createMockChild();
spawnMock.mockImplementationOnce(() => { spawnMock.mockImplementationOnce(() => {
@@ -24,6 +24,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory. - Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB. - Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup. - PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs. - `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored. - PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged. - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
@@ -10,6 +10,7 @@ import {
import { sanitizeTerminalHistoryChunk } from './history.js'; import { sanitizeTerminalHistoryChunk } from './history.js';
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js'; import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js'; import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js';
import { stripAppImageArgv0Leak } from '../inherited-env.js';
const MAX_SESSIONS = 20; const MAX_SESSIONS = 20;
const MAX_HISTORY_BYTES = 512 * 1024; const MAX_HISTORY_BYTES = 512 * 1024;
@@ -66,6 +67,8 @@ export function createTerminalRuntime({
// required because bun-pty also inherits Bun's native process environment. // required because bun-pty also inherits Bun's native process environment.
env.NODE_CHANNEL_FD = ''; env.NODE_CHANNEL_FD = '';
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE; delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
stripAppImageArgv0Leak(env);
const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) }; const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) };
return { process: provider.spawn(executable, args, options), backend: provider.backend, shell: resolvedShell.id, loginShell }; return { process: provider.spawn(executable, args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
} catch (error) { lastError = error; } } catch (error) { lastError = error; }
@@ -154,6 +154,8 @@ describe('terminal runtime', () => {
expect(harness.processes[0].options.cwd).toBe('/repo'); expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15'); expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe(''); expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007'); harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']); expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
@@ -173,6 +175,22 @@ describe('terminal runtime', () => {
} finally { await harness.runtime.shutdown(); } } finally { await harness.runtime.shutdown(); }
}); });
it('strips AppImage ARGV0 from PTY child environments', async () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
const harness = createHarness();
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
expect(response.statusCode).toBe(200);
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
await harness.runtime.shutdown();
}
});
it('lists available shells and uses the selected shell for create and restart', async () => { it('lists available shells and uses the selected shell for create and restart', async () => {
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']); const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
const harness = createHarness({ const harness = createHarness({