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..46722691 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 { stripAppImageArgv0Leak } 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). + stripAppImageArgv0Leak(process.env); + 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..9cae7f27 --- /dev/null +++ b/packages/web/server/lib/inherited-env.js @@ -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 | 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; +} 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..e7193190 --- /dev/null +++ b/packages/web/server/lib/inherited-env.test.js @@ -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(); + }); +}); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index c57ecb8b..bb9fcdfc 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 0bd65b50..5ed52956 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -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) { diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 52ce6908..96386db3 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -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' }); diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 046a710e..77cb3f4d 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'; @@ -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) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index aadb5483..1a1c1b21 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -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(() => { diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index ffc27411..d662c060 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index f717cb7a..3d036ff0 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 } 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; } diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 4bc21420..4a39225f 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -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({