Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion packages/runtime/src/__tests__/shell-exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => ({
cwd: process.cwd(),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"';
}
64 changes: 64 additions & 0 deletions packages/runtime/src/__tests__/shell-run-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '<missing>'}\\nkeep=${process.env.MAKA_SHELL_ENV_KEEP ?? '<missing>'}\\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=<missing>/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=<missing>/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=<missing>/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 () => {
Expand Down
33 changes: 26 additions & 7 deletions packages/runtime/src/shell-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand All @@ -131,18 +150,15 @@ export function runShellWithBoundedTail(
command: string,
options: BoundedShellOptions,
): Promise<BoundedShellResult> {
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,
);
Expand All @@ -154,7 +170,10 @@ export function runProcessWithBoundedTail(
args: readonly string[],
options: BoundedShellOptions,
): Promise<BoundedShellResult> {
return runSpawnedProcessWithBoundedTail(program, args, false, options);
return runSpawnedProcessWithBoundedTail(program, args, false, {
...options,
env: buildUserCommandEnv(options.env ?? process.env),
});
}

function runSpawnedProcessWithBoundedTail(
Expand Down
19 changes: 7 additions & 12 deletions packages/runtime/src/shell-run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -735,6 +736,7 @@ export class ShellRunProcessManager
sessionEpoch: number,
onLiveAdmission: ((live: LiveShellRun) => void) | undefined,
): Promise<LivePipeShellRun> {
const env = buildUserCommandEnv(input.env ?? process.env);
const collector = new PipeTailCollector(this.maxRetainedChars);
const pending: Array<(live: LivePipeShellRun) => void> = [];
let live: LivePipeShellRun | undefined;
Expand All @@ -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,
Expand All @@ -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)),
Expand Down Expand Up @@ -813,6 +811,7 @@ export class ShellRunProcessManager
slotReservation: ShellRunSlotReservation,
sessionEpoch: number,
): Promise<LivePtyShellRun> {
const env = buildUserCommandEnv(input.env ?? process.env);
const pending: Array<(live: LivePtyShellRun) => void> = [];
let live: LivePtyShellRun | undefined;
let driver: PtyProcessDriver | undefined;
Expand All @@ -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,
Expand All @@ -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)),
Expand Down