diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index d4acab11d5f..79fe54a1056 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -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("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({ diff --git a/src/process/exec.ts b/src/process/exec.ts index 1fc2f45442d..476dac5c4fb 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -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,