fix(process): clamp execfile timeouts

This commit is contained in:
Vincent Koc
2026-06-22 01:10:41 +02:00
parent 77b6ca9a9b
commit 66b94ba577
2 changed files with 34 additions and 6 deletions

View File

@@ -2,34 +2,39 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import process from "node:process";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
const spawnMock = vi.hoisted(() => vi.fn());
const execFileMock = vi.hoisted(() => vi.fn());
const execFilePromiseMock = vi.hoisted(() => vi.fn());
let attachChildProcessBridge: typeof import("./child-process-bridge.js").attachChildProcessBridge;
let resolveCommandEnv: typeof import("./exec.js").resolveCommandEnv;
let resolveProcessExitCode: typeof import("./exec.js").resolveProcessExitCode;
let runExec: typeof import("./exec.js").runExec;
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
let shouldSpawnWithShell: typeof import("./exec.js").shouldSpawnWithShell;
async function loadExecModules(options?: { mockSpawn?: boolean }) {
async function loadExecModules(options?: { mockSpawn?: boolean; mockExecFile?: boolean }) {
vi.resetModules();
if (options?.mockSpawn) {
if (options?.mockSpawn || options?.mockExecFile) {
vi.doMock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
spawn: options?.mockSpawn ? spawnMock : actual.spawn,
execFile: options?.mockExecFile ? execFileMock : actual.execFile,
};
});
} else {
vi.doUnmock("node:child_process");
}
({ attachChildProcessBridge } = await import("./child-process-bridge.js"));
({ resolveCommandEnv, resolveProcessExitCode, runCommandWithTimeout, shouldSpawnWithShell } =
({ resolveCommandEnv, resolveProcessExitCode, runCommandWithTimeout, runExec, shouldSpawnWithShell } =
await import("./exec.js"));
}
@@ -85,6 +90,9 @@ describe("runCommandWithTimeout", () => {
beforeEach(async () => {
vi.useRealTimers();
spawnMock.mockReset();
execFileMock.mockReset();
execFilePromiseMock.mockReset();
delete (execFileMock as { [promisify.custom]?: unknown })[promisify.custom];
await loadExecModules();
});
@@ -166,6 +174,23 @@ describe("runCommandWithTimeout", () => {
expect(resolved.npm_config_fund).toBe("false");
});
it("caps oversized execFile timeouts", async () => {
execFilePromiseMock.mockResolvedValue({ stdout: Buffer.from("ok"), stderr: Buffer.from("") });
(execFileMock as { [promisify.custom]?: typeof execFilePromiseMock })[promisify.custom] =
execFilePromiseMock;
await loadExecModules({ mockExecFile: true });
await expect(
runExec("node", ["--version"], { timeoutMs: Number.MAX_SAFE_INTEGER }),
).resolves.toEqual({ stdout: "ok", stderr: "" });
expect(execFilePromiseMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ timeout: MAX_TIMER_TIMEOUT_MS }),
);
});
it("infers success for shimmed Windows commands when exit codes are missing", () => {
expect(
resolveProcessExitCode({

View File

@@ -154,9 +154,12 @@ export async function runExec(
): Promise<{ stdout: string; stderr: string }> {
const options =
typeof opts === "number"
? { timeout: opts, encoding: "buffer" as const }
? { timeout: resolveTimerTimeoutMs(opts, 1), encoding: "buffer" as const }
: {
timeout: opts.timeoutMs,
timeout:
typeof opts.timeoutMs === "number"
? resolveTimerTimeoutMs(opts.timeoutMs, 1)
: undefined,
maxBuffer: opts.maxBuffer,
cwd: opts.cwd,
encoding: "buffer" as const,