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:
co-authored by
Serhii Dziupin
parent
2ba8ae8bd4
commit
be38fb8cf4
@@ -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
|
||||
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(() => {
|
||||
|
||||
@@ -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.
|
||||
- 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 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.
|
||||
- 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.
|
||||
|
||||
@@ -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 } from '../inherited-env.js';
|
||||
|
||||
const MAX_SESSIONS = 20;
|
||||
const MAX_HISTORY_BYTES = 512 * 1024;
|
||||
@@ -66,6 +67,8 @@ 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).
|
||||
stripAppImageArgv0Leak(env);
|
||||
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 };
|
||||
} catch (error) { lastError = error; }
|
||||
|
||||
@@ -154,6 +154,8 @@ 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('');
|
||||
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');
|
||||
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(); }
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
|
||||
const harness = createHarness({
|
||||
|
||||
Reference in New Issue
Block a user