diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 46722691..ca7a493e 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1374,7 +1374,7 @@ 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 { stripAppImageArgv0Leak } from '@openchamber/web/server/lib/inherited-env.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 @@ -1382,7 +1382,7 @@ import { stripAppImageArgv0Leak } from '@openchamber/web/server/lib/inherited-en 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); + clearAppImageArgv0FromProcessEnv(); const shellEnv = loadShellEnv(); if (!shellEnv) return; diff --git a/packages/web/server/lib/inherited-env.js b/packages/web/server/lib/inherited-env.js index 9cae7f27..a75de0ff 100644 --- a/packages/web/server/lib/inherited-env.js +++ b/packages/web/server/lib/inherited-env.js @@ -9,6 +9,11 @@ * 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 | null | undefined} env @@ -21,3 +26,49 @@ export function stripAppImageArgv0Leak(env) { } 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], + }; +} diff --git a/packages/web/server/lib/inherited-env.test.js b/packages/web/server/lib/inherited-env.test.js index e7193190..8348d449 100644 --- a/packages/web/server/lib/inherited-env.test.js +++ b/packages/web/server/lib/inherited-env.test.js @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { stripAppImageArgv0Leak } from './inherited-env.js'; +import { + clearAppImageArgv0FromProcessEnv, + resolveLinuxPtyLaunch, + stripAppImageArgv0Leak, +} from './inherited-env.js'; describe('stripAppImageArgv0Leak', () => { it('removes ARGV0 from a child env object', () => { @@ -27,3 +31,35 @@ describe('stripAppImageArgv0Leak', () => { 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'], + }); + }); +}); diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 5ed52956..23950d63 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -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,6 +228,10 @@ 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; @@ -244,9 +249,6 @@ 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) { diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 96386db3..7f51f40f 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -153,6 +153,21 @@ describe('OpenCode env runtime', () => { } }); + 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' }); diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index d662c060..40196998 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -24,7 +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. +- 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. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 3d036ff0..9aa328d4 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -10,7 +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'; +import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js'; const MAX_SESSIONS = 20; const MAX_HISTORY_BYTES = 512 * 1024; @@ -68,9 +68,11 @@ export function createTerminalRuntime({ 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'); diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 4a39225f..f0e2b622 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -156,6 +156,10 @@ describe('terminal runtime', () => { 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'); + 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'); expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']); @@ -184,6 +188,11 @@ describe('terminal runtime', () => { 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; @@ -216,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(); } });