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. 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 { clearAppImageArgv0FromProcessEnv } 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).
clearAppImageArgv0FromProcessEnv();
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;
} }
+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 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.
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js';
import { mergePathValues } from './path-utils.js'; import { mergePathValues } from './path-utils.js';
export const createOpenCodeEnvRuntime = (deps) => { export const createOpenCodeEnvRuntime = (deps) => {
@@ -227,12 +228,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
}; };
const applyLoginShellEnvSnapshot = () => { 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(); const snapshot = getLoginShellEnvSnapshot();
if (!snapshot) { if (!snapshot) {
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;
@@ -131,6 +131,43 @@ 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('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 () => { 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';
@@ -526,13 +527,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) {
@@ -312,6 +312,40 @@ describe('OpenCode lifecycle', () => {
expect(server.signalCode).toBe('SIGTERM'); 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 () => { it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
const child = createMockChild(); const child = createMockChild();
spawnMock.mockImplementationOnce(() => { 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. - 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. - 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 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. - `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.
+6 -1
View File
@@ -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, resolveLinuxPtyLaunch } 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,8 +67,12 @@ 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).
// 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 } : {}) }; 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; } } catch (error) { lastError = error; }
} }
throw lastError ?? new Error('No executable shell found'); 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.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('');
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c'); expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
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('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(); const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance); 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(); } } 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 () => { 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({
@@ -198,14 +225,24 @@ describe('terminal runtime', () => {
const created = createResponse(); const created = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created); 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(created.statusCode).toBe(200);
expect(harness.processes[0].shell).toBe('/bin/zsh'); if (process.platform === 'linux') {
expect(harness.processes[0].args).toEqual(['-l']); 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(); const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted); 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(restarted.statusCode).toBe(200);
expect(harness.processes[1].shell).toBe('/bin/bash'); if (process.platform === 'linux') {
expect(harness.processes[1].args).toEqual(['-l']); 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(); } } finally { await harness.runtime.shutdown(); }
}); });