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:
Serhii Dziupin
2026-08-03 16:37:54 +03:00
committed by GitHub
12 changed files with 280 additions and 12 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.
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).
### 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
import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js';
import { clearAppImageArgv0FromProcessEnv } from '@openchamber/web/server/lib/inherited-env.js';
// import/start the server in-process. The server and its children (opencode
// CLI, git, etc.) inherit process.env directly now — there is no sidecar
// subprocess to hand a custom env to.
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).
clearAppImageArgv0FromProcessEnv();
const shellEnv = loadShellEnv();
if (!shellEnv) return;
@@ -1388,7 +1393,7 @@ const inheritUserShellEnv = () => {
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter);
for (const [key, value] of Object.entries(shellEnv)) {
if (key === 'PATH') continue;
if (key === 'PATH' || key === 'ARGV0') continue;
if (typeof process.env[key] === 'undefined') {
process.env[key] = value;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 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.
*/
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
/**
* 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;
}
/**
* Clear AppImage `ARGV0` from this process.
*
* Bun keeps a native environ that `bun-pty` inherits even after
* `delete process.env.ARGV0`. On Linux under Bun we also call libc `unsetenv`.
*/
export function clearAppImageArgv0FromProcessEnv() {
delete process.env.ARGV0;
if (process.platform !== 'linux' || typeof Bun === 'undefined') return;
try {
const require = createRequire(import.meta.url);
const { dlopen } = require('bun:ffi');
const libc = dlopen('libc.so.6', {
unsetenv: { args: ['cstring'], returns: 'i32' },
});
libc.symbols.unsetenv(Buffer.from('ARGV0\0'));
} catch {
// Node/Electron and environments without bun:ffi rely on explicit child envs.
}
}
/**
* Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
*
* `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
* JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
* before execing the real shell. No-op on non-Linux platforms.
*
* @param {string} executable
* @param {string[]} args
* @returns {{ executable: string, args: string[] }}
*/
export function resolveLinuxPtyLaunch(executable, args = []) {
if (process.platform !== 'linux') {
return { executable, args };
}
const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
if (!envBinary) {
return { executable, args };
}
return {
executable: envBinary,
args: ['-u', 'ARGV0', executable, ...args],
};
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
clearAppImageArgv0FromProcessEnv,
resolveLinuxPtyLaunch,
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();
});
});
describe('clearAppImageArgv0FromProcessEnv', () => {
it('removes ARGV0 from process.env', () => {
const previous = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
try {
clearAppImageArgv0FromProcessEnv();
expect(process.env.ARGV0).toBeUndefined();
} finally {
if (previous === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previous;
}
});
});
describe('resolveLinuxPtyLaunch', () => {
it('wraps the shell with env -u ARGV0 on Linux', () => {
if (process.platform !== 'linux') return;
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
executable: expect.stringMatching(/\/env$/),
args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
});
});
it('leaves non-Linux launches unchanged', () => {
if (process.platform === 'linux') return;
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
executable: '/bin/zsh',
args: ['-l'],
});
});
});
@@ -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(() => {
@@ -26,6 +26,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
- 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. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
- `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.
- 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.
+6 -1
View File
@@ -10,6 +10,7 @@ import {
import { sanitizeTerminalHistoryChunk } from './history.js';
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js';
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
const MAX_SESSIONS = 20;
const MAX_HISTORY_BYTES = 512 * 1024;
@@ -66,8 +67,12 @@ export function createTerminalRuntime({
// required because bun-pty also inherits Bun's native process environment.
env.NODE_CHANNEL_FD = '';
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).
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
stripAppImageArgv0Leak(env);
const launch = resolveLinuxPtyLaunch(executable, args);
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(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
} catch (error) { lastError = error; }
}
throw lastError ?? new Error('No executable shell found');
@@ -154,8 +154,14 @@ describe('terminal runtime', () => {
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
}
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
@@ -173,6 +179,27 @@ describe('terminal runtime', () => {
} 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');
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args[0]).toBe('-u');
expect(harness.processes[0].args[1]).toBe('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 () => {
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
const harness = createHarness({
@@ -198,14 +225,24 @@ describe('terminal runtime', () => {
const created = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
expect(created.statusCode).toBe(200);
expect(harness.processes[0].shell).toBe('/bin/zsh');
expect(harness.processes[0].args).toEqual(['-l']);
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
} else {
expect(harness.processes[0].shell).toBe('/bin/zsh');
expect(harness.processes[0].args).toEqual(['-l']);
}
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
expect(restarted.statusCode).toBe(200);
expect(harness.processes[1].shell).toBe('/bin/bash');
expect(harness.processes[1].args).toEqual(['-l']);
if (process.platform === 'linux') {
expect(harness.processes[1].shell).toMatch(/\/env$/);
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
} else {
expect(harness.processes[1].shell).toBe('/bin/bash');
expect(harness.processes[1].args).toEqual(['-l']);
}
} finally { await harness.runtime.shutdown(); }
});