| |
| 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<void>((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"); |
| |
| |
| 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<typeof runCommandBuffered> | undefined; |
| |
| |
| 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) { |
| |
| |
| await vi.advanceTimersByTimeAsync(101); |
| await new Promise<void>((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); |
|
|
| |
| |
| const closed = once(parent, "close", { signal: AbortSignal.timeout(1_000) }); |
| await vi.advanceTimersByTimeAsync(timeoutMs - 101); |
| await vi.advanceTimersByTimeAsync(100); |
| |
| |
| 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<void>((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); |
| |
| 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 { |
| |
| |
| 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 { |
| |
| } |
| } |
| 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<typeof runExec>; |
| try { |
| running = runExec(process.execPath, ["-e", "process.stdin.pipe(process.stdout)"], { |
| stdinFileDescriptor: descriptor, |
| timeoutMs: 3_000, |
| }); |
| } finally { |
| |
| 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 }; |
| |
| 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); |
| }); |
| }); |
|
|