diff --git a/packages/electron/README.md b/packages/electron/README.md index 2e052833..45722457 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -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 diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index d82fbb3b..ca7a493e 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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; } diff --git a/packages/web/server/lib/inherited-env.js b/packages/web/server/lib/inherited-env.js new file mode 100644 index 00000000..a75de0ff --- /dev/null +++ b/packages/web/server/lib/inherited-env.js @@ -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 | 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], + }; +} diff --git a/packages/web/server/lib/inherited-env.test.js b/packages/web/server/lib/inherited-env.test.js new file mode 100644 index 00000000..8348d449 --- /dev/null +++ b/packages/web/server/lib/inherited-env.test.js @@ -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'], + }); + }); +}); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 68c33dc9..1c625beb 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 0bd65b50..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,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; diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 52ce6908..7f51f40f 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -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' }); diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index a331ea59..172d88b2 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -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) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 1991b6b7..fb57c3f2 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -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(() => { diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index d0b4563a..33fd9e54 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 04a7135c..5e90ac93 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -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'); diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 8a67c07e..8232be95 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -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(); } });