diff --git a/packages/runtime/src/__tests__/shell-exec.test.ts b/packages/runtime/src/__tests__/shell-exec.test.ts index 18c1867772..49f7170210 100644 --- a/packages/runtime/src/__tests__/shell-exec.test.ts +++ b/packages/runtime/src/__tests__/shell-exec.test.ts @@ -23,7 +23,12 @@ import { existsSync, promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { killWindowsTree } from '../process-tree-terminator.js'; -import { runProcessWithBoundedTail, runShellWithBoundedTail } from '../shell-exec.js'; +import { + buildUserCommandEnv, + runProcessWithBoundedTail, + runShellWithBoundedTail, +} from '../shell-exec.js'; +import { defaultShellPlan } from '../shell-detect.js'; const base = (over: Record = {}) => ({ cwd: process.cwd(), @@ -71,6 +76,82 @@ function findPwsh(): string | undefined { } describe('runShellWithBoundedTail', () => { + test('removes Windows case variants without mutating the supplied environment', () => { + const input = { + ELECTRON_RUN_AS_NODE: 'upper', + electron_run_as_node: 'lower', + MAKA_SHELL_ENV_KEEP: 'kept', + }; + const output = buildUserCommandEnv(input); + if (process.platform === 'win32') { + assert.deepEqual(output, { MAKA_SHELL_ENV_KEEP: 'kept' }); + } else { + assert.deepEqual(output, { + electron_run_as_node: 'lower', + MAKA_SHELL_ENV_KEEP: 'kept', + }); + } + assert.deepEqual(input, { + ELECTRON_RUN_AS_NODE: 'upper', + electron_run_as_node: 'lower', + MAKA_SHELL_ENV_KEEP: 'kept', + }); + }); + + test('clears inherited Electron Node mode at the user-command boundary', async () => { + const previousElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const previousKeep = process.env.MAKA_SHELL_ENV_KEEP; + process.env.ELECTRON_RUN_AS_NODE = '1'; + process.env.MAKA_SHELL_ENV_KEEP = 'kept-by-parent'; + try { + const childEnv = buildUserCommandEnv(process.env); + assert.equal(childEnv.ELECTRON_RUN_AS_NODE, undefined); + assert.equal(childEnv.MAKA_SHELL_ENV_KEEP, 'kept-by-parent'); + + const result = await runShellWithBoundedTail( + shellEnvironmentProbeCommand(), + base({ env: process.env, shell: defaultShellPlan() }), + ); + assert.equal(result.exitCode, 0); + assert.match(result.stdout, /electron=\r?\n/u); + assert.match(result.stdout, /keep=kept-by-parent/u); + assert.match(result.stdout, /explicit=explicit/u); + assert.equal(process.env.ELECTRON_RUN_AS_NODE, '1'); + assert.equal(process.env.MAKA_SHELL_ENV_KEEP, 'kept-by-parent'); + } finally { + if (previousElectronRunAsNode === undefined) delete process.env.ELECTRON_RUN_AS_NODE; + else process.env.ELECTRON_RUN_AS_NODE = previousElectronRunAsNode; + if (previousKeep === undefined) delete process.env.MAKA_SHELL_ENV_KEEP; + else process.env.MAKA_SHELL_ENV_KEEP = previousKeep; + } + }); + + test('clears inherited Electron Node mode for direct argv commands', async () => { + const previousElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const previousKeep = process.env.MAKA_SHELL_ENV_KEEP; + process.env.ELECTRON_RUN_AS_NODE = '1'; + process.env.MAKA_SHELL_ENV_KEEP = 'kept-by-parent'; + try { + const result = await runProcessWithBoundedTail( + process.execPath, + [ + '-e', + "process.stdout.write(`electron=${process.env.ELECTRON_RUN_AS_NODE ?? ''}\\nkeep=${process.env.MAKA_SHELL_ENV_KEEP ?? ''}`)", + ], + base({ env: process.env }), + ); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, 'electron=\nkeep=kept-by-parent'); + assert.equal(process.env.ELECTRON_RUN_AS_NODE, '1'); + assert.equal(process.env.MAKA_SHELL_ENV_KEEP, 'kept-by-parent'); + } finally { + if (previousElectronRunAsNode === undefined) delete process.env.ELECTRON_RUN_AS_NODE; + else process.env.ELECTRON_RUN_AS_NODE = previousElectronRunAsNode; + if (previousKeep === undefined) delete process.env.MAKA_SHELL_ENV_KEEP; + else process.env.MAKA_SHELL_ENV_KEEP = previousKeep; + } + }); + test('writes a legacy WSL Bash command through stdin', { skip: process.platform === 'win32' ? 'uses /bin/sh as a portable stdin probe' : false, }, async () => { @@ -349,3 +430,14 @@ describe('runShellWithBoundedTail', () => { assert.equal(await killWindowsTree(999_999), false); }); }); + +function shellEnvironmentProbeCommand(): string { + const shell = defaultShellPlan(); + if (shell.kind === 'cmd') { + return 'echo electron=%ELECTRON_RUN_AS_NODE% & echo keep=%MAKA_SHELL_ENV_KEEP% & set ELECTRON_RUN_AS_NODE=explicit & call echo explicit=%%ELECTRON_RUN_AS_NODE%%'; + } + if (shell.kind === 'pwsh' || shell.kind === 'powershell') { + return 'Write-Output "electron=$env:ELECTRON_RUN_AS_NODE"; Write-Output "keep=$env:MAKA_SHELL_ENV_KEEP"; $env:ELECTRON_RUN_AS_NODE=\'explicit\'; Write-Output "explicit=$env:ELECTRON_RUN_AS_NODE"'; + } + return 'printf \'electron=%s\\nkeep=%s\\n\' "${ELECTRON_RUN_AS_NODE-}" "$MAKA_SHELL_ENV_KEEP"; ELECTRON_RUN_AS_NODE=explicit; printf \'explicit=%s\\n\' "$ELECTRON_RUN_AS_NODE"'; +} diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts index a462b1363e..8a072b4fb2 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -173,6 +173,70 @@ describe('ShellRunProcessManager', () => { assert.equal(manager.liveCount(), 0); }); + test('clears inherited Electron Node mode for background pipes, PTY, and argv', async () => { + const previousElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const previousKeep = process.env.MAKA_SHELL_ENV_KEEP; + process.env.ELECTRON_RUN_AS_NODE = '1'; + process.env.MAKA_SHELL_ENV_KEEP = 'kept-by-parent'; + let manager: ShellRunProcessManager | undefined; + try { + manager = await createTestManager(); + const cwd = await workspace(); + const probeScript = + "process.stdout.write(`electron=${process.env.ELECTRON_RUN_AS_NODE ?? ''}\\nkeep=${process.env.MAKA_SHELL_ENV_KEEP ?? ''}\\n`)"; + const command = nodeCommand(probeScript); + + const pipe = await manager.runBackgroundBash(shellInput({ cwd, command, timeoutMs: 10_000 })); + assert.equal(pipe.kind, 'shell_run'); + const pipeResult = await waitForTerminalShellRun(manager, pipe.ref); + assert.equal(pipeResult.status, 'completed'); + assert.equal(pipeResult.output?.mode, 'pipes'); + if (pipeResult.output?.mode !== 'pipes') throw new Error('expected pipe output'); + assert.match(pipeResult.output.stdout, /electron=/u); + assert.match(pipeResult.output.stdout, /keep=kept-by-parent/u); + + const argv = await manager.runBackgroundBash( + shellInput({ + cwd, + command: 'direct argv probe', + argv: [process.execPath, '-e', probeScript], + timeoutMs: 10_000, + }), + ); + assert.equal(argv.kind, 'shell_run'); + const argvResult = await waitForTerminalShellRun(manager, argv.ref); + assert.equal(argvResult.status, 'completed'); + assert.equal(argvResult.output?.mode, 'pipes'); + if (argvResult.output?.mode !== 'pipes') throw new Error('expected pipe output'); + assert.match(argvResult.output.stdout, /electron=/u); + assert.match(argvResult.output.stdout, /keep=kept-by-parent/u); + + const pty = await manager.runBackgroundBash( + shellInput({ cwd, command, pty: true, timeoutMs: 10_000 }), + ); + assert.equal(pty.kind, 'shell_run'); + const ptyResult = await waitForTerminalShellRun(manager, pty.ref); + assert.equal(ptyResult.status, 'completed'); + assert.equal(ptyResult.output?.mode, 'pty'); + if (ptyResult.output?.mode !== 'pty') throw new Error('expected PTY output'); + const ptyText = terminalText(ptyResult.output); + assert.match(ptyText, /electron=/u); + assert.match(ptyText, /keep=kept-by-parent/u); + + assert.equal(process.env.ELECTRON_RUN_AS_NODE, '1'); + assert.equal(process.env.MAKA_SHELL_ENV_KEEP, 'kept-by-parent'); + } finally { + try { + await manager?.terminateAll(); + } finally { + if (previousElectronRunAsNode === undefined) delete process.env.ELECTRON_RUN_AS_NODE; + else process.env.ELECTRON_RUN_AS_NODE = previousElectronRunAsNode; + if (previousKeep === undefined) delete process.env.MAKA_SHELL_ENV_KEEP; + else process.env.MAKA_SHELL_ENV_KEEP = previousKeep; + } + } + }); + test('preserves CJK PowerShell output through pipes', { skip: process.platform === 'win32' ? false : 'Windows PowerShell 5.1 regression', }, async () => { diff --git a/packages/runtime/src/shell-exec.ts b/packages/runtime/src/shell-exec.ts index d277aee75d..809106f9ce 100644 --- a/packages/runtime/src/shell-exec.ts +++ b/packages/runtime/src/shell-exec.ts @@ -57,6 +57,8 @@ export const BASH_MAX_RETAINED_CHARS = 1024 * 1024; // chunks keep flowing into the retained tail buffer. export const BASH_MAX_LIVE_EMIT_CHARS = 1024 * 1024; +const ELECTRON_RUN_AS_NODE = 'ELECTRON_RUN_AS_NODE'; + // Emitted once per stream when live forwarding is suppressed. The full output is // not lost — it still feeds the retained tail and the returned result. export const LIVE_OUTPUT_SUPPRESSED_MARKER = @@ -119,6 +121,23 @@ export interface BoundedShellResult { aborted: boolean; } +/** + * Build the environment boundary for a user command without changing the + * Runtime Host's own environment. Electron uses this variable to put the + * current process in Node mode; inheriting it would make Electron-launched + * user programs lose Electron APIs such as BrowserWindow. Windows environment + * names are case-insensitive, while POSIX environment names are not. + */ +export function buildUserCommandEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(env).filter(([key]) => + process.platform === 'win32' + ? key.toUpperCase() !== ELECTRON_RUN_AS_NODE + : key !== ELECTRON_RUN_AS_NODE, + ), + ); +} + /** * Run `command` in a shell, streaming output into a memory-bounded tail. Never * kills the command for producing too much output — it keeps only the last @@ -131,18 +150,15 @@ export function runShellWithBoundedTail( command: string, options: BoundedShellOptions, ): Promise { - const plan = buildShellSpawnPlan( - options.shell ?? defaultShellPlan(), - command, - options.env ?? process.env, - ); + const env = buildUserCommandEnv(options.env ?? process.env); + const plan = buildShellSpawnPlan(options.shell ?? defaultShellPlan(), command, env); return runSpawnedProcessWithBoundedTail( plan.file, plan.args, plan.useShellOption, { ...options, - ...(plan.env ? { env: plan.env } : {}), + env: plan.env ?? env, }, plan.stdin, ); @@ -154,7 +170,10 @@ export function runProcessWithBoundedTail( args: readonly string[], options: BoundedShellOptions, ): Promise { - return runSpawnedProcessWithBoundedTail(program, args, false, options); + return runSpawnedProcessWithBoundedTail(program, args, false, { + ...options, + env: buildUserCommandEnv(options.env ?? process.env), + }); } function runSpawnedProcessWithBoundedTail( diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index 6ab56d666e..13fecafe5c 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -39,6 +39,7 @@ import { BASH_MAX_LIVE_EMIT_CHARS, BASH_MAX_RETAINED_CHARS, LIVE_OUTPUT_SUPPRESSED_MARKER, + buildUserCommandEnv, } from './shell-exec.js'; import { DEFAULT_PROCESS_TERMINATION_GRACE_MS, @@ -735,6 +736,7 @@ export class ShellRunProcessManager sessionEpoch: number, onLiveAdmission: ((live: LiveShellRun) => void) | undefined, ): Promise { + const env = buildUserCommandEnv(input.env ?? process.env); const collector = new PipeTailCollector(this.maxRetainedChars); const pending: Array<(live: LivePipeShellRun) => void> = []; let live: LivePipeShellRun | undefined; @@ -751,11 +753,7 @@ export class ShellRunProcessManager args: [...input.argv.slice(1)], useShellOption: false, } - : buildShellSpawnPlan( - input.shell ?? defaultShellPlan(), - input.command, - input.env ?? process.env, - ); + : buildShellSpawnPlan(input.shell ?? defaultShellPlan(), input.command, env); startingRecord = await this.createStartingRecord( input, shellRunId, @@ -767,7 +765,7 @@ export class ShellRunProcessManager const driver = new PipeProcessDriver({ plan, cwd: input.cwd, - ...((plan.env ?? input.env) ? { env: plan.env ?? input.env } : {}), + env: plan.env ?? env, ...(input.fdInputs ? { fdInputs: input.fdInputs } : {}), outputDrainMs: this.pipeOutputDrainMs, onData: (stream, data) => dispatch((target) => this.onPipeData(target, stream, data)), @@ -813,6 +811,7 @@ export class ShellRunProcessManager slotReservation: ShellRunSlotReservation, sessionEpoch: number, ): Promise { + const env = buildUserCommandEnv(input.env ?? process.env); const pending: Array<(live: LivePtyShellRun) => void> = []; let live: LivePtyShellRun | undefined; let driver: PtyProcessDriver | undefined; @@ -836,11 +835,7 @@ export class ShellRunProcessManager onDirty: () => dispatch((target) => this.scheduleAutomaticFlush(target)), onFailure: (error) => dispatch((target) => this.handleIntegrityFailure(target, error)), }); - const plan = buildPtyShellSpawnPlan( - input.shell ?? defaultShellPlan(), - input.command, - input.env ?? process.env, - ); + const plan = buildPtyShellSpawnPlan(input.shell ?? defaultShellPlan(), input.command, env); startingRecord = await this.createStartingRecord( input, shellRunId, @@ -853,7 +848,7 @@ export class ShellRunProcessManager file: plan.file, args: plan.args, cwd: input.cwd, - env: plan.env ?? input.env ?? process.env, + env: plan.env ?? env, cols: PTY_INITIAL_COLS, rows: PTY_INITIAL_ROWS, onData: (data) => dispatch((target) => this.onPtyData(target, data)),