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:
Cursor Agent
2026-08-03 08:54:39 +00:00
co-authored by Serhii Dziupin
parent 2ba8ae8bd4
commit be38fb8cf4
12 changed files with 148 additions and 5 deletions
@@ -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({