// Exec tests cover command execution, output capture, and cancellation behavior. import type { ChildProcess } from "node:child_process"; import { EventEmitter, once } from "node:events"; import { closeSync, existsSync, openSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { setVerbose } from "../global-state.js"; import { isPidAlive } from "../shared/pid-alive.js"; import { readPidFile, waitForPidToExit } from "../test-utils/process-tree.js"; import { withTempDir } from "../test-utils/temp-dir.js"; import { attachChildProcessBridge } from "./child-process-bridge.js"; import * as execSpawn from "./exec-spawn.js"; import { resolveCommandEnv, resolveProcessExitCode, runCommandBuffered, runCommandWithTimeout, runExec, runUtf8CommandWithTimeout, shouldSpawnWithShell, } from "./exec.js"; const OPENCLAW_CLI_ENV_VALUE = "1"; describe("runCommandWithTimeout", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => { expect( shouldSpawnWithShell({ resolvedCommand: "npm.cmd", platform: "win32", }), ).toBe(false); }); it.skipIf(process.platform === "win32").each(["normal", "cooperative", "forced"] as const)( "reports invocation cleanup and honors the initial SIGINT signal: %s", async (mode) => { const controller = new AbortController(); let ready!: () => void; const started = new Promise((resolve) => { ready = resolve; }); const program = mode === "normal" ? "process.stdout.write('ready'); process.exitCode=17;" : `const timer=setInterval(()=>{},1000); process.on('SIGINT',()=>{${mode === "cooperative" ? "clearInterval(timer);process.stdout.write('interrupted');process.exitCode=17;" : ""}}); process.stdout.write('ready');`; const running = runCommandWithTimeout([process.execPath, "-e", program], { signal: controller.signal, killProcessTree: true, killSignal: "SIGINT", killGraceMs: 100, timeoutMs: 5000, onOutputChunk: () => { ready(); }, }); await started; if (mode !== "normal") { controller.abort(); } const result = await running; expect(result.cleanup).toBe(mode); if (mode !== "forced") { expect(result.code).toBe(17); } if (mode === "cooperative") { expect(result.stdout).toContain("interrupted"); } }, ); it.skipIf(process.platform === "win32")( "joins owned descendants even when a successful root closes its output", async () => { let descendant: number | undefined; try { const result = await runCommandWithTimeout( [ process.execPath, "-e", `const {spawn}=require('node:child_process'); const child=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});setInterval(()=>{},1000);process.send('ready')"],{stdio:['ignore','ignore','ignore','ipc']}); child.once('message',()=>{process.stdout.write(String(child.pid));child.disconnect();child.unref();});`, ], { killProcessTree: true, requireProcessTreeExtinction: true, killGraceMs: 50, timeoutMs: 10_000, onOutputChunk: (chunk) => { descendant = Number(chunk.toString()); }, }, ); expect(Number.isSafeInteger(descendant) && descendant! > 0).toBe(true); expect(result.code).toBe(0); expect(result.cleanup).toBe("forced"); expect(await waitForPidToExit(descendant!)).toBe(true); } finally { if (descendant && isPidAlive(descendant)) { process.kill(descendant, "SIGKILL"); await waitForPidToExit(descendant); } } }, ); it("merges custom env with base env and drops undefined values", () => { const resolved = resolveCommandEnv({ argv: ["node", "script.js"], baseEnv: { OPENCLAW_BASE_ENV: "base", OPENCLAW_CHILD_ENV_REMOVE: "base", OPENCLAW_TO_REMOVE: undefined, }, env: { OPENCLAW_CHILD_ENV_REMOVE: undefined, OPENCLAW_TEST_ENV: "ok", }, }); expect(resolved.OPENCLAW_BASE_ENV).toBe("base"); expect(resolved.OPENCLAW_CHILD_ENV_REMOVE).toBeUndefined(); expect(resolved.OPENCLAW_TEST_ENV).toBe("ok"); expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined(); expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); it("collapses case-insensitive duplicate env keys on Windows", () => { const resolved = resolveCommandEnv({ argv: ["node", "script.js"], platform: "win32", baseEnv: { Path: "C:\\base\\bin", OPENCLAW_BASE_ENV: "base", }, env: { PATH: "C:\\override\\bin", OPENCLAW_TEST_ENV: "ok", }, }); expect(resolved.Path).toBeUndefined(); expect(resolved.PATH).toBe("C:\\override\\bin"); expect(resolved.OPENCLAW_BASE_ENV).toBe("base"); expect(resolved.OPENCLAW_TEST_ENV).toBe("ok"); }); it("removes case-insensitive inherited env keys on Windows", () => { const resolved = resolveCommandEnv({ argv: ["node", "script.js"], platform: "win32", baseEnv: { Path: "C:\\base\\bin", }, env: { PATH: undefined, }, }); expect(resolved.Path).toBeUndefined(); expect(resolved.PATH).toBeUndefined(); }); it("preserves case-distinct env keys outside Windows", () => { const resolved = resolveCommandEnv({ argv: ["node", "script.js"], platform: "linux", baseEnv: { Path: "/base/bin" }, env: { PATH: "/override/bin" }, }); expect(resolved.Path).toBe("/base/bin"); expect(resolved.PATH).toBe("/override/bin"); }); it("does not restore parent variables excluded from the child environment", async () => { const key = "OPENCLAW_EXECA_PARENT_ONLY_TEST"; const previous = process.env[key]; process.env[key] = "parent-value"; try { const result = await runCommandWithTimeout( [process.execPath, "-e", `process.stdout.write(process.env.${key} ?? "missing")`], { timeoutMs: 2_000, baseEnv: {}, }, ); expect(result.stdout).toBe("missing"); } finally { if (previous === undefined) { delete process.env[key]; } else { process.env[key] = previous; } } }); it("suppresses npm fund prompts for npm argv", () => { const resolved = resolveCommandEnv({ argv: ["npm", "--version"], baseEnv: {} }); expect(resolved.NPM_CONFIG_FUND).toBe("false"); expect(resolved.npm_config_fund).toBe("false"); }); it("infers success for shimmed Windows commands when exit codes are missing", () => { expect( resolveProcessExitCode({ explicitCode: null, childExitCode: null, resolvedSignal: null, usesWindowsExitCodeShim: true, timedOut: false, noOutputTimedOut: false, killIssuedByTimeout: false, }), ).toBe(0); }); it("does not infer success after this process issued a timeout kill", () => { expect( resolveProcessExitCode({ explicitCode: null, childExitCode: null, resolvedSignal: null, usesWindowsExitCodeShim: true, timedOut: true, noOutputTimedOut: false, killIssuedByTimeout: true, }), ).toBeNull(); }); it("returns without spawning when the abort signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); await expect( runCommandWithTimeout([process.execPath, "-e", "process.exit(99)"], { timeoutMs: 2_000, signal: controller.signal, }), ).resolves.toMatchObject({ code: null, killed: false, noOutputTimedOut: false, signal: null, stderr: "", stdout: "", termination: "signal", }); }); it.runIf(process.platform !== "win32")( "normalizes a child-requested signal as command termination", async () => { const result = await runCommandWithTimeout( [process.execPath, "-e", "process.kill(process.pid, 'SIGTERM')"], { timeoutMs: 2_000 }, ); expect(result).toMatchObject({ code: null, signal: "SIGTERM", termination: "signal", }); }, ); it.runIf(process.platform !== "win32")( "uses the requested kill signal when a command times out", async () => { const result = await runCommandWithTimeout( [process.execPath, "-e", "setInterval(() => {}, 1_000)"], { timeoutMs: 20, killSignal: "SIGKILL" }, ); expect(result).toMatchObject({ signal: "SIGKILL", termination: "timeout", }); }, ); it.runIf(process.platform === "win32")( "rejects unresolved commands before Execa can fall through to ambient ComSpec", async () => { const command = `openclaw-missing-${process.pid}\r\ncalc.exe`; const previousComspec = process.env.comspec; process.env.comspec = process.execPath; try { await expect(runCommandWithTimeout([command], { timeoutMs: 2_000 })).rejects.toMatchObject({ code: "ENOENT", path: command, syscall: `spawn ${command}`, }); } finally { if (previousComspec === undefined) { delete process.env.comspec; } else { process.env.comspec = previousComspec; } } }, ); it.runIf(process.platform !== "win32")( "swallows stdin EPIPE when the child exits before input is consumed (#75438)", { timeout: 5_000 }, async () => { const result = await runCommandWithTimeout([process.execPath, "-e", "process.exit(0)"], { timeoutMs: 3_000, input: "this input will EPIPE because the child ignores stdin\n", }); expect(result.code).toBe(0); }, ); it.each([ [undefined, 2], [0, 0], [-1, 0], [1, 1], [2, 2], ])( "preserves matching output up to quota %s while tail capture continues", async (limit, count) => { const result = await runCommandWithTimeout( [ process.execPath, "-e", [ "process.stdout.write('Visit https://example.com/device and enter code ABCD-EFGH\\n')", "process.stdout.write('x'.repeat(200) + 'enter code TAIL')", ].join(";"), ], { timeoutMs: 3_000, maxOutputBytes: 24, maxPreservedOutputLines: limit, preserveOutputLine: (line) => line.includes("enter code"), }, ); const tail = `${"x".repeat(9)}enter code TAIL`; expect(result.stdout).toBe(tail); expect(result.stdoutTruncatedBytes).toBeGreaterThan(0); expect(result.preservedStdoutLines).toEqual( count ? ["Visit https://example.com/device and enter code ABCD-EFGH", tail].slice(0, count) : undefined, ); }, ); it.each([ ["long unterminated", "x".repeat(10_000), "x".repeat(24)], ["UTF-8 boundary", `😀${"x".repeat(22)}`, "x".repeat(22)], ])("bounds preserved %s line tails", async (_name, input, expected) => { const result = await runUtf8CommandWithTimeout( [process.execPath, "-e", "process.stdin.pipe(process.stdout)"], { input, timeoutMs: 3_000, maxOutputBytes: 24, preserveOutputLine: () => true, }, ); expect(result.stdout).toBe(expected); expect(result.stdoutTruncatedBytes).toBeGreaterThan(0); expect(result.preservedStdoutLines).toEqual([expected]); }); it("supports independent stdout head and stderr tail caps", async () => { const result = await runCommandWithTimeout( [ process.execPath, "-e", "process.stdout.write('abcdefgh'); process.stderr.write('12345678')", ], { maxOutputBytes: { stdout: 4, stderr: 4 }, outputCapture: { stdout: "head", stderr: "tail" }, timeoutMs: 3_000, }, ); expect(result.stdout).toBe("abcd"); expect(result.stderr).toBe("5678"); expect(result.stdoutTruncatedBytes).toBe(4); expect(result.stderrTruncatedBytes).toBe(4); }); it("caps combined output in arrival order", async () => { const result = await runCommandWithTimeout( [ process.execPath, "-e", "process.stdout.write('abcd'); setImmediate(() => process.stderr.write('efgh'))", ], { maxCombinedOutputBytes: 6, maxOutputBytes: 16, outputCapture: "head", timeoutMs: 3_000, }, ); expect(`${result.stdout}${result.stderr}`).toBe("abcdef"); expect((result.stdoutTruncatedBytes ?? 0) + (result.stderrTruncatedBytes ?? 0)).toBe(2); }); it("keeps the combined output tail when tail capture is selected", async () => { const result = await runCommandWithTimeout( [process.execPath, "-e", "process.stdout.write('abcdefgh')"], { maxCombinedOutputBytes: 4, maxOutputBytes: 16, outputCapture: "tail", timeoutMs: 3_000, }, ); expect(result.stdout).toBe("efgh"); expect(result.stdoutTruncatedBytes).toBe(4); }); it("does not treat combined overflow as a selected stream overflow", async () => { const result = await runCommandWithTimeout( [ process.execPath, "-e", "process.stderr.write('abcdefgh'); setImmediate(() => process.stdout.write('x'))", ], { maxCombinedOutputBytes: 8, maxOutputBytes: 16, outputCapture: "head", terminateOnOutputLimit: { stdout: true }, timeoutMs: 3_000, }, ); expect(result.termination).toBe("exit"); expect(result.outputLimitExceeded).toBeUndefined(); expect(result.stdout).toBe(""); expect(result.stderr).toBe("abcdefgh"); }); it("terminates commands that exceed a selected stream cap", async () => { const result = await runCommandWithTimeout( [ process.execPath, "-e", "process.stdout.write('x'.repeat(100)); setInterval(() => {}, 1000)", ], { maxOutputBytes: { stdout: 16, stderr: 16 }, outputCapture: "head", terminateOnOutputLimit: { stdout: true }, timeoutMs: 3_000, }, ); expect(result.outputLimitExceeded).toBe(true); expect(result.termination).toBe("signal"); expect(result.stdout).toBe("x".repeat(16)); }); it("rejects mixed capture modes under a combined cap", async () => { await expect( runCommandWithTimeout([process.execPath, "-e", "process.exit(0)"], { maxCombinedOutputBytes: 16, outputCapture: { stdout: "head", stderr: "tail" }, timeoutMs: 3_000, }), ).rejects.toThrow("maxCombinedOutputBytes requires matching stdout and stderr capture modes"); }); it("observes discarded output and stops without retaining it", async () => { let observedBytes = 0; const result = await runCommandWithTimeout( [ process.execPath, "-e", "process.stdout.write('x'.repeat(1024 * 1024)); setInterval(() => {}, 1000)", ], { onOutputChunk: (chunk, stream) => { if (stream !== "stdout") { return true; } observedBytes += chunk.byteLength; return observedBytes < 32 * 1024; }, outputCapture: { stdout: "discard", stderr: "tail" }, timeoutMs: 3_000, }, ); expect(observedBytes).toBeGreaterThanOrEqual(32 * 1024); expect(result.stdout).toBe(""); expect(result.stdoutTruncatedBytes).toBeGreaterThanOrEqual(observedBytes); expect(result.outputLimitExceeded).toBe(true); expect(result.termination).toBe("signal"); }); it.each([ ["tail", Buffer.from("a😀z"), 3, "z", 5], ["head", Buffer.from("abcdef"), 4, "abcd", 2], ["head", Buffer.from("a¢z"), 2, "a", 3], ["head", Buffer.from("a€z"), 3, "a", 4], ["head", Buffer.from("a😀z"), 4, "a", 5], ["head", Buffer.from("\ufeffa😀z"), 6, "\ufeffa", 5], ["head", Buffer.from([0x61, 0xff, 0x62, 0xe2, 0x82, 0xac, 0x7a]), 5, "a�b�", 2], ] as const)( "preserves truncated UTF-8 %s output (%#)", async (outputCapture, input, maxOutputBytes, expected, truncatedBytes) => { const result = await runUtf8CommandWithTimeout( [process.execPath, "-e", "process.stdin.pipe(process.stdout)"], { input, maxOutputBytes, outputCapture, timeoutMs: 3_000, }, ); expect(result.stdout).toBe(expected); expect(result.stdoutTruncatedBytes).toBe(truncatedBytes); }, ); it.each([1, 2, 3])( "discards an entirely partial UTF-8 head at %i bytes", async (maxOutputBytes) => { const result = await runUtf8CommandWithTimeout( [process.execPath, "-e", "process.stdout.write('😀')"], { maxOutputBytes, outputCapture: "head", timeoutMs: 3_000, }, ); expect(result.stdout).toBe(""); expect(result.stdoutTruncatedBytes).toBe(4); }, ); it("keeps argv values out of transport errors", async () => { const privateArg = "private-command-argument"; const error = await runCommandWithTimeout( [`openclaw-missing-${process.pid}-${Date.now()}`, "--token", privateArg], { timeoutMs: 3_000 }, ).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(Error); expect(String(error)).not.toContain(privateArg); expect(error).toMatchObject({ code: "ENOENT" }); }); }); describe("runCommandBuffered", () => { it("preserves binary output and nonzero exit details", async () => { const result = await runCommandBuffered( [ process.execPath, "-e", "process.stdout.write(Buffer.from([0xff, 0, 0x61])); process.stderr.write('bad'); process.exit(7)", ], { timeoutMs: 3_000 }, ); expect(result).toMatchObject({ code: 7, termination: "exit" }); expect(result.stdout).toEqual(Buffer.from([0xff, 0, 0x61])); expect(result.stderr).toEqual(Buffer.from("bad")); }); it("reports the stream that exceeded its output cap", async () => { const result = await runCommandBuffered( [process.execPath, "-e", "void process.stderr; process.stdout.write('x'.repeat(100))"], { maxOutputBytes: { stdout: 16, stderr: 32 }, timeoutMs: 3_000 }, ); expect(result.termination).toBe("output-limit"); expect(result.outputLimitStream).toBe("stdout"); expect(result.stdout.byteLength).toBeLessThanOrEqual(16); }); it("caps stdout and stderr under one aggregate output budget", async () => { const result = await runCommandBuffered( [ process.execPath, "-e", "process.stdout.write('abcd'); setImmediate(() => process.stderr.write('efgh'))", ], { maxCombinedOutputBytes: 6, maxOutputBytes: 8, timeoutMs: 3_000, }, ); expect(result.termination).toBe("output-limit"); expect(result.outputLimitStream).toBe("stderr"); expect(result.stdout.byteLength + result.stderr.byteLength).toBe(6); }); it("maps timeout and pre-aborted signals without throwing", async () => { const timedOut = await runCommandBuffered( [process.execPath, "-e", "setInterval(() => {}, 1_000)"], { timeoutMs: 20 }, ); expect(timedOut.termination).toBe("timeout"); const controller = new AbortController(); controller.abort(new Error("stop")); await expect( runCommandBuffered([process.execPath, "-e", "process.exit(99)"], { signal: controller.signal, }), ).resolves.toMatchObject({ code: null, termination: "signal", error: new Error("stop") }); }); it.runIf(process.platform !== "win32").each([ { exitCode: 0, escaped: false, timeoutMs: 50 }, { exitCode: 7, escaped: false, timeoutMs: 50 }, { exitCode: 0, escaped: true, timeoutMs: 250 }, ])( "drains descendants on failure or the post-success timeout (exit $exitCode, escaped=$escaped)", { timeout: 5_000 }, async ({ exitCode, escaped, timeoutMs }) => withTempDir("openclaw-exec-descendant-", async (dir) => { const pidPath = path.join(dir, "descendant.pid"); const termPath = path.join(dir, "sigterm"); // Acknowledge only after the handler and keepalive exist. Stay quiet so // inherited-pipe release cannot kill the descendant through EPIPE. const descendantSource = [ "const { writeFileSync } = require('node:fs')", `process.on('SIGTERM', () => writeFileSync(${JSON.stringify(termPath)}, 'handled'))`, "setInterval(() => {}, 1_000)", "process.send('ready')", ].join(";"); const parentSource = [ "const { spawn } = require('node:child_process')", "const { writeFileSync } = require('node:fs')", `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendantSource)}], { detached: ${escaped}, stdio: ['ignore', 'inherit', 'inherit', 'ipc'] })`, `writeFileSync(${JSON.stringify(pidPath)}, String(child.pid))`, `child.once('message', () => process.exit(${exitCode}))`, ].join(";"); const realSetTimeout = setTimeout; const spawnSpy = vi.spyOn(execSpawn, "spawnCommandWithInvocation"); let parent: ChildProcess | undefined; let descendantPid: number | undefined; let command: ReturnType | undefined; // Freeze deadlines, not subprocess I/O: Node startup must not consume the // timeout or the 100ms inherited-pipe idle grace. Polling must stay real. vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); try { let settled = false; command = runCommandBuffered([process.execPath, "-e", parentSource], { timeoutMs, }).then((result) => { settled = true; return result; }); const spawnResult = spawnSpy.mock.results[0]; if (spawnResult?.type !== "return") { throw new Error("command did not spawn"); } parent = spawnResult.value.child.nodeChildProcess; if (!parent) { throw new Error("command did not expose a child process"); } expect(await once(parent, "exit", { signal: AbortSignal.timeout(2_000) })).toEqual([ exitCode, null, ]); descendantPid = await readPidFile(pidPath); expect(isPidAlive(descendantPid)).toBe(true); expect(settled).toBe(false); if (escaped) { // This pipe holder has its own group: root-group termination cannot // close its pipes. Quiet successful output still belongs to the deadline. await vi.advanceTimersByTimeAsync(101); await new Promise((resolve) => { setImmediate(resolve); }); expect(parent.stdout?.destroyed).toBe(false); expect(parent.stderr?.destroyed).toBe(false); expect(settled).toBe(false); expect(isPidAlive(descendantPid)).toBe(true); expect(existsSync(termPath)).toBe(false); // Bound the real close observation separately from the frozen policy // clock so missing post-termination release still reaches test cleanup. const closed = once(parent, "close", { signal: AbortSignal.timeout(1_000) }); await vi.advanceTimersByTimeAsync(timeoutMs - 101); await vi.advanceTimersByTimeAsync(100); // Output release runs in the next timers phase so buffered pipe I/O // gets a poll turn on both Node and Bun. await vi.advanceTimersByTimeAsync(1); await closed; expect(await command).toMatchObject({ code: null, termination: "timeout" }); expect(isPidAlive(descendantPid)).toBe(true); expect(existsSync(termPath)).toBe(false); return; } if (exitCode === 0) { expect(existsSync(termPath)).toBe(false); await vi.advanceTimersByTimeAsync(50); } for (let attempt = 0; attempt < 40 && !existsSync(termPath); attempt += 1) { await new Promise((resolve) => { realSetTimeout(resolve, 25); }); } expect(existsSync(termPath)).toBe(true); expect(isPidAlive(descendantPid)).toBe(true); expect(settled).toBe(false); await vi.advanceTimersByTimeAsync(execSpawn.COMMAND_PROCESS_TREE_KILL_GRACE_MS); // Force delivery now has a separate bounded exit-observation phase. await vi.advanceTimersByTimeAsync(execSpawn.COMMAND_PROCESS_TREE_KILL_GRACE_MS); expect(await command).toMatchObject( exitCode === 0 ? { code: null, termination: "timeout" } : { code: exitCode, termination: "exit" }, ); vi.useRealTimers(); expect(await waitForPidToExit(descendantPid)).toBe(true); } finally { try { // Record the spawned descendant before its readiness acknowledgement, // so even an early root/IPC failure can reap the explicitly owned group. if (descendantPid === undefined && existsSync(pidPath)) { descendantPid = await readPidFile(pidPath); } for (const groupPid of [parent?.pid, escaped ? descendantPid : undefined]) { if (groupPid === undefined || !Number.isInteger(groupPid) || groupPid <= 0) { continue; } try { process.kill(-groupPid, "SIGKILL"); } catch { // Already gone. } } if (vi.isFakeTimers()) { await vi.runAllTimersAsync(); } } finally { vi.useRealTimers(); spawnSpy.mockRestore(); } await command; if (parent?.pid) { expect(await waitForPidToExit(parent.pid)).toBe(true); } if (descendantPid !== undefined) { expect(await waitForPidToExit(descendantPid)).toBe(true); } } }), ); it.runIf(process.platform !== "win32")( "preserves a child-requested signal in buffered results", async () => { const result = await runCommandBuffered( [process.execPath, "-e", "process.kill(process.pid, 'SIGTERM')"], { timeoutMs: 2_000 }, ); expect(result).toMatchObject({ code: null, signal: "SIGTERM", termination: "signal" }); expect(result.error).toBeUndefined(); }, ); it("can discard a diagnostic stream without applying its byte cap", async () => { const result = await runCommandBuffered( [ process.execPath, "-e", "process.stderr.write('x'.repeat(1024)); process.stdout.write('ok')", ], { discardOutput: { stderr: true }, maxOutputBytes: { stdout: 32, stderr: 8 }, timeoutMs: 3_000, }, ); expect(result).toMatchObject({ code: 0, termination: "exit" }); expect(result.stdout).toEqual(Buffer.from("ok")); expect(result.stderr).toEqual(Buffer.alloc(0)); }); it("keeps argv values out of buffered transport errors", async () => { const privateArg = "private-buffered-argument"; const result = await runCommandBuffered( [`openclaw-missing-${process.pid}-${Date.now()}`, privateArg], { timeoutMs: 3_000 }, ); expect(result).toMatchObject({ code: null, termination: "error" }); expect(result.error).toMatchObject({ code: "ENOENT" }); expect(result.error?.message).not.toContain(privateArg); }); }); describe("runExec", () => { it("captures stdout and stderr", async () => { await expect( runExec(process.execPath, ["-e", "process.stdout.write('ok'); process.stderr.write('warn')"]), ).resolves.toEqual({ stdout: "ok", stderr: "warn" }); }); it("preserves the numeric exit code on command failures", async () => { await expect(runExec(process.execPath, ["-e", "process.exit(7)"])).rejects.toMatchObject({ code: 7, exitCode: 7, }); }); it("supports stdin and an explicit base environment", async () => { const { stdout, stderr } = await runExec( process.execPath, [ "-e", "process.stdin.pipe(process.stdout); process.stderr.write(process.env.OPENCLAW_RUN_EXEC_TEST ?? 'missing')", ], { baseEnv: { OPENCLAW_RUN_EXEC_TEST: "base" }, input: Buffer.from("input"), timeoutMs: 3_000, }, ); expect(stdout).toBe("input"); expect(stderr).toBe("base"); }); it("supports an inherited file descriptor as stdin", async () => { const descriptor = openSync(fileURLToPath(import.meta.url), "r"); let running: ReturnType; try { running = runExec(process.execPath, ["-e", "process.stdin.pipe(process.stdout)"], { stdinFileDescriptor: descriptor, timeoutMs: 3_000, }); } finally { // The child must own stdin before control returns to the caller. closeSync(descriptor); } const { stdout } = await running; expect(stdout).toContain("// Exec tests cover command execution"); }); it("can keep sensitive output out of verbose logs", async () => { const stdoutSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setVerbose(true); try { await runExec( process.execPath, ["-e", "process.stdout.write('private-out'); process.stderr.write('private-err')"], { logOutput: false }, ); await expect( runExec( process.execPath, ["-e", "process.stderr.write('private-failure'); process.exit(2)"], { logOutput: false }, ), ).rejects.toMatchObject({ code: 2 }); } finally { setVerbose(false); } expect(stdoutSpy.mock.calls.flat().join(" ")).not.toContain("private-out"); expect(stderrSpy.mock.calls.flat().join(" ")).not.toMatch(/private-err|private-failure/u); }); }); describe("attachChildProcessBridge", () => { it("forwards SIGTERM to the wrapped child and detaches on exit", () => { const beforeSigterm = new Set(process.listeners("SIGTERM")); const child = new EventEmitter() as EventEmitter & ChildProcess; const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true); child.kill = kill as ChildProcess["kill"]; const observedSignals: NodeJS.Signals[] = []; const { detach } = attachChildProcessBridge(child, { signals: ["SIGTERM"], onSignal: (signal) => observedSignals.push(signal), }); const addedSigterm = process .listeners("SIGTERM") .find((listener) => !beforeSigterm.has(listener)); if (!addedSigterm) { throw new Error("expected SIGTERM listener"); } addedSigterm("SIGTERM"); expect(observedSignals).toEqual(["SIGTERM"]); expect(kill).toHaveBeenCalledWith("SIGTERM"); child.emit("exit"); expect(process.listeners("SIGTERM")).toHaveLength(beforeSigterm.size); detach(); }); }); describe("child input admission", () => { it("publishes input only after binding the actual spawned PID and argv", async () => { let admittedPid: number | undefined; let admittedArgv: readonly string[] | undefined; const result = await runCommandWithTimeout( [ process.execPath, "-e", "let input='';process.stdin.on('data',x=>input+=x);process.stdin.on('end',()=>process.stdout.write(JSON.stringify({pid:process.pid,argv:[process.argv0,...process.execArgv,...process.argv.slice(1)],input})))", ], { input: "owned", timeoutMs: 5_000, beforeInput: (pid, argv) => { admittedPid = pid; admittedArgv = argv; }, }, ); expect(result.code).toBe(0); expect(admittedArgv).toBeDefined(); expect(JSON.parse(result.stdout)).toEqual({ pid: admittedPid, argv: admittedArgv, input: "owned", }); }); it("joins the child without delivering input when admission rejects", async () => { let pid: number | undefined; const refusal = new Error("authority lost before input"); const work = runCommandWithTimeout( [ process.execPath, "-e", "process.stdin.on('data',()=>process.stdout.write('effect'));setInterval(()=>{},1000)", ], { input: "forbidden", timeoutMs: 5_000, killProcessTree: true, beforeInput: (childPid) => { pid = childPid; throw refusal; }, }, ); await expect(work).rejects.toBe(refusal); expect(pid).toBeTypeOf("number"); expect(isPidAlive(pid!)).toBe(false); }); it("rejects asynchronous admission and drains its rejection before returning", async () => { let pid: number | undefined; const options = { input: "forbidden", timeoutMs: 5_000, killProcessTree: true }; // Model an untyped JS caller; the typed callback contract forbids a Promise. Reflect.set(options, "beforeInput", async (childPid: number) => { pid = childPid; throw new Error("late refusal"); }); const work = runCommandWithTimeout( [process.execPath, "-e", "process.stdin.resume();setInterval(()=>{},1000)"], options, ); await expect(work).rejects.toThrow("must complete synchronously"); expect(isPidAlive(pid!)).toBe(false); }); });