fix(terminal): drop native ARGV0 for bun-pty via env -u
bun-pty merges the OS environ into PTY children, so deleting ARGV0 from the JS env object alone left the AppImage path in the shell. Wrap Linux PTY spawns with env -u ARGV0, clear native ARGV0 under Bun via libc unsetenv, and always clear process.env even when no login-shell snapshot exists. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
be38fb8cf4
commit
5defd1af75
@@ -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;
|
||||
|
||||
@@ -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<string, string | undefined> | 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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user