diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a64fceaf677a..8eeaa3a800b6 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -35e24e7461ffc83dba13cdc56f1b1b1efcf5a11d7067392ddb275b879208d633 plugin-sdk-api-baseline.json -6eb82047169b07702451bbf59c9430be429efd3a60baa04076f70598c32dfb12 plugin-sdk-api-baseline.jsonl +0bbd37802d7330a5639480fd15a4f97131b996423136598fc8bd20719d6ad009 plugin-sdk-api-baseline.json +7b889420c787ac6c16fff3f9ef7ff0136f7387c1c695b7991f458b5c72f57818 plugin-sdk-api-baseline.jsonl diff --git a/extensions/acpx/src/process-reaper.ts b/extensions/acpx/src/process-reaper.ts index 1f834193364d..6e80fa53870a 100644 --- a/extensions/acpx/src/process-reaper.ts +++ b/extensions/acpx/src/process-reaper.ts @@ -2,15 +2,13 @@ * ACPX process ownership checks and cleanup. The reaper only terminates * OpenClaw-owned wrapper trees after validating paths, packages, and lease ids. */ -import { execFile } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; -import { promisify } from "node:util"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { splitCommandParts } from "./command-line.js"; import { resolveAcpxPluginRoot } from "./config.js"; import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js"; -const execFileAsync = promisify(execFile); const requireFromHere = createRequire(import.meta.url); const GENERATED_WRAPPER_BASENAMES = new Set([ "codex-acp-wrapper.mjs", @@ -218,7 +216,8 @@ async function listPlatformProcesses(): Promise { if (process.platform === "win32") { return []; } - const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], { + const { stdout } = await runExec("ps", ["-axo", "pid=,ppid=,command="], { + logOutput: false, maxBuffer: 8 * 1024 * 1024, }); return parseProcessList(stdout); diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index 3c2a3a7fb888..ebb29d806715 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -4,13 +4,11 @@ * Manages chrome-devtools-mcp processes and sessions, maps Browser actions to * MCP tools, and exposes tab/snapshot/action helpers for logged-in browsers. */ -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { setTimeout as sleepTimeout } from "node:timers/promises"; -import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; @@ -19,6 +17,7 @@ import { addTimerTimeoutGraceMs, resolveNonNegativeIntegerOption, } from "openclaw/plugin-sdk/number-runtime"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { normalizeOptionalString, readStringValue, @@ -217,7 +216,6 @@ const CHROME_MCP_SNAPSHOT_REF_PREFIX = "mcp-ref:"; class ChromeMcpReconnectRequiredError extends Error {} class ChromeMcpProcessSnapshotError extends Error {} -const execFileAsync = promisify(execFile); const sessions = new Map(); const pendingSessions = new Map(); const retainedCleanupSessions = new Map>(); @@ -800,7 +798,7 @@ async function listChromeMcpPlatformProcesses( return await listChromeMcpLinuxProcesses(); } const windows = platform === "win32"; - const { stdout } = await execFileAsync( + const { stdout } = await runExec( windows ? "powershell.exe" : "ps", windows ? [ @@ -812,9 +810,9 @@ async function listChromeMcpPlatformProcesses( : ["-axww", "-o", "pid=,ppid=,lstart=,command="], { env: windows ? undefined : { ...process.env, LC_ALL: "C", TZ: "UTC" }, + logOutput: false, maxBuffer: 4 * 1024 * 1024, - timeout: 2_000, - windowsHide: windows, + timeoutMs: 2_000, }, ); if (windows) { @@ -934,10 +932,10 @@ async function taskkillChromeMcpProcessTree( await deps.taskkillProcessTree(rootPid); return; } - await execFileAsync("taskkill", ["/pid", String(rootPid), "/t", "/f"], { + await runExec("taskkill", ["/pid", String(rootPid), "/t", "/f"], { + logOutput: false, maxBuffer: 64 * 1024, - timeout: 2_000, - windowsHide: true, + timeoutMs: 2_000, }); } diff --git a/extensions/browser/src/browser/system-chrome-cookies.ts b/extensions/browser/src/browser/system-chrome-cookies.ts index 3eb5c259bf12..0058e02a150f 100644 --- a/extensions/browser/src/browser/system-chrome-cookies.ts +++ b/extensions/browser/src/browser/system-chrome-cookies.ts @@ -1,7 +1,7 @@ /** macOS Chrome-family cookie database decryption and Playwright mapping. */ -import { execFile } from "node:child_process"; import crypto from "node:crypto"; import { DatabaseSync } from "node:sqlite"; +import { runCommandBuffered } from "openclaw/plugin-sdk/process-runtime"; export type SystemBrowser = "chrome" | "brave" | "edge" | "chromium"; @@ -69,47 +69,45 @@ function isAsciiWhitespace(value: number): boolean { /** Read the browser Safe Storage secret. The OS consent prompt is intentional. */ async function readKeychainSecret(entry: KeychainEntry, signal?: AbortSignal): Promise { signal?.throwIfAborted(); - return await new Promise((resolve, reject) => { - execFile( - "security", - ["find-generic-password", "-w", "-s", entry.service, "-a", entry.account], - { encoding: "buffer", signal }, - (error, stdout) => { - if (error) { - if (signal?.aborted) { - reject( - signal.reason instanceof Error - ? signal.reason - : new Error("Browser cookie import aborted.", { cause: signal.reason ?? error }), - ); - return; - } - reject( - new Error( - `could not read ${entry.service} from macOS Keychain; approve the prompt and retry`, - ), - ); - return; - } - const raw = Buffer.from(stdout); - let start = 0; - let end = raw.length; - while (start < end && isAsciiWhitespace(raw.readUInt8(start))) { - start += 1; - } - while (end > start && isAsciiWhitespace(raw.readUInt8(end - 1))) { - end -= 1; - } - const secret = Buffer.from(raw.subarray(start, end)); - raw.fill(0); - if (secret.length === 0) { - reject(new Error(`macOS Keychain returned an empty ${entry.service} secret`)); - return; - } - resolve(secret); + let stdout: Buffer; + try { + const result = await runCommandBuffered( + ["security", "find-generic-password", "-w", "-s", entry.service, "-a", entry.account], + { + signal, + maxOutputBytes: 1024 * 1024, }, ); - }); + if (result.termination !== "exit" || result.code !== 0) { + throw result.error ?? new Error(`security exited with code ${result.code ?? "unknown"}`); + } + stdout = result.stdout; + } catch (error) { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new Error("Browser cookie import aborted.", { cause: signal.reason ?? error }); + } + throw new Error( + `could not read ${entry.service} from macOS Keychain; approve the prompt and retry`, + { cause: error }, + ); + } + const raw = stdout; + let start = 0; + let end = raw.length; + while (start < end && isAsciiWhitespace(raw.readUInt8(start))) { + start += 1; + } + while (end > start && isAsciiWhitespace(raw.readUInt8(end - 1))) { + end -= 1; + } + const secret = Buffer.from(raw.subarray(start, end)); + raw.fill(0); + if (secret.length === 0) { + throw new Error(`macOS Keychain returned an empty ${entry.service} secret`); + } + return secret; } /** Convert Chromium's Windows-epoch microseconds to Unix seconds. */ diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts index a9f0a60f5865..fc12d018f46e 100644 --- a/extensions/codex/src/app-server/computer-use.ts +++ b/extensions/codex/src/app-server/computer-use.ts @@ -2,9 +2,8 @@ * Computer Use plugin/MCP readiness checks and optional install flow for Codex * app-server sessions. */ -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { promisify } from "node:util"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { describeControlFailure } from "./capabilities.js"; import { isCodexAppServerConnectionClosedError, @@ -191,7 +190,6 @@ const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000; const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"]; const COMPUTER_USE_LIVE_TEST_RETRY_COUNT = 1; const COMPUTER_USE_LIVE_TEST_THREAD_NAME = "OpenClaw Computer Use readiness probe"; -const execFileAsync = promisify(execFile); /** Reads Computer Use readiness without installing or mutating app-server state. */ export async function readCodexComputerUseStatus( @@ -1119,7 +1117,8 @@ export async function killStaleComputerUseMcpChildren( } let stdout: string; try { - const result = await execFileAsync("/bin/ps", ["-axo", "pid=,ppid=,command="], { + const result = await runExec("/bin/ps", ["-axo", "pid=,ppid=,command="], { + logOutput: false, maxBuffer: 5 * 1024 * 1024, }); stdout = result.stdout; diff --git a/extensions/file-transfer/src/node-host/dir-fetch.stream-errors.test.ts b/extensions/file-transfer/src/node-host/dir-fetch.stream-errors.test.ts index edec924e1367..f8d1d2c025c2 100644 --- a/extensions/file-transfer/src/node-host/dir-fetch.stream-errors.test.ts +++ b/extensions/file-transfer/src/node-host/dir-fetch.stream-errors.test.ts @@ -1,129 +1,73 @@ -// File Transfer tests cover dir fetch child-output failures. -import crypto from "node:crypto"; -import { EventEmitter } from "node:events"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +// File Transfer tests cover canonical process-wrapper failures during dir fetch. import { afterEach, describe, expect, it, vi } from "vitest"; -type MockChild = EventEmitter & { - kill: ReturnType; - stderr: EventEmitter; - stdin: EventEmitter & { end: () => void }; - stdout: EventEmitter; -}; +const { runCommandBufferedMock } = vi.hoisted(() => ({ runCommandBufferedMock: vi.fn() })); -function mockSpawn(script: (child: MockChild) => void, startOnStdin = false) { - return vi.fn(() => { - const child = new EventEmitter() as MockChild; - child.kill = vi.fn(); - child.stderr = new EventEmitter(); - child.stdin = new EventEmitter() as MockChild["stdin"]; - child.stdout = new EventEmitter(); - child.stdin.end = () => { - if (startOnStdin) { - queueMicrotask(() => script(child)); - } - }; - if (!startOnStdin) { - queueMicrotask(() => script(child)); - } - return child; - }); -} +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandBuffered: runCommandBufferedMock, +})); -async function importWithSpawn(spawnMock: ReturnType) { - vi.resetModules(); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; - }); - return await import("./dir-fetch.js"); +import { testing } from "./dir-fetch.js"; + +function commandResult(overrides: Record = {}) { + return { + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, + }; } afterEach(() => { - vi.doUnmock("node:child_process"); - vi.resetModules(); + runCommandBufferedMock.mockReset(); }); -describe("dir.fetch child output lifecycle", () => { - it.runIf(process.platform !== "win32")( - "returns READ_ERROR when real tar archive stdout fails", - async () => { - const tempPath = path.join(os.tmpdir(), `dir-fetch-stream-error-${crypto.randomUUID()}`); - await fs.mkdir(tempPath); - const tmpRoot = await fs.realpath(tempPath); - await fs.writeFile(path.join(tmpRoot, "payload.bin"), Buffer.alloc(1024 * 1024, 1)); - vi.resetModules(); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: vi.fn( - ( - command: string, - args: readonly string[], - options: Parameters[2], - ) => { - const child = actual.spawn(command, [...args], options); - if (command === "/usr/bin/tar" && args[0] === "-czf") { - const stdout = child.stdout; - if (!stdout) { - throw new Error("expected piped tar stdout"); - } - queueMicrotask(() => stdout.destroy(new Error("injected archive read failure"))); - } - return child; - }, - ), - }; - }); - - try { - const { handleDirFetch } = await import("./dir-fetch.js"); - await expect(handleDirFetch({ path: tmpRoot })).resolves.toMatchObject({ - ok: false, - code: "READ_ERROR", - message: "tar command failed", - }); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } - }, - ); - - it("stops a broken du read and falls back to capped tar", async () => { - const spawnMock = mockSpawn((child) => { - child.stdout.emit("error", new Error("du read failed")); - child.emit("close", 0); - }); - const { testing } = await importWithSpawn(spawnMock); +describe("dir.fetch process wrapper", () => { + it("falls back to capped tar when the optional du probe fails", async () => { + runCommandBufferedMock.mockRejectedValueOnce(new Error("du failed")); await expect(testing.preflightDu("/tmp/project", 1024)).resolves.toBe(true); - expect(spawnMock.mock.results[0]?.value.kill).toHaveBeenCalledOnce(); + expect(runCommandBufferedMock).toHaveBeenCalledWith( + ["du", "-sk", "/tmp/project"], + expect.objectContaining({ discardOutput: { stderr: true } }), + ); }); - it("fails tar entry listing closed on stdout errors", async () => { - const spawnMock = mockSpawn((child) => { - child.stdout.emit("data", Buffer.from("partial.txt\n")); - child.stdout.emit("error", new Error("listing read failed")); - child.emit("close", 0); - }, true); - const { testing } = await importWithSpawn(spawnMock); + it("fails tar entry listing closed on wrapper errors", async () => { + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ code: null, termination: "error", error: new Error("listing failed") }), + ); await expect(testing.listTarEntries(Buffer.from("archive"))).resolves.toBeNull(); - expect(spawnMock.mock.results[0]?.value.kill).toHaveBeenCalledOnce(); + expect(runCommandBufferedMock).toHaveBeenCalledWith( + ["tar", "-tzf", "-"], + expect.objectContaining({ discardOutput: { stderr: true } }), + ); }); - it("fails archive creation closed on stdout errors", async () => { - const spawnMock = mockSpawn((child) => { - child.stdout.emit("data", Buffer.from("partial archive")); - child.stdout.emit("error", new Error("archive read failed")); - child.emit("close", 0); - }); - const { testing } = await importWithSpawn(spawnMock); + it("classifies archive output caps, timeouts, and launch errors", async () => { + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ + code: null, + termination: "output-limit", + outputLimitStream: "stdout", + }), + ); + await expect(testing.createTarArchive("/tmp/project", 1024)).resolves.toBe("TOO_LARGE"); + expect(runCommandBufferedMock).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ discardOutput: { stderr: true } }), + ); + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ code: null, termination: "timeout" }), + ); + await expect(testing.createTarArchive("/tmp/project", 1024)).resolves.toBe("TIMEOUT"); + + runCommandBufferedMock.mockRejectedValueOnce(new Error("spawn failed")); await expect(testing.createTarArchive("/tmp/project", 1024)).resolves.toBe("ERROR"); - expect(spawnMock.mock.results[0]?.value.kill).toHaveBeenCalledOnce(); }); }); diff --git a/extensions/file-transfer/src/node-host/dir-fetch.ts b/extensions/file-transfer/src/node-host/dir-fetch.ts index 7ad94f1c80d3..3f843413f2a5 100644 --- a/extensions/file-transfer/src/node-host/dir-fetch.ts +++ b/extensions/file-transfer/src/node-host/dir-fetch.ts @@ -1,9 +1,8 @@ // File Transfer plugin module implements dir fetch behavior. -import { spawn } from "node:child_process"; import crypto from "node:crypto"; import path from "node:path"; +import { runCommandBuffered } from "openclaw/plugin-sdk/process-runtime"; import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime"; -import { consumeChildOutput } from "../shared/child-output.js"; import { classifyFsSafeReadError, readAbsolutePath, @@ -73,125 +72,34 @@ async function preflightDu(dirPath: string, maxBytes: number): Promise // du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k) // We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses) const heuristicKb = Math.ceil((maxBytes * 4) / 1024); - return new Promise((resolve) => { - const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] }); - let output = ""; - let settled = false; - const finish = (withinBudget: boolean): void => { - if (settled) { - return; - } - settled = true; - resolve(withinBudget); - }; - const stopChild = (): void => { - try { - du.kill("SIGKILL"); - } catch { - /* gone */ - } - }; - consumeChildOutput(du.stdout, { - onData: (chunk) => { - output += chunk.toString(); - }, - onError: () => { - // `du` is an optional heuristic. Stop this broken read and let the - // capped tar stream remain authoritative rather than crashing host. - stopChild(); - finish(true); - }, - }); - du.on("close", (code) => { - if (code !== 0) { - // du failed; be permissive and let tar catch the overflow - finish(true); - return; - } - const match = /^(\d+)/.exec(output.trim()); - if (!match) { - finish(true); - return; - } - const sizeKb = Number.parseInt(match[0], 10); - finish(sizeKb <= heuristicKb); - }); - du.on("error", () => { - // du not available; skip preflight - finish(true); - }); - }); + const result = await runCommandBuffered(["du", "-sk", dirPath], { + discardOutput: { stderr: true }, + maxOutputBytes: 64 * 1024, + timeoutMs: 10_000, + }).catch(() => null); + if (!result || result.termination !== "exit" || result.code !== 0) { + // `du` is optional; the capped tar command remains authoritative. + return true; + } + const match = /^(\d+)/.exec(result.stdout.toString("utf8").trim()); + return match ? Number.parseInt(match[0], 10) <= heuristicKb : true; } async function listTarEntries(tarBuffer: Buffer): Promise { - // Async spawn so a slow `tar -tzf` doesn't park the node-host event - // loop for up to 10s. Other in-flight requests continue to be served. - return new Promise((resolve) => { - const child = spawn("tar", ["-tzf", "-"], { stdio: ["pipe", "pipe", "ignore"] }); - let stdoutBuf = ""; - let settled = false; - const finish = (entries: string[] | null): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(watchdog); - resolve(entries); - }; - const stopChild = (): void => { - try { - child.kill("SIGKILL"); - } catch { - /* gone */ - } - }; - const watchdog = setTimeout(() => { - stopChild(); - finish(null); - }, 10_000); - consumeChildOutput(child.stdout, { - onData: (chunk) => { - if (settled) { - return; - } - stdoutBuf += chunk.toString(); - // Bound buffer growth — pathological archives shouldn't OOM us. - if (stdoutBuf.length > 32 * 1024 * 1024) { - stopChild(); - finish(null); - } - }, - onError: () => { - stopChild(); - finish(null); - }, - }); - child.on("close", (code) => { - if (settled) { - return; - } - if (code !== 0) { - finish(null); - return; - } - const lines = stdoutBuf - .split("\n") - .map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, "")) - .filter((line) => line.length > 0); - finish(lines); - }); - child.on("error", () => { - finish(null); - }); - child.stdin.on("error", (error: NodeJS.ErrnoException) => { - if (settled && error.code === "EPIPE") { - return; - } - stopChild(); - finish(null); - }); - child.stdin.end(tarBuffer); - }); + const result = await runCommandBuffered(["tar", "-tzf", "-"], { + discardOutput: { stderr: true }, + input: tarBuffer, + maxOutputBytes: { stdout: 32 * 1024 * 1024, stderr: 64 * 1024 }, + timeoutMs: 10_000, + }).catch(() => null); + if (!result || result.termination !== "exit" || result.code !== 0) { + return null; + } + return result.stdout + .toString("utf8") + .split("\n") + .map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, "")) + .filter((line) => line.length > 0); } type TarArchiveResult = Buffer | "TOO_LARGE" | "TIMEOUT" | "ERROR"; @@ -204,61 +112,21 @@ async function createTarArchive( const tarArgs = ["-czf", "-", "-C", canonicalPath, "."]; const timeoutMs = 60_000; - return await new Promise((resolve) => { - // stderr is not consumed or returned; ignoring it avoids an unnecessary - // pipe and follows Node's stdio guidance for discarded child output. - const child = spawn(tarBin, tarArgs, { stdio: ["ignore", "pipe", "ignore"] }); - const chunks: Buffer[] = []; - let totalBytes = 0; - let settled = false; - const finish = (result: TarArchiveResult): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(watchdog); - resolve(result); - }; - const stopChild = (signal: NodeJS.Signals): void => { - try { - child.kill(signal); - } catch { - /* gone */ - } - }; - const watchdog = setTimeout(() => { - stopChild("SIGKILL"); - finish("TIMEOUT"); - }, timeoutMs); - - consumeChildOutput(child.stdout, { - onData: (chunk) => { - if (settled) { - return; - } - totalBytes += chunk.byteLength; - if (totalBytes > maxBytes) { - stopChild("SIGTERM"); - finish("TOO_LARGE"); - return; - } - chunks.push(chunk); - }, - onError: () => { - stopChild("SIGKILL"); - finish("ERROR"); - }, - }); - child.on("close", (code) => { - if (settled) { - return; - } - finish(code === 0 ? Buffer.concat(chunks) : "ERROR"); - }); - child.on("error", () => { - finish("ERROR"); - }); - }); + const result = await runCommandBuffered([tarBin, ...tarArgs], { + discardOutput: { stderr: true }, + maxOutputBytes: { stdout: maxBytes, stderr: 64 * 1024 }, + timeoutMs, + }).catch(() => null); + if (!result) { + return "ERROR"; + } + if (result.termination === "timeout") { + return "TIMEOUT"; + } + if (result.termination === "output-limit" && result.outputLimitStream === "stdout") { + return "TOO_LARGE"; + } + return result.termination === "exit" && result.code === 0 ? result.stdout : "ERROR"; } async function listTreeEntries(root: string, maxEntries: number): Promise { diff --git a/extensions/file-transfer/src/shared/child-output.ts b/extensions/file-transfer/src/shared/child-output.ts deleted file mode 100644 index 9779455b355d..000000000000 --- a/extensions/file-transfer/src/shared/child-output.ts +++ /dev/null @@ -1,15 +0,0 @@ -// File Transfer helpers keep child-process output consumption error-aware. -import type { Readable } from "node:stream"; - -export function consumeChildOutput( - stream: Readable, - handlers: { - onData: (chunk: Buffer) => void; - onError: (error: Error) => void; - }, -): void { - // Child stdout/stderr are independent EventEmitters: child `error`/`close` - // cannot absorb a pipe error, so every consumed output owns both outcomes. - stream.on("data", handlers.onData); - stream.on("error", handlers.onError); -} diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts b/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts index d28ce2d28f2c..438496b986dc 100644 --- a/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts +++ b/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts @@ -1,49 +1,58 @@ +// File Transfer tests cover archive-policy process-wrapper failures. import crypto from "node:crypto"; -// File Transfer tests cover archive-policy child-output failures. -import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { projectBoundedTextTail } from "./append-bounded-text-tail.js"; -type MockChild = EventEmitter & { - kill: ReturnType; - stderr: EventEmitter; - stdin: EventEmitter & { end: () => void }; - stdout: EventEmitter; -}; +const { runCommandWithTimeoutMock } = vi.hoisted(() => ({ + runCommandWithTimeoutMock: vi.fn(), +})); -function mockTarSpawn(script: (child: MockChild) => void) { - return vi.fn(() => { - const child = new EventEmitter() as MockChild; - child.kill = vi.fn(); - child.stderr = new EventEmitter(); - child.stdin = new EventEmitter() as MockChild["stdin"]; - child.stdout = new EventEmitter(); - child.stdin.end = () => queueMicrotask(() => script(child)); - return child; - }); +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandWithTimeout: runCommandWithTimeoutMock, +})); + +import { testing } from "./node-invoke-policy.js"; + +function commandResult(overrides: Record = {}) { + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, + }; } -async function importWithSpawn(spawnMock: ReturnType) { - vi.resetModules(); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; - }); - return await import("./node-invoke-policy.js"); +function mockCommandResult(overrides: Record = {}) { + runCommandWithTimeoutMock.mockImplementationOnce( + async ( + _argv: string[], + options: { onOutputChunk?: (chunk: Buffer, stream: string) => boolean | void }, + ) => { + const stdout = typeof overrides.stdout === "string" ? overrides.stdout : ""; + const stopped = stdout + ? options.onOutputChunk?.(Buffer.from(stdout), "stdout") === false + : false; + return commandResult({ + ...overrides, + stdout: "", + ...(stopped + ? { code: null, killed: true, outputLimitExceeded: true, termination: "signal" } + : {}), + }); + }, + ); } afterEach(() => { - vi.doUnmock("node:child_process"); - vi.resetModules(); + runCommandWithTimeoutMock.mockReset(); }); -describe("dir.fetch archive policy output lifecycle", () => { - it("fails archive listing closed on stdout errors", async () => { - const spawnMock = mockTarSpawn((child) => { - child.stdout.emit("data", Buffer.from("partial.txt\n")); - child.stdout.emit("error", new Error("policy listing read failed")); - child.emit("close", 0); - }); - const { testing } = await importWithSpawn(spawnMock); +describe("dir.fetch archive policy process wrapper", () => { + it("fails archive listing closed on wrapper errors", async () => { + runCommandWithTimeoutMock.mockRejectedValueOnce(new Error("policy listing read failed")); await expect( testing.listDirFetchArchiveEntries({ @@ -52,43 +61,32 @@ describe("dir.fetch archive policy output lifecycle", () => { ).resolves.toEqual({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: "tar -tzf stdout error: Error: policy listing read failed", + reason: "tar -tzf error: policy listing read failed", }); - expect(spawnMock.mock.results[0]?.value.kill).toHaveBeenCalledWith("SIGKILL"); }); - it("keeps complete archive entries authoritative after diagnostic stderr errors", async () => { - const spawnMock = mockTarSpawn((child) => { - child.stderr.emit("error", new Error("diagnostics unavailable")); - child.stdout.emit("data", Buffer.from("./ok.txt\n")); - child.emit("close", 0); - }); - const { testing } = await importWithSpawn(spawnMock); - + it("normalizes successful archive entries", async () => { + mockCommandResult({ stdout: "./ok.txt\n" }); const archive = Buffer.from("archive"); + await expect( - testing.listDirFetchArchiveEntries({ - tarBase64: archive.toString("base64"), - }), + testing.listDirFetchArchiveEntries({ tarBase64: archive.toString("base64") }), ).resolves.toEqual({ ok: true, entries: ["ok.txt"], sizeBytes: archive.byteLength, sha256: crypto.createHash("sha256").update(archive).digest("hex"), }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ tolerateOutputError: { stderr: true } }), + ); }); - it("surfaces UTF-16 safe tar stderr tail when archive listing fails with emoji at projection boundary", async () => { + it("surfaces a UTF-16-safe stderr tail on nonzero exit", async () => { const oldNoise = "n".repeat(250); - // Length 201: raw slice(-200) would start on the low surrogate of 🤖. const recent = "🤖" + "f".repeat(199); - const spawnMock = mockTarSpawn((child) => { - child.stderr.emit("data", Buffer.from(oldNoise)); - child.stderr.emit("data", Buffer.from(recent)); - child.emit("close", 2); - }); - const { testing } = await importWithSpawn(spawnMock); - const { projectBoundedTextTail } = await import("./append-bounded-text-tail.js"); + mockCommandResult({ code: 2, stderr: oldNoise + recent }); const result = await testing.listDirFetchArchiveEntries({ tarBase64: Buffer.from("archive").toString("base64"), @@ -96,13 +94,19 @@ describe("dir.fetch archive policy output lifecycle", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); - expect(result.reason).toContain("f".repeat(199)); expect(result.reason).not.toContain("🤖"); - expect( - /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + mockCommandResult({ + stdout: Array.from({ length: 5_001 }, (_, index) => `file-${index}`).join("\n") + "\n", + }); + + await expect( + testing.listDirFetchArchiveEntries({ + tarBase64: Buffer.from("archive").toString("base64"), + }), + ).resolves.toMatchObject({ ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY" }); + }); }); diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.ts b/extensions/file-transfer/src/shared/node-invoke-policy.ts index 6a4e31b76581..dd429856b967 100644 --- a/extensions/file-transfer/src/shared/node-invoke-policy.ts +++ b/extensions/file-transfer/src/shared/node-invoke-policy.ts @@ -1,15 +1,16 @@ // File Transfer plugin module implements node invoke policy behavior. -import { spawn } from "node:child_process"; import crypto from "node:crypto"; +import { StringDecoder } from "node:string_decoder"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; import type { OpenClawPluginNodeInvokePolicy, OpenClawPluginNodeInvokePolicyContext, OpenClawPluginNodeInvokePolicyResult, } from "openclaw/plugin-sdk/plugin-entry"; -import { appendBoundedTextTail, projectBoundedTextTail } from "./append-bounded-text-tail.js"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; +import { projectBoundedTextTail } from "./append-bounded-text-tail.js"; import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js"; -import { consumeChildOutput } from "./child-output.js"; import { FILE_TRANSFER_NODE_INVOKE_COMMANDS, type FileTransferNodeInvokeCommand, @@ -362,141 +363,89 @@ async function listDirFetchArchiveEntries( reason: `dir.fetch archive sha256 mismatch: payload says ${payload.sha256.toLowerCase()}, decoded ${sha256}`, }; } - return await new Promise< - | { ok: true; entries: string[]; sizeBytes: number; sha256: string } - | { ok: false; code: string; reason: string } - >((resolve) => { - const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; - const child = spawn(tarBin, ["-tzf", "-"], { stdio: ["pipe", "pipe", "pipe"] }); - const entries: string[] = []; - let pending = ""; - let outputBytes = 0; - let stderr = ""; - let settled = false; - const finish = ( - result: - | { ok: true; entries: string[]; sizeBytes: number; sha256: string } - | { ok: false; code: string; reason: string }, - ): void => { - if (settled) { - return; + const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; + const entries: string[] = []; + const decoder = new StringDecoder("utf8"); + let pending = ""; + let outputBytes = 0; + let outputTooLarge = false; + let entriesTooMany = false; + const appendLine = (line: string): boolean => { + const entry = normalizeTarEntryPath(line); + if (entry === null) { + return true; + } + entries.push(entry); + entriesTooMany = entries.length > DIR_FETCH_MAX_ENTRIES; + return !entriesTooMany; + }; + const result = await runCommandWithTimeout([tarBin, "-tzf", "-"], { + input: tarBuffer, + maxOutputBytes: { stderr: DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS }, + onOutputChunk: (chunk, stream) => { + if (stream !== "stdout") { + return true; } - settled = true; - clearTimeout(watchdog); - resolve(result); - }; - const stopChild = (): void => { - try { - child.kill("SIGKILL"); - } catch { - /* gone */ - } - }; - const appendLine = (line: string): boolean => { - if (settled) { + outputBytes += chunk.byteLength; + if (outputBytes > DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES) { + outputTooLarge = true; return false; } - const entry = normalizeTarEntryPath(line); - if (entry !== null) { - entries.push(entry); - if (entries.length > DIR_FETCH_MAX_ENTRIES) { - stopChild(); - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_TOO_MANY", - reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries`, - }); - return false; - } - } - return true; + const lines = `${pending}${decoder.write(chunk)}`.split("\n"); + pending = lines.pop() ?? ""; + return lines.every(appendLine); + }, + outputCapture: { stdout: "discard", stderr: "tail" }, + tolerateOutputError: { stderr: true }, + timeoutMs: DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS, + }).catch((error: unknown) => ({ error })); + if (!("termination" in result)) { + return { + ok: false, + code: "ARCHIVE_ENTRIES_UNREADABLE", + reason: `tar -tzf error: ${formatErrorMessage(result.error)}`, }; - const watchdog = setTimeout(() => { - stopChild(); - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: "tar -tzf timed out", - }); - }, DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS); - consumeChildOutput(child.stdout, { - onData: (chunk) => { - if (settled) { - return; - } - outputBytes += chunk.byteLength; - if (outputBytes > DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES) { - stopChild(); - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: "tar -tzf output too large", - }); - return; - } - const lines = `${pending}${chunk.toString()}`.split("\n"); - pending = lines.pop() ?? ""; - for (const line of lines) { - if (!appendLine(line)) { - return; - } - } - }, - onError: (error) => { - stopChild(); - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: `tar -tzf stdout error: ${String(error)}`, - }); - }, - }); - consumeChildOutput(child.stderr, { - onData: (chunk) => { - stderr = appendBoundedTextTail(stderr, chunk, DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS); - }, - onError: (error) => { - stderr = `[stderr unavailable: ${String(error)}]`; - }, - }); - child.on("close", (code) => { - if (settled) { - return; - } - if (code !== 0) { - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: `tar -tzf exited ${code}: ${projectBoundedTextTail(stderr, DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS)}`, - }); - return; - } - if (pending) { - if (!appendLine(pending)) { - return; - } - } - finish({ ok: true, entries, sizeBytes, sha256 }); - }); - child.on("error", (error) => { - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: `tar -tzf error: ${String(error)}`, - }); - }); - child.stdin.on("error", (error: NodeJS.ErrnoException) => { - if (settled && error.code === "EPIPE") { - return; - } - finish({ - ok: false, - code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: `tar -tzf input error: ${String(error)}`, - }); - }); - child.stdin.end(tarBuffer); - }); + } + if (result.termination === "timeout") { + return { ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", reason: "tar -tzf timed out" }; + } + if (entriesTooMany) { + return { + ok: false, + code: "ARCHIVE_ENTRIES_TOO_MANY", + reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries`, + }; + } + if (outputTooLarge) { + return { + ok: false, + code: "ARCHIVE_ENTRIES_UNREADABLE", + reason: "tar -tzf output too large", + }; + } + if (result.termination !== "exit") { + return { + ok: false, + code: "ARCHIVE_ENTRIES_UNREADABLE", + reason: `tar -tzf error: ${result.termination}`, + }; + } + if (result.code !== 0) { + return { + ok: false, + code: "ARCHIVE_ENTRIES_UNREADABLE", + reason: `tar -tzf exited ${result.code}: ${projectBoundedTextTail(result.stderr, DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS)}`, + }; + } + appendLine(pending + decoder.end()); + if (entries.length > DIR_FETCH_MAX_ENTRIES) { + return { + ok: false, + code: "ARCHIVE_ENTRIES_TOO_MANY", + reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries`, + }; + } + return { ok: true, entries, sizeBytes, sha256 }; } async function validateDirFetchEntries(input: { diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts index b6ee115e27af..f226f3e77c4e 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts @@ -1,6 +1,5 @@ -// File Transfer tests cover dir fetch tool plugin behavior. +// File Transfer tests cover dir fetch tar validation through the canonical process wrapper. import { spawn } from "node:child_process"; -import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -15,11 +14,13 @@ beforeEach(async () => { }); afterEach(async () => { + vi.doUnmock("openclaw/plugin-sdk/process-runtime"); + vi.resetModules(); await fs.rm(tmpRoot, { recursive: true, force: true }); }); async function tarDirectory(dir: string): Promise { - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; const child = spawn(tarBin, ["-czf", "-", "-C", dir, "."], { stdio: ["ignore", "pipe", "pipe"], @@ -41,36 +42,68 @@ async function tarDirectory(dir: string): Promise { }); } -const testUnlessWindows = process.platform === "win32" ? it.skip : it; - -function mockTarSpawn( - script: ( - child: EventEmitter & { - kill: ReturnType; - stderr: EventEmitter; - stdin: EventEmitter & { end: () => void }; - stdout: EventEmitter; - }, - ) => void, -) { - return vi.fn(() => { - const child = new EventEmitter() as EventEmitter & { - kill: ReturnType; - stderr: EventEmitter; - stdin: EventEmitter & { end: () => void }; - stdout: EventEmitter; - }; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.stdin = new EventEmitter() as EventEmitter & { end: () => void }; - child.kill = vi.fn(); - child.stdin.end = () => { - queueMicrotask(() => script(child)); - }; - return child; - }); +function commandResult(overrides: Record = {}) { + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, + }; } +function bufferedCommandResult(overrides: Record = {}) { + return { + ...commandResult(), + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + ...overrides, + }; +} + +async function importWithCommandResults(...results: Array>) { + const runCommandBuffered = vi.fn().mockResolvedValue(bufferedCommandResult()); + const runCommandWithTimeout = vi.fn(); + for (const result of results) { + runCommandWithTimeout.mockImplementationOnce( + async ( + _argv: string[], + options: { onOutputChunk?: (chunk: Buffer, stream: string) => boolean | void }, + ) => { + if (result.error instanceof Error && result.termination === "error") { + throw result.error; + } + const stdout = typeof result.stdout === "string" ? result.stdout : ""; + const stopped = stdout + ? options.onOutputChunk?.(Buffer.from(stdout), "stdout") === false + : false; + return commandResult({ + ...result, + stdout: "", + ...(stopped + ? { code: null, killed: true, outputLimitExceeded: true, termination: "signal" } + : {}), + }); + }, + ); + } + runCommandWithTimeout.mockResolvedValue(commandResult()); + vi.resetModules(); + vi.doMock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandBuffered, + runCommandWithTimeout, + })); + return { + module: await import("./dir-fetch-tool.js"), + runCommandBuffered, + runCommandWithTimeout, + }; +} + +const testUnlessWindows = process.platform === "win32" ? it.skip : it; + describe("validateTarUncompressedBudget", () => { testUnlessWindows( "rejects an archive before extraction when expanded bytes exceed budget", @@ -82,244 +115,119 @@ describe("validateTarUncompressedBudget", () => { ok: false, reason: "archive expands past uncompressed budget 64 bytes", }); - await expect(validateTarUncompressedBudget(tarBuffer, 256)).resolves.toEqual({ - ok: true, - }); + await expect(validateTarUncompressedBudget(tarBuffer, 256)).resolves.toEqual({ ok: true }); }, ); - it("fails closed when tar stdout cannot be read", async () => { - vi.resetModules(); - const spawnMock = mockTarSpawn((child) => { - child.stdout.emit("data", Buffer.from("partial")); - child.stdout.emit("error", new Error("budget read failed")); - child.emit("close", 0); - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; + it("fails closed on wrapper errors", async () => { + const { module, runCommandWithTimeout } = await importWithCommandResults({ + code: null, + termination: "error", + error: new Error("budget read failed"), }); - try { - const { testing } = await import("./dir-fetch-tool.js"); - await expect(testing.validateTarUncompressedBudget(Buffer.from("x"))).resolves.toEqual({ - ok: false, - reason: "tar uncompressed budget validation stdout error: Error: budget read failed", - }); - expect(spawnMock.mock.results[0]?.value.kill).toHaveBeenCalledWith("SIGKILL"); - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); - } - }); - - it("keeps complete budget output authoritative after diagnostic stderr errors", async () => { - vi.resetModules(); - const spawnMock = mockTarSpawn((child) => { - child.stderr.emit("error", new Error("diagnostics unavailable")); - child.stdout.emit("data", Buffer.alloc(16)); - child.emit("close", 0); + await expect(module.testing.validateTarUncompressedBudget(Buffer.from("x"))).resolves.toEqual({ + ok: false, + reason: "tar uncompressed budget validation error: budget read failed", }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; - }); - - try { - const { testing } = await import("./dir-fetch-tool.js"); - await expect(testing.validateTarUncompressedBudget(Buffer.from("x"), 32)).resolves.toEqual({ - ok: true, - }); - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); - } + expect(runCommandWithTimeout).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ tolerateOutputError: { stderr: true } }), + ); }); }); describe("dir.fetch tar validation", () => { - it("fails tar listing closed when stdout cannot be read", async () => { - vi.resetModules(); - const spawnMock = mockTarSpawn((child) => { - child.stdout.emit("data", Buffer.from("partial.txt\n")); - child.stdout.emit("error", new Error("listing read failed")); - child.emit("close", 0); - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; + it("fails tar listing closed on wrapper errors", async () => { + const { module } = await importWithCommandResults({ + code: null, + termination: "error", + error: new Error("listing read failed"), }); - try { - const { testing } = await import("./dir-fetch-tool.js"); - await expect(testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({ - ok: false, - reason: "tar -tzf stdout error: Error: listing read failed", - }); - expect(spawnMock.mock.results[0]?.value.kill).toHaveBeenCalledWith("SIGKILL"); - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); - } + await expect(module.testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({ + ok: false, + reason: "tar -tzf error: listing read failed", + }); }); - it("keeps successful unpack authoritative after diagnostic stderr errors", async () => { - vi.resetModules(); - const spawnMock = mockTarSpawn((child) => { - child.stderr.emit("error", new Error("diagnostics unavailable")); - child.emit("close", 0); - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; - }); + it("accepts successful unpack", async () => { + const { module, runCommandWithTimeout } = await importWithCommandResults(); - try { - const { testing } = await import("./dir-fetch-tool.js"); - await expect(testing.unpackTar(Buffer.from("x"), tmpRoot)).resolves.toBeUndefined(); - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); - } + await expect(module.testing.unpackTar(Buffer.from("x"), tmpRoot)).resolves.toBeUndefined(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + outputCapture: { stdout: "discard", stderr: "tail" }, + tolerateOutputError: { stderr: true }, + }), + ); }); - it("ignores late stdin EPIPE after tar listing has already settled", async () => { - vi.resetModules(); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: vi.fn(() => { - const child = new EventEmitter() as EventEmitter & { - kill: ReturnType; - stderr: EventEmitter; - stdin: EventEmitter & { end: () => void }; - stdout: EventEmitter; - }; - const stdout = new EventEmitter(); - const stderr = new EventEmitter(); - const stdin = new EventEmitter() as EventEmitter & { end: () => void }; - child.stdout = stdout; - child.stderr = stderr; - child.stdin = stdin; - child.kill = vi.fn(); - stdin.end = () => { - queueMicrotask(() => { - stderr.emit("data", Buffer.from("invalid archive")); - child.emit("close", 2); - stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); - }); - }; - return child; - }), - }; + it("keeps tar exit diagnostics", async () => { + const { module } = await importWithCommandResults({ + code: 2, + stderr: "invalid archive", }); - try { - const { testing } = await import("./dir-fetch-tool.js"); - await expect(testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({ - ok: false, - reason: "tar -tzf exited 2: invalid archive", - }); - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); - } + await expect(module.testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({ + ok: false, + reason: "tar -tzf exited 2: invalid archive", + }); }); - it("stops tar name listing once the entry cap is exceeded", async () => { - vi.resetModules(); + it("stops name validation at the entry cap", async () => { const tarLines = Array.from({ length: 5001 }, (_, index) => `file-${index}`).join("\n") + "\n"; - const spawnMock = mockTarSpawn((child) => { - child.stdout.emit("data", Buffer.from(tarLines)); - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: spawnMock, - }; + const { module, runCommandWithTimeout } = await importWithCommandResults({ + stdout: tarLines, }); - try { - const { testing } = await import("./dir-fetch-tool.js"); - await expect(testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({ - ok: false, - reason: "archive contains 5001 entries; limit 5000", - }); - expect(spawnMock).toHaveBeenCalledTimes(1); - const child = spawnMock.mock.results[0]?.value; - expect(child?.kill).toHaveBeenCalledWith("SIGKILL"); - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); - } + await expect(module.testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({ + ok: false, + reason: "archive contains 5001 entries; limit 5000", + }); + expect(runCommandWithTimeout).toHaveBeenCalledOnce(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ tolerateOutputError: { stderr: true } }), + ); }); it("keeps recent tar stderr when listing fails noisily", async () => { - vi.resetModules(); const oldNoise = "old-noise\n".repeat(600); const recent = "recent-invalid-archive-details\n".repeat(12); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: mockTarSpawn((child) => { - child.stderr.emit("data", Buffer.from(oldNoise)); - child.stderr.emit("data", Buffer.from(recent)); - child.emit("close", 2); - }), - }; + const { module } = await importWithCommandResults({ + code: 2, + stderr: oldNoise + recent, }); - try { - const { testing } = await import("./dir-fetch-tool.js"); - const result = await testing.preValidateTarball(Buffer.from("x")); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); - expect(result.reason).not.toContain(oldNoise.slice(0, 40)); - } - } finally { - vi.doUnmock("node:child_process"); - vi.resetModules(); + const result = await module.testing.preValidateTarball(Buffer.from("x")); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); + expect(result.reason).not.toContain(oldNoise.slice(0, 40)); } }); - it("surfaces UTF-16 safe tar stderr tail when listing fails with emoji at projection boundary", async () => { - vi.resetModules(); + it("surfaces a UTF-16-safe tar stderr tail", async () => { const oldNoise = "n".repeat(250); - // Length 201: raw slice(-200) would start on the low surrogate of 🤖. const recent = "🤖" + "f".repeat(199); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: mockTarSpawn((child) => { - child.stderr.emit("data", Buffer.from(oldNoise)); - child.stderr.emit("data", Buffer.from(recent)); - child.emit("close", 2); - }), - }; + const { module } = await importWithCommandResults({ + code: 2, + stderr: oldNoise + recent, }); - try { - const { testing } = await import("./dir-fetch-tool.js"); - const result = await testing.preValidateTarball(Buffer.from("x")); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); - expect(result.reason).toContain("f".repeat(199)); - expect(result.reason).not.toContain("🤖"); - expect( - /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(input: { mapLine: (line: string) => T; maxValues: number; }): Promise<{ ok: true; values: T[] } | { ok: false; reason: string }> { - return new Promise((resolve) => { - const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; - const child = spawn(tarBin, input.args, { stdio: ["pipe", "pipe", "pipe"] }); - const values: T[] = []; - let pending = ""; - let outputChars = 0; - let stderr = ""; - let settled = false; - - const finish = (result: { ok: true; values: T[] } | { ok: false; reason: string }): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(watchdog); - resolve(result); - }; - const stopChild = (): void => { - try { - child.kill("SIGKILL"); - } catch { - /* gone */ - } - }; - const appendLine = (line: string): boolean => { - if (settled) { - return false; - } - if (!line) { - return true; - } - values.push(input.mapLine(line)); - if (values.length >= input.maxValues) { - stopChild(); - finish({ ok: true, values }); - return false; - } + const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; + const decoder = new StringDecoder("utf8"); + const values: T[] = []; + let pending = ""; + let outputBytes = 0; + let outputTooLarge = false; + let valueLimitReached = false; + const appendLine = (line: string): boolean => { + if (!line) { return true; - }; - const consumeChunk = (chunk: Buffer): void => { - if (settled) { - return; - } - const text = chunk.toString(); - outputChars += text.length; - if (outputChars > TAR_LIST_OUTPUT_MAX_CHARS) { - stopChild(); - finish({ ok: false, reason: `${input.label} output too large` }); - return; - } - const lines = `${pending}${text}`.split("\n"); - pending = lines.pop() ?? ""; - for (const line of lines) { - if (!appendLine(line)) { - return; + } + values.push(input.mapLine(line)); + valueLimitReached = values.length >= input.maxValues; + return !valueLimitReached; + }; + let result: Awaited>; + try { + result = await runCommandWithTimeout([tarBin, ...input.args], { + input: input.tarBuffer, + maxOutputBytes: { stderr: TAR_STDERR_TAIL_CHARS }, + onOutputChunk: (chunk, stream) => { + if (stream !== "stdout") { + return true; } - } + outputBytes += chunk.byteLength; + if (outputBytes > TAR_LIST_OUTPUT_MAX_CHARS) { + outputTooLarge = true; + return false; + } + const lines = `${pending}${decoder.write(chunk)}`.split("\n"); + pending = lines.pop() ?? ""; + return lines.every(appendLine); + }, + outputCapture: { stdout: "discard", stderr: "tail" }, + tolerateOutputError: { stderr: true }, + timeoutMs: 30_000, + }); + } catch (error) { + return { ok: false, reason: `${input.label} error: ${formatErrorMessage(error)}` }; + } + if (result.termination === "timeout") { + return { ok: false, reason: `${input.label} timed out` }; + } + if (valueLimitReached) { + return { ok: true, values }; + } + if (outputTooLarge) { + return { ok: false, reason: `${input.label} output too large` }; + } + if (result.termination !== "exit") { + return { + ok: false, + reason: `${input.label} error: ${result.termination}`, }; - - const watchdog: ReturnType = setTimeout(() => { - stopChild(); - finish({ ok: false, reason: `${input.label} timed out` }); - }, 30_000); - consumeChildOutput(child.stdout, { - onData: consumeChunk, - onError: (error) => { - stopChild(); - finish({ ok: false, reason: `${input.label} stdout error: ${String(error)}` }); - }, - }); - consumeChildOutput(child.stderr, { - onData: (chunk) => { - stderr = appendBoundedTextTail(stderr, chunk, TAR_STDERR_TAIL_CHARS); - }, - onError: (error) => { - // stderr is diagnostic only; preserve that fact for a later nonzero - // close without invalidating complete stdout validation data. - stderr = `[stderr unavailable: ${String(error)}]`; - }, - }); - child.on("close", (code) => { - if (settled) { - return; - } - if (code !== 0) { - finish({ - ok: false, - reason: `${input.label} exited ${code}: ${projectBoundedTextTail(stderr, TAR_ERROR_REASON_STDERR_CHARS)}`, - }); - return; - } - if (pending) { - appendLine(pending); - } - finish({ ok: true, values }); - }); - child.on("error", (e) => { - finish({ ok: false, reason: `${input.label} error: ${String(e)}` }); - }); - child.stdin.on("error", (e: NodeJS.ErrnoException) => { - if (settled && e.code === "EPIPE") { - return; - } - finish({ ok: false, reason: `${input.label} input error: ${String(e)}` }); - }); - child.stdin.end(input.tarBuffer); - }); + } + if (result.code !== 0) { + return { + ok: false, + reason: `${input.label} exited ${result.code}: ${projectBoundedTextTail(result.stderr, TAR_ERROR_REASON_STDERR_CHARS)}`, + }; + } + appendLine(pending + decoder.end()); + return { ok: true, values }; } async function computeFileSha256(filePath: string): Promise { @@ -291,97 +245,51 @@ export async function validateTarUncompressedBudget( tarBuffer: Buffer, maxBytes = DIR_FETCH_MAX_UNCOMPRESSED_BYTES, ): Promise<{ ok: true } | { ok: false; reason: string }> { - return new Promise((resolve) => { - const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; - const child = spawn(tarBin, ["-xOzf", "-"], { stdio: ["pipe", "pipe", "pipe"] }); - let totalBytes = 0; - let stderr = ""; - let settled = false; - const finish = (result: { ok: true } | { ok: false; reason: string }): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(watchdog); - resolve(result); - }; - const watchdog: ReturnType = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - /* gone */ - } - finish({ ok: false, reason: "tar uncompressed budget validation timed out" }); - }, TAR_UNPACK_TIMEOUT_MS); - - consumeChildOutput(child.stdout, { - onData: (chunk) => { - if (settled) { - return; + const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; + let totalBytes = 0; + let budgetExceeded = false; + let result: Awaited>; + try { + result = await runCommandWithTimeout([tarBin, "-xOzf", "-"], { + input: tarBuffer, + maxOutputBytes: { stderr: TAR_STDERR_TAIL_CHARS }, + onOutputChunk: (chunk, stream) => { + if (stream !== "stdout") { + return true; } totalBytes += chunk.byteLength; - if (totalBytes > maxBytes) { - try { - child.kill("SIGKILL"); - } catch { - /* gone */ - } - finish({ - ok: false, - reason: `archive expands past uncompressed budget ${maxBytes} bytes`, - }); - } - }, - onError: (error) => { - try { - child.kill("SIGKILL"); - } catch { - /* gone */ - } - finish({ - ok: false, - reason: `tar uncompressed budget validation stdout error: ${String(error)}`, - }); + budgetExceeded = totalBytes > maxBytes; + return !budgetExceeded; }, + outputCapture: { stdout: "discard", stderr: "tail" }, + tolerateOutputError: { stderr: true }, + timeoutMs: TAR_UNPACK_TIMEOUT_MS, }); - consumeChildOutput(child.stderr, { - onData: (chunk) => { - stderr = appendBoundedTextTail(stderr, chunk, TAR_STDERR_TAIL_CHARS); - }, - onError: (error) => { - stderr = `[stderr unavailable: ${String(error)}]`; - }, - }); - child.on("close", (code) => { - if (settled) { - return; - } - if (code !== 0) { - finish({ - ok: false, - reason: `tar uncompressed budget validation exited ${code}: ${projectBoundedTextTail(stderr, TAR_ERROR_REASON_STDERR_CHARS)}`, - }); - return; - } - finish({ ok: true }); - }); - child.on("error", (error) => { - finish({ - ok: false, - reason: `tar uncompressed budget validation error: ${String(error)}`, - }); - }); - child.stdin.on("error", (error: NodeJS.ErrnoException) => { - if (settled && error.code === "EPIPE") { - return; - } - finish({ - ok: false, - reason: `tar uncompressed budget validation input error: ${String(error)}`, - }); - }); - child.stdin.end(tarBuffer); - }); + } catch (error) { + return { + ok: false, + reason: `tar uncompressed budget validation error: ${formatErrorMessage(error)}`, + }; + } + if (result.termination === "timeout") { + return { ok: false, reason: "tar uncompressed budget validation timed out" }; + } + if (budgetExceeded) { + return { ok: false, reason: `archive expands past uncompressed budget ${maxBytes} bytes` }; + } + if (result.termination !== "exit") { + return { + ok: false, + reason: `tar uncompressed budget validation error: ${result.termination}`, + }; + } + if (result.code !== 0) { + return { + ok: false, + reason: `tar uncompressed budget validation exited ${result.code}: ${projectBoundedTextTail(result.stderr, TAR_ERROR_REASON_STDERR_CHARS)}`, + }; + } + return { ok: true }; } type UnpackedFileEntry = { @@ -417,73 +325,28 @@ type UnpackedFileEntry = { */ async function unpackTar(tarBuffer: Buffer, destDir: string): Promise { await fs.mkdir(destDir, { recursive: true, mode: 0o700 }); - return new Promise((resolve, reject) => { - const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; - const child = spawn( - tarBin, - ["-xzf", "-", "-C", destDir, "--no-same-owner", "--no-same-permissions"], - { - stdio: ["pipe", "ignore", "pipe"], - }, + const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; + const result = await runCommandWithTimeout( + [tarBin, "-xzf", "-", "-C", destDir, "--no-same-owner", "--no-same-permissions"], + { + input: tarBuffer, + maxOutputBytes: { stderr: TAR_STDERR_TAIL_CHARS }, + outputCapture: { stdout: "discard", stderr: "tail" }, + tolerateOutputError: { stderr: true }, + timeoutMs: TAR_UNPACK_TIMEOUT_MS, + }, + ); + if (result.termination === "timeout") { + throw new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`); + } + if (result.termination !== "exit") { + throw new Error(`tar unpack failed: ${result.termination}`); + } + if (result.code !== 0) { + throw new Error( + `tar unpack exited ${result.code}: ${projectBoundedTextTail(result.stderr, TAR_UNPACK_ERROR_STDERR_CHARS)}`, ); - let stderrOut = ""; - let settled = false; - const fail = (error: Error): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(watchdog); - reject(error); - }; - const succeed = (): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(watchdog); - resolve(); - }; - const watchdog: ReturnType = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - /* already gone */ - } - fail(new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`)); - }, TAR_UNPACK_TIMEOUT_MS); - consumeChildOutput(child.stderr, { - onData: (chunk) => { - stderrOut = appendBoundedTextTail(stderrOut, chunk, TAR_STDERR_TAIL_CHARS); - }, - onError: (error) => { - // Extraction success is authoritative; a diagnostic read failure only - // replaces stderr context if tar later exits nonzero. - stderrOut = `[stderr unavailable: ${String(error)}]`; - }, - }); - child.on("close", (code) => { - if (code !== 0) { - fail( - new Error( - `tar unpack exited ${code}: ${projectBoundedTextTail(stderrOut, TAR_UNPACK_ERROR_STDERR_CHARS)}`, - ), - ); - return; - } - succeed(); - }); - child.on("error", (e) => { - fail(e); - }); - child.stdin.on("error", (e: NodeJS.ErrnoException) => { - if (settled && e.code === "EPIPE") { - return; - } - fail(e); - }); - child.stdin.end(tarBuffer); - }); + } } /** diff --git a/extensions/imessage/src/actions.runtime.test.ts b/extensions/imessage/src/actions.runtime.test.ts index 421fda505404..d4fd8209455d 100644 --- a/extensions/imessage/src/actions.runtime.test.ts +++ b/extensions/imessage/src/actions.runtime.test.ts @@ -1,13 +1,11 @@ // Imessage tests cover actions plugin behavior. -import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -const spawnMock = vi.hoisted(() => vi.fn()); const createIMessageRpcClientMock = vi.hoisted(() => vi.fn()); +const runIMessageCliJsonCommandMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", async (importOriginal) => ({ - ...(await importOriginal()), - spawn: spawnMock, +vi.mock("./cli-output.js", () => ({ + runIMessageCliJsonCommand: runIMessageCliJsonCommandMock, })); vi.mock("./client.js", () => ({ @@ -20,46 +18,9 @@ const { imessageActionsRuntime, findChatGuidForTest, normalizeDirectChatIdentifi afterEach(() => { vi.restoreAllMocks(); createIMessageRpcClientMock.mockReset(); - spawnMock.mockReset(); + runIMessageCliJsonCommandMock.mockReset(); }); -function mockSpawnJsonResponse(payload: Record = { success: true }) { - spawnMock.mockImplementationOnce(() => { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter & { setEncoding: (encoding: string) => void }; - stderr: EventEmitter & { setEncoding: (encoding: string) => void }; - kill: (signal: string) => void; - }; - child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.kill = vi.fn(); - queueMicrotask(() => { - child.stdout.emit("data", `${JSON.stringify(payload)}\n`); - child.emit("close", 0); - }); - return child; - }); -} - -function mockSpawnWithStreamError(stream: "stdout" | "stderr", error: Error) { - const kill = vi.fn(); - spawnMock.mockImplementationOnce(() => { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter & { setEncoding: (encoding: string) => void }; - stderr: EventEmitter & { setEncoding: (encoding: string) => void }; - kill: (signal: string) => void; - }; - child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.kill = kill; - queueMicrotask(() => { - child[stream].emit("error", error); - }); - return child; - }); - return kill; -} - function mockRpcChatList(chats: Array>) { const request = vi.fn().mockResolvedValue({ chats }); const stop = vi.fn().mockResolvedValue(undefined); @@ -69,7 +30,7 @@ function mockRpcChatList(chats: Array>) { describe("imessage actions runtime", () => { it("passes the configured Messages db path to private API bridge commands", async () => { - mockSpawnJsonResponse(); + runIMessageCliJsonCommandMock.mockResolvedValue({ success: true }); await imessageActionsRuntime.sendReaction({ chatGuid: "iMessage;+;chat0000", @@ -82,9 +43,11 @@ describe("imessage actions runtime", () => { }, }); - expect(spawnMock).toHaveBeenCalledWith( - "imsg", - [ + expect(runIMessageCliJsonCommandMock).toHaveBeenCalledWith({ + cliPath: "imsg", + dbPath: "/tmp/messages.db", + timeoutMs: undefined, + args: [ "tapback", "--chat", "iMessage;+;chat0000", @@ -94,16 +57,13 @@ describe("imessage actions runtime", () => { "like", "--part", "0", - "--db", - "/tmp/messages.db", - "--json", ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); + }); }); - it("rejects on stdout stream error", async () => { - const kill = mockSpawnWithStreamError("stdout", new Error("stdout pipe broken")); + it("preserves canonical CLI wrapper errors", async () => { + const wrapperError = new Error("imsg failed"); + runIMessageCliJsonCommandMock.mockRejectedValue(wrapperError); await expect( imessageActionsRuntime.sendReaction({ @@ -115,25 +75,7 @@ describe("imessage actions runtime", () => { chatGuid: "iMessage;+;chat0000", }, }), - ).rejects.toThrow("iMessage CLI stdout stream error: stdout pipe broken"); - expect(kill).toHaveBeenCalledWith("SIGKILL"); - }); - - it("rejects on stderr stream error", async () => { - const kill = mockSpawnWithStreamError("stderr", new Error("stderr pipe broken")); - - await expect( - imessageActionsRuntime.sendReaction({ - chatGuid: "iMessage;+;chat0000", - messageId: "message-guid", - reaction: "like", - options: { - cliPath: "imsg", - chatGuid: "iMessage;+;chat0000", - }, - }), - ).rejects.toThrow("iMessage CLI stderr stream error: stderr pipe broken"); - expect(kill).toHaveBeenCalledWith("SIGKILL"); + ).rejects.toBe(wrapperError); }); it("drops cached chats.list entries when the current clock is not a valid date timestamp", async () => { diff --git a/extensions/imessage/src/actions.runtime.ts b/extensions/imessage/src/actions.runtime.ts index 1e47864a5872..b7df5156bd15 100644 --- a/extensions/imessage/src/actions.runtime.ts +++ b/extensions/imessage/src/actions.runtime.ts @@ -1,5 +1,4 @@ // Imessage plugin module implements actions behavior. -import { spawn } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; import { @@ -7,13 +6,8 @@ import { parseStrictInteger, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; -import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { - appendIMessageCliStderrTail, - appendIMessageCliStdout, - listenForIMessageCliStreamErrors, -} from "./cli-output.js"; +import { runIMessageCliJsonCommand } from "./cli-output.js"; import { createIMessageRpcClient } from "./client.js"; import { extractMarkdownFormatRuns } from "./markdown-format.js"; import { @@ -169,140 +163,15 @@ function findChatGuid( return null; } -function buildIMessageCliJsonArgs(args: readonly string[], options: CliRunOptions): string[] { - const dbPath = options.dbPath?.trim(); - return [...args, ...(dbPath ? ["--db", dbPath] : []), "--json"]; -} - async function runIMessageCliJson( args: readonly string[], options: CliRunOptions, ): Promise> { - return await new Promise((resolve, reject) => { - const child = spawn(options.cliPath, buildIMessageCliJsonArgs(args, options), { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - let killEscalation: ReturnType | null = null; - let settled = false; - const clearTimers = (optionsValue: { keepKillEscalation?: boolean } = {}): void => { - if (timer) { - clearTimeout(timer); - } - if (killEscalation && !optionsValue.keepKillEscalation) { - clearTimeout(killEscalation); - } - }; - const fail = (error: Error, optionsLocal: { keepKillEscalation?: boolean } = {}): void => { - if (settled) { - return; - } - settled = true; - clearTimers(optionsLocal); - reject(error); - }; - const succeed = (value: Record): void => { - if (settled) { - return; - } - settled = true; - clearTimers(); - resolve(value); - }; - const timer = - options.timeoutMs && options.timeoutMs > 0 - ? setTimeout(() => { - child.kill("SIGTERM"); - // If SIGTERM doesn't take within 2s (wedged child, ignored - // signal handler), escalate to SIGKILL so the process doesn't - // linger as a zombie. - killEscalation = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // best-effort - } - }, 2000); - fail(new Error(`iMessage action timed out after ${options.timeoutMs}ms`), { - keepKillEscalation: true, - }); - }, options.timeoutMs) - : null; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - if (settled) { - return; - } - const appended = appendIMessageCliStdout(stdout, chunk); - if (!appended.ok) { - try { - child.kill("SIGKILL"); - } catch { - // best-effort - } - fail(new Error(appended.message)); - return; - } - stdout = appended.value; - }); - child.stderr.on("data", (chunk) => { - stderr = appendIMessageCliStderrTail(stderr, chunk); - }); - listenForIMessageCliStreamErrors({ - child, - isSettled: () => settled, - fail, - }); - child.on("error", (error) => { - if (settled) { - clearTimers(); - return; - } - fail(error); - }); - child.on("close", (code) => { - if (settled) { - clearTimers(); - return; - } - const lines = normalizeStringEntries(stdout.split(/\r?\n/)); - const last = lines.at(-1); - let parsed: Record | null = null; - if (last) { - try { - const value = JSON.parse(last); - if (value && typeof value === "object" && !Array.isArray(value)) { - parsed = value as Record; - } - } catch { - parsed = null; - } - } - if (code !== 0) { - const detail = - (typeof parsed?.error === "string" && parsed.error.trim()) || - stderr.trim() || - stdout.trim() || - `imsg exited with code ${code}`; - fail(new Error(detail)); - return; - } - if (!parsed) { - fail(new Error(`imsg returned non-JSON output: ${stdout.trim() || stderr.trim()}`)); - return; - } - if (parsed.success === false) { - const error = - typeof parsed.error === "string" && parsed.error.trim() - ? parsed.error.trim() - : "iMessage action failed"; - fail(new Error(error)); - return; - } - succeed(parsed); - }); + return await runIMessageCliJsonCommand({ + args, + cliPath: options.cliPath, + dbPath: options.dbPath, + timeoutMs: options.timeoutMs, }); } diff --git a/extensions/imessage/src/cli-output.test.ts b/extensions/imessage/src/cli-output.test.ts index f9fe17a8e3c9..4b44f7da2944 100644 --- a/extensions/imessage/src/cli-output.test.ts +++ b/extensions/imessage/src/cli-output.test.ts @@ -1,27 +1,109 @@ -// Imessage tests cover cli output plugin behavior. -import { describe, expect, it } from "vitest"; -import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; +// iMessage tests cover canonical bounded CLI execution. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + IMESSAGE_CLI_STDERR_TAIL_BYTES, + IMESSAGE_CLI_STDOUT_MAX_BYTES, + runIMessageCliJsonCommand, +} from "./cli-output.js"; -describe("iMessage CLI output bounds", () => { - it("rejects stdout once the JSON capture exceeds the cap", () => { - const result = appendIMessageCliStdout("abc", "def", 5); +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandWithTimeout: vi.fn(), +})); - expect(result).toEqual({ - ok: false, - message: "imsg stdout exceeded 5 characters", - }); +const runCommandMock = vi.mocked(runCommandWithTimeout); + +function commandResult( + overrides: Partial>> = {}, +): Awaited> { + return { + stdout: '{"success":true,"messageId":"ok"}\n', + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, + }; +} + +describe("runIMessageCliJsonCommand", () => { + beforeEach(() => { + runCommandMock.mockReset(); + runCommandMock.mockResolvedValue(commandResult()); }); - it("keeps only recent stderr details", () => { - const result = appendIMessageCliStderrTail("old-noise:", "recent-error", 12); + it("uses the canonical wrapper with bounded asymmetric output", async () => { + await expect( + runIMessageCliJsonCommand({ + cliPath: "/usr/local/bin/imsg", + dbPath: " /tmp/chat.db ", + args: ["send", "--text", "hello"], + timeoutMs: 2_000, + }), + ).resolves.toMatchObject({ success: true, messageId: "ok" }); - expect(result).toBe("recent-error"); + expect(runCommandMock).toHaveBeenCalledWith( + ["/usr/local/bin/imsg", "send", "--text", "hello", "--db", "/tmp/chat.db", "--json"], + expect.objectContaining({ + maxOutputBytes: { + stdout: IMESSAGE_CLI_STDOUT_MAX_BYTES, + stderr: IMESSAGE_CLI_STDERR_TAIL_BYTES, + }, + outputCapture: { stdout: "head", stderr: "tail" }, + terminateOnOutputLimit: { stdout: true }, + }), + ); }); - it("does not split a surrogate pair at the tail boundary", () => { - const input = `x🚀${"y".repeat(10)}`; + it("parses the last JSON object after CLI noise", async () => { + runCommandMock.mockResolvedValueOnce( + commandResult({ stdout: 'warning\n{"success":true,"messageId":"last"}\n' }), + ); + await expect( + runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"] }), + ).resolves.toMatchObject({ messageId: "last" }); + }); - expect(input.slice(-11)).toBe(`\ude80${"y".repeat(10)}`); - expect(appendIMessageCliStderrTail("", input, 11)).toBe("y".repeat(10)); + it("surfaces timeout and stdout-cap failures", async () => { + runCommandMock.mockResolvedValueOnce(commandResult({ code: 124, termination: "timeout" })); + await expect( + runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"], timeoutMs: 25 }), + ).rejects.toThrow("iMessage action timed out after 25ms"); + + runCommandMock.mockResolvedValueOnce( + commandResult({ code: null, termination: "signal", outputLimitExceeded: true }), + ); + await expect(runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"] })).rejects.toThrow( + `imsg stdout exceeded ${IMESSAGE_CLI_STDOUT_MAX_BYTES} bytes`, + ); + }); + + it("prefers structured and stderr command failures", async () => { + runCommandMock.mockResolvedValueOnce( + commandResult({ code: 2, stdout: '{"success":false,"error":"denied"}\n' }), + ); + await expect(runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"] })).rejects.toThrow( + "denied", + ); + + runCommandMock.mockResolvedValueOnce(commandResult({ code: 2, stdout: "", stderr: "boom" })); + await expect(runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"] })).rejects.toThrow( + "boom", + ); + }); + + it("rejects successful non-JSON and success=false output", async () => { + runCommandMock.mockResolvedValueOnce(commandResult({ stdout: "not json" })); + await expect(runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"] })).rejects.toThrow( + "imsg returned non-JSON output: not json", + ); + + runCommandMock.mockResolvedValueOnce( + commandResult({ stdout: '{"success":false,"error":"failed"}\n' }), + ); + await expect(runIMessageCliJsonCommand({ cliPath: "imsg", args: ["send"] })).rejects.toThrow( + "failed", + ); }); }); diff --git a/extensions/imessage/src/cli-output.ts b/extensions/imessage/src/cli-output.ts index 7203eef93815..4a2652a5c8ef 100644 --- a/extensions/imessage/src/cli-output.ts +++ b/extensions/imessage/src/cli-output.ts @@ -1,55 +1,72 @@ -// Imessage plugin module implements cli output behavior. -import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +// Bounded one-shot iMessage CLI execution shared by action and send surfaces. +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; -const IMESSAGE_CLI_STDOUT_MAX_CHARS = 8 * 1024 * 1024; -const IMESSAGE_CLI_STDERR_TAIL_CHARS = 64 * 1024; +export const IMESSAGE_CLI_STDOUT_MAX_BYTES = 8 * 1024 * 1024; +export const IMESSAGE_CLI_STDERR_TAIL_BYTES = 64 * 1024; -type AppendStdoutResult = { ok: true; value: string } | { ok: false; message: string }; - -function chunkToString(chunk: string | Buffer): string { - return typeof chunk === "string" ? chunk : chunk.toString("utf8"); -} - -export function listenForIMessageCliStreamErrors(params: { - child: Pick; - isSettled: () => boolean; - fail: (error: Error) => void; -}): void { - for (const stream of ["stdout", "stderr"] as const) { - // Keep the listener after settlement: late stream errors still need to be - // consumed even though they can no longer change the command result. - params.child[stream].on("error", (error) => { - if (params.isSettled()) { - return; - } - params.fail(new Error(`iMessage CLI ${stream} stream error: ${error.message}`)); - try { - params.child.kill("SIGKILL"); - } catch { - // The helper may already be gone. - } - }); +function parseLastJsonObject(stdout: string): Record | null { + const last = stdout + .split(/\r?\n/u) + .findLast((line) => line.trim().length > 0) + ?.trim(); + if (!last) { + return null; + } + try { + const value = JSON.parse(last) as unknown; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } catch { + return null; } } -export function appendIMessageCliStdout( - current: string, - chunk: string | Buffer, - maxChars = IMESSAGE_CLI_STDOUT_MAX_CHARS, -): AppendStdoutResult { - const next = current + chunkToString(chunk); - if (next.length > maxChars) { - return { ok: false, message: `imsg stdout exceeded ${maxChars} characters` }; +export async function runIMessageCliJsonCommand(params: { + cliPath: string; + dbPath?: string; + args: readonly string[]; + timeoutMs?: number; +}): Promise> { + const dbPath = params.dbPath?.trim(); + const argv = [params.cliPath, ...params.args, ...(dbPath ? ["--db", dbPath] : []), "--json"]; + const result = await runCommandWithTimeout(argv, { + killProcessTree: true, + maxOutputBytes: { + stdout: IMESSAGE_CLI_STDOUT_MAX_BYTES, + stderr: IMESSAGE_CLI_STDERR_TAIL_BYTES, + }, + outputCapture: { stdout: "head", stderr: "tail" }, + terminateOnOutputLimit: { stdout: true }, + timeoutMs: params.timeoutMs, + }); + if (result.termination === "timeout") { + throw new Error(`iMessage action timed out after ${params.timeoutMs}ms`); + } + if (result.outputLimitExceeded || result.stdoutTruncatedBytes) { + throw new Error(`imsg stdout exceeded ${IMESSAGE_CLI_STDOUT_MAX_BYTES} bytes`); } - return { ok: true, value: next }; -} -export function appendIMessageCliStderrTail( - current: string, - chunk: string | Buffer, - maxChars = IMESSAGE_CLI_STDERR_TAIL_CHARS, -): string { - const next = current + chunkToString(chunk); - return next.length > maxChars ? sliceUtf16Safe(next, -maxChars) : next; + const parsed = parseLastJsonObject(result.stdout); + if (result.code !== 0) { + const detail = + (typeof parsed?.error === "string" && parsed.error.trim()) || + result.stderr.trim() || + result.stdout.trim() || + `imsg exited with code ${result.code}`; + throw new Error(detail); + } + if (!parsed) { + throw new Error( + `imsg returned non-JSON output: ${result.stdout.trim() || result.stderr.trim()}`, + ); + } + if (parsed.success === false) { + const detail = + typeof parsed.error === "string" && parsed.error.trim() + ? parsed.error.trim() + : "iMessage action failed"; + throw new Error(detail); + } + return parsed; } diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 05655b45d3d3..5ccebcf4f169 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -1,5 +1,4 @@ // Imessage tests cover send plugin behavior. -import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -30,13 +29,6 @@ const IMESSAGE_TEST_CFG = { }, }; -const spawnMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", async (importOriginal) => ({ - ...(await importOriginal()), - spawn: spawnMock, -})); - function createClient(result: Record): IMessageRpcClient { return { request: vi.fn(async () => result), @@ -87,7 +79,6 @@ describe("sendMessageIMessage receipts", () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); vi.useRealTimers(); - spawnMock.mockReset(); }); it("attaches a text receipt for native send ids", async () => { @@ -1277,26 +1268,7 @@ describe("sendMessageIMessage receipts", () => { }); }); -function mockSpawnWithStreamError(stream: "stdout" | "stderr", error: Error) { - const kill = vi.fn(); - spawnMock.mockImplementationOnce(() => { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter & { setEncoding: (encoding: string) => void }; - stderr: EventEmitter & { setEncoding: (encoding: string) => void }; - kill: (signal: string) => void; - }; - child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.kill = kill; - queueMicrotask(() => { - child[stream].emit("error", error); - }); - return child; - }); - return kill; -} - -describe("sendMessageIMessage CLI stream errors", () => { +describe("sendMessageIMessage CLI wrapper errors", () => { beforeEach(() => { installIMessageStateRuntimeForTest(); resetIMessageShortIdState(); @@ -1310,32 +1282,19 @@ describe("sendMessageIMessage CLI stream errors", () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); vi.useRealTimers(); - spawnMock.mockReset(); }); - it("rejects on stdout stream error during attachment send", async () => { - const kill = mockSpawnWithStreamError("stdout", new Error("stdout pipe broken")); + it("preserves canonical CLI wrapper errors during attachment send", async () => { + const wrapperError = new Error("imsg execution failed"); + const runCliJson = vi.fn().mockRejectedValue(wrapperError); await expect( sendMessageIMessage("chat_guid:chat-1", "", { config: IMESSAGE_TEST_CFG, mediaUrl: "/tmp/image.png", + runCliJson, resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }), }), - ).rejects.toThrow("iMessage CLI stdout stream error: stdout pipe broken"); - expect(kill).toHaveBeenCalledWith("SIGKILL"); - }); - - it("rejects on stderr stream error during attachment send", async () => { - const kill = mockSpawnWithStreamError("stderr", new Error("stderr pipe broken")); - - await expect( - sendMessageIMessage("chat_guid:chat-1", "", { - config: IMESSAGE_TEST_CFG, - mediaUrl: "/tmp/image.png", - resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }), - }), - ).rejects.toThrow("iMessage CLI stderr stream error: stderr pipe broken"); - expect(kill).toHaveBeenCalledWith("SIGKILL"); + ).rejects.toBe(wrapperError); }); }); diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 9943fb75ef5e..162b0eede5ac 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,5 +1,4 @@ // Imessage plugin module implements send behavior. -import { spawn } from "node:child_process"; import { constants, accessSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; @@ -25,11 +24,7 @@ import { type IMessageApprovalConversationKey, registerIMessageApprovalReactionTargetForOutboundMessage, } from "./approval-reactions.js"; -import { - appendIMessageCliStderrTail, - appendIMessageCliStdout, - listenForIMessageCliStreamErrors, -} from "./cli-output.js"; +import { runIMessageCliJsonCommand } from "./cli-output.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { DEFAULT_IMESSAGE_SEND_TIMEOUT_MS } from "./constants.js"; import { extractMarkdownFormatRuns } from "./markdown-format.js"; @@ -558,11 +553,6 @@ function resolveOutboundEchoScope(params: { return `${params.accountId}:imessage:${params.target.to}`; } -function buildIMessageCliJsonArgs(args: readonly string[], dbPath?: string): string[] { - const trimmedDbPath = dbPath?.trim(); - return [...args, ...(trimmedDbPath ? ["--db", trimmedDbPath] : []), "--json"]; -} - function resolveIMessageCliFailure(result: Record): string | null { if (result.success !== false) { return null; @@ -583,124 +573,11 @@ async function runIMessageCliJson( args: readonly string[], timeoutMs?: number, ): Promise> { - return await new Promise((resolve, reject) => { - const child = spawn(cliPath, buildIMessageCliJsonArgs(args, dbPath), { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - let killEscalation: ReturnType | null = null; - let settled = false; - const clearTimers = (options: { keepKillEscalation?: boolean } = {}): void => { - if (timer) { - clearTimeout(timer); - } - if (killEscalation && !options.keepKillEscalation) { - clearTimeout(killEscalation); - } - }; - const fail = (error: Error, options: { keepKillEscalation?: boolean } = {}): void => { - if (settled) { - return; - } - settled = true; - clearTimers(options); - reject(error); - }; - const succeed = (value: Record): void => { - if (settled) { - return; - } - settled = true; - clearTimers(); - resolve(value); - }; - const timer = - timeoutMs && timeoutMs > 0 - ? setTimeout(() => { - child.kill("SIGTERM"); - killEscalation = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // best-effort - } - }, 2000); - fail(new Error(`iMessage action timed out after ${timeoutMs}ms`), { - keepKillEscalation: true, - }); - }, timeoutMs) - : null; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - if (settled) { - return; - } - const appended = appendIMessageCliStdout(stdout, chunk); - if (!appended.ok) { - try { - child.kill("SIGKILL"); - } catch { - // best-effort - } - fail(new Error(appended.message)); - return; - } - stdout = appended.value; - }); - child.stderr.on("data", (chunk) => { - stderr = appendIMessageCliStderrTail(stderr, chunk); - }); - listenForIMessageCliStreamErrors({ - child, - isSettled: () => settled, - fail, - }); - child.on("error", (error) => { - if (settled) { - clearTimers(); - return; - } - fail(error); - }); - child.on("close", (code) => { - if (settled) { - clearTimers(); - return; - } - const lines = stdout - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean); - const last = lines.at(-1); - let parsed: Record | null = null; - if (last) { - try { - const json = JSON.parse(last) as unknown; - if (json && typeof json === "object" && !Array.isArray(json)) { - parsed = json as Record; - } - } catch { - // handled below - } - } - if (code === 0 && parsed) { - const failure = resolveIMessageCliFailure(parsed); - if (failure) { - fail(new Error(failure)); - return; - } - succeed(parsed); - return; - } - if (parsed && typeof parsed.error === "string" && parsed.error.trim()) { - fail(new Error(parsed.error.trim())); - return; - } - const detail = stderr.trim() || stdout.trim() || `imsg exited with code ${code}`; - fail(new Error(detail)); - }); + return await runIMessageCliJsonCommand({ + args, + cliPath, + dbPath, + timeoutMs, }); } diff --git a/extensions/logbook/src/node-host.ts b/extensions/logbook/src/node-host.ts index 299375d189f7..761764eb572c 100644 --- a/extensions/logbook/src/node-host.ts +++ b/extensions/logbook/src/node-host.ts @@ -1,15 +1,12 @@ // Logbook node-host command: screen capture for headless node hosts (macOS). // Nodes without the OpenClaw app (plain `openclaw node host run`) advertise // logbook.snapshot so capture works anywhere the plugin is enabled. -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -const execFileAsync = promisify(execFile); - type LogbookSnapshotParams = { screenIndex?: number; maxWidth?: number; @@ -57,26 +54,26 @@ export async function handleLogbookSnapshot(rawParams: unknown): Promise boolean; - -async function importDepsWithSpawnMock( - spawnMock: ReturnType, -): Promise { - vi.resetModules(); - vi.doMock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: spawnMock, - }; - }); - return await import("./deps.js"); -} - -function waitForChildClose(proc: ChildProcessWithoutNullStreams) { - return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("timed out waiting for matrix command child close")); - }, 5_000); - timer.unref?.(); - proc.once("close", (code, signal) => { - clearTimeout(timer); - resolve({ code, signal }); - }); - }); -} - -function waitForReadableData(stream: NodeJS.ReadableStream) { - return new Promise((resolve, reject) => { - let cleanup = () => {}; - const timer = setTimeout(() => { - cleanup(); - reject(new Error("timed out waiting for matrix command child output")); - }, 5_000); - timer.unref?.(); - cleanup = () => { - clearTimeout(timer); - stream.off("data", onData); - stream.off("error", onError); - }; - const onData = () => { - cleanup(); - resolve(); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - stream.once("data", onData); - stream.once("error", onError); - }); -} - -function waitForReadableErrorDispatch() { - return new Promise((resolve) => { - setImmediate(resolve); - }); -} - -afterEach(() => { - vi.useRealTimers(); - vi.doUnmock("node:child_process"); -}); - function resolveTestNativeBindingFilename(): string | null { switch (process.platform) { case "darwin": @@ -284,78 +217,17 @@ describe("runFixedCommandWithTimeout", () => { expect(result.stderr).not.toContain("\uFFFD"); }); - it("settles real child stream errors after child close and terminates once", async () => { - const actual = await vi.importActual("node:child_process"); + it("returns the documented timeout exit code", async () => { + const result = await runFixedCommandWithTimeout({ + argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"], + cwd: process.cwd(), + timeoutMs: 25, + }); - for (const streamName of ["stdout", "stderr"] as const) { - let proc: ChildProcessWithoutNullStreams | undefined; - let killSpy: MockInstance | undefined; - try { - const spawnMock = vi.fn( - (command: string, args: string[] | undefined, options: SpawnOptions) => { - proc = actual.spawn(command, args ?? [], options) as ChildProcessWithoutNullStreams; - return proc; - }, - ); - const { runFixedCommandWithTimeout: runWithMockedSpawn } = - await importDepsWithSpawnMock(spawnMock); - const exitListenersBefore = process.listenerCount("exit"); - - const resultPromise = runWithMockedSpawn({ - argv: [ - process.execPath, - "-e", - [ - "process.stdin.resume();", - 'process.on("SIGTERM", () => {});', - 'process.stdout.write("stdout ready\\n");', - 'process.stderr.write("stderr ready\\n");', - "setInterval(() => {}, 1000);", - ].join(""), - ], - cwd: process.cwd(), - timeoutMs: 10_000, - }); - if (!proc) { - throw new Error("expected matrix command helper to spawn a child process"); - } - killSpy = vi.spyOn(proc, "kill"); - const closePromise = waitForChildClose(proc); - await Promise.all([waitForReadableData(proc.stdout), waitForReadableData(proc.stderr)]); - let settled = false; - void resultPromise.then(() => { - settled = true; - }); - const message = `synthetic parent ${streamName} read failure`; - - proc[streamName].destroy(new Error(message)); - await waitForReadableErrorDispatch(); - expect(settled).toBe(false); - expect(process.listenerCount("exit")).toBe(exitListenersBefore + 1); - expect(killSpy).toHaveBeenCalledTimes(1); - expect(killSpy).toHaveBeenCalledWith("SIGTERM"); - - const duplicateStreamName = streamName === "stdout" ? "stderr" : "stdout"; - proc[duplicateStreamName].destroy(new Error("duplicate parent readable failure")); - await waitForReadableErrorDispatch(); - expect(killSpy).toHaveBeenCalledTimes(1); - - const result = await resultPromise; - const close = await closePromise; - - expect(result.code).toBe(1); - expect(result.stderr).toContain(`${streamName} stream failed: ${message}`); - expect(result.stderr).not.toContain("duplicate parent readable failure"); - expect(close).toStrictEqual({ code: null, signal: "SIGKILL" }); - expect(killSpy).toHaveBeenLastCalledWith("SIGKILL"); - expect(process.listenerCount("exit")).toBe(exitListenersBefore); - } finally { - killSpy?.mockRestore(); - if (proc && proc.exitCode === null && !proc.killed) { - proc.kill("SIGKILL"); - } - } - } + expect(result).toMatchObject({ + code: 124, + stderr: "command timed out after 25ms", + }); }); }); diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 3ed34dc5ea30..aee21ed802f0 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -1,9 +1,9 @@ // Matrix plugin module implements deps behavior. -import { spawn } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; const REQUIRED_MATRIX_PACKAGES = [ @@ -13,7 +13,6 @@ const REQUIRED_MATRIX_PACKAGES = [ ]; const MIN_MATRIX_CRYPTO_NATIVE_BINDING_BYTES = 1_000_000; export const MATRIX_COMMAND_OUTPUT_TAIL_BYTES = 64 * 1024; -const MATRIX_STREAM_ERROR_KILL_GRACE_MS = 1_000; type MatrixCryptoRuntimeDeps = { requireFn?: (id: string) => unknown; @@ -52,147 +51,38 @@ type CommandResult = { let defaultMatrixCryptoRuntimeEnsurePromise: Promise | null = null; -function sliceUtf8OutputTail(buffer: Buffer): Buffer { - let start = Math.max(0, buffer.byteLength - MATRIX_COMMAND_OUTPUT_TAIL_BYTES); - while (start < buffer.byteLength) { - const byte = buffer[start]; - if (byte === undefined || (byte & 0xc0) !== 0x80) { - break; - } - start++; - } - return buffer.subarray(start); -} - -function appendBoundedOutputTail(current: Buffer, chunk: Buffer | string): Buffer { - const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - if (chunkBuffer.byteLength >= MATRIX_COMMAND_OUTPUT_TAIL_BYTES) { - return sliceUtf8OutputTail(chunkBuffer); - } - - const nextBytes = current.byteLength + chunkBuffer.byteLength; - if (nextBytes <= MATRIX_COMMAND_OUTPUT_TAIL_BYTES) { - return Buffer.concat([current, chunkBuffer], nextBytes); - } - - return sliceUtf8OutputTail(Buffer.concat([current, chunkBuffer], nextBytes)); -} - -function decodeOutputTail(output: Buffer): string { - return sliceUtf8OutputTail(output).toString("utf8"); -} - export async function runFixedCommandWithTimeout(params: { argv: string[]; cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv; }): Promise { - return await new Promise((resolve) => { - const [command, ...args] = params.argv; - if (!command) { - resolve({ - code: 1, - stdout: "", - stderr: "command is required", - }); - return; - } - - const proc = spawn(command, args, { + if (!params.argv[0]) { + return { code: 1, stdout: "", stderr: "command is required" }; + } + try { + const result = await runCommandWithTimeout(params.argv, { cwd: params.cwd, - env: { ...process.env, ...params.env }, - stdio: ["ignore", "pipe", "pipe"], + env: params.env, + killProcessTree: true, + maxOutputBytes: MATRIX_COMMAND_OUTPUT_TAIL_BYTES, + outputCapture: "tail", + timeoutMs: params.timeoutMs, }); - - let stdout: Buffer = Buffer.alloc(0); - let stderr: Buffer = Buffer.alloc(0); - let settled = false; - let timer: NodeJS.Timeout | null = null; - let streamKillTimer: NodeJS.Timeout | null = null; - let streamErrorMessage: string | null = null; - const killChildOnExit = () => { - if (!settled && proc.exitCode === null) { - proc.kill("SIGTERM"); - } + return { + code: result.termination === "timeout" ? 124 : (result.code ?? 1), + stdout: result.stdout, + stderr: + result.stderr || + (result.termination === "timeout" ? `command timed out after ${params.timeoutMs}ms` : ""), }; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - if (streamKillTimer) { - clearTimeout(streamKillTimer); - } - process.off("exit", killChildOnExit); - resolve(result); + } catch (error) { + return { + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), }; - process.once("exit", killChildOnExit); - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout = appendBoundedOutputTail(stdout, chunk); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr = appendBoundedOutputTail(stderr, chunk); - }); - const failReadableStream = (streamName: "stdout" | "stderr") => (error: Error) => { - if (settled || streamErrorMessage) { - return; - } - streamErrorMessage = `${streamName} stream failed: ${formatErrorMessage(error)}`; - if (proc.exitCode === null) { - proc.kill("SIGTERM"); - } - streamKillTimer = setTimeout(() => { - if (!settled && proc.exitCode === null) { - proc.kill("SIGKILL"); - } - }, MATRIX_STREAM_ERROR_KILL_GRACE_MS); - streamKillTimer.unref?.(); - }; - proc.stdout?.on("error", failReadableStream("stdout")); - proc.stderr?.on("error", failReadableStream("stderr")); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - if (streamErrorMessage) { - return; - } - finalize({ - code: 124, - stdout: decodeOutputTail(stdout), - stderr: decodeOutputTail(stderr) || `command timed out after ${params.timeoutMs}ms`, - }); - }, params.timeoutMs); - - proc.on("error", (err) => { - if (streamErrorMessage) { - return; - } - finalize({ - code: 1, - stdout: decodeOutputTail(stdout), - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - const streamErrorStderr = streamErrorMessage - ? stderr.byteLength > 0 - ? appendBoundedOutputTail(stderr, `\n${streamErrorMessage}`) - : Buffer.from(streamErrorMessage) - : stderr; - finalize({ - code: streamErrorMessage ? 1 : (code ?? 1), - stdout: decodeOutputTail(stdout), - stderr: decodeOutputTail(streamErrorStderr), - }); - }); - }); + } } function defaultRequireFn(id: string): unknown { diff --git a/extensions/memory-wiki/src/obsidian.ts b/extensions/memory-wiki/src/obsidian.ts index 33db9c7942f7..18dfd94aacbc 100644 --- a/extensions/memory-wiki/src/obsidian.ts +++ b/extensions/memory-wiki/src/obsidian.ts @@ -1,13 +1,10 @@ // Memory Wiki plugin module implements obsidian behavior. -import { execFile } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import type { ResolvedMemoryWikiConfig } from "./config.js"; -const execFileAsync = promisify(execFile); - type ObsidianCliProbe = { available: boolean; command: string | null; @@ -21,7 +18,11 @@ type ObsidianCliResult = { }; type ObsidianCliDeps = { - exec?: typeof execFileAsync; + exec?: ( + command: string, + args: string[], + options: { encoding: "utf8" }, + ) => Promise<{ stdout: string; stderr: string }>; resolveCommand?: (command: string) => Promise; }; @@ -80,13 +81,14 @@ async function runObsidianCli(params: { deps?: ObsidianCliDeps; }): Promise { const resolveCommand = params.deps?.resolveCommand ?? resolveCommandOnPath; - const exec = params.deps?.exec ?? execFileAsync; const probe = await probeObsidianCli({ resolveCommand }); if (!probe.command) { throw new Error("Obsidian CLI is not available on PATH."); } const argv = [...buildVaultPrefix(params.config), params.subcommand, ...(params.args ?? [])]; - const { stdout, stderr } = await exec(probe.command, argv, { encoding: "utf8" }); + const { stdout, stderr } = params.deps?.exec + ? await params.deps.exec(probe.command, argv, { encoding: "utf8" }) + : await runExec(probe.command, argv, { logOutput: false }); return { command: probe.command, argv, diff --git a/extensions/microsoft-foundry/cli.ts b/extensions/microsoft-foundry/cli.ts index 9b65841ded97..9949b49d6472 100644 --- a/extensions/microsoft-foundry/cli.ts +++ b/extensions/microsoft-foundry/cli.ts @@ -1,5 +1,6 @@ // Microsoft Foundry plugin module implements cli behavior. -import { execFile, execFileSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { normalizeOptionalString, normalizeStringifiedOptionalString, @@ -56,24 +57,18 @@ export function execAz(args: string[]): string { } async function execAzAsync(args: string[]): Promise { - return await new Promise((resolve, reject) => { - execFile( - "az", - args, - { - encoding: "utf-8", - timeout: 30_000, - shell: process.platform === "win32", - }, - (error, stdout, stderr) => { - if (error) { - reject(buildAzCommandError(error, stderr ?? "", stdout ?? "")); - return; - } - resolve(normalizeStringifiedOptionalString(stdout) ?? ""); - }, + try { + const { stdout } = await runExec("az", args, { logOutput: false, timeoutMs: 30_000 }); + return normalizeStringifiedOptionalString(stdout) ?? ""; + } catch (error) { + const commandError = error instanceof Error ? error : new Error(String(error)); + const output = error as { stderr?: unknown; stdout?: unknown }; + throw buildAzCommandError( + commandError, + typeof output.stderr === "string" ? output.stderr : "", + typeof output.stdout === "string" ? output.stdout : "", ); - }); + } } export function isAzCliInstalled(): boolean { diff --git a/extensions/mxc/src/mxc-backend.ts b/extensions/mxc/src/mxc-backend.ts index e7aa7d40ab70..333b180feb1b 100644 --- a/extensions/mxc/src/mxc-backend.ts +++ b/extensions/mxc/src/mxc-backend.ts @@ -1,8 +1,8 @@ -import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; import { mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { ContainerConfig } from "@microsoft/mxc-sdk"; +import { runCommandBuffered } from "openclaw/plugin-sdk/process-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; import type { SandboxBackendHandle, @@ -297,33 +297,36 @@ export function createMxcSandboxBackendHandle(params: { sandboxTempDir, ); const argv = buildMxcLauncherArgv(payloadFile.payloadFile); - const [binaryPath, ...args] = argv; try { - return await execFileBuffered(binaryPath, args, { - env: buildLauncherEnv(), + const result = await runCommandBuffered(argv, { + baseEnv: buildLauncherEnv(), input: execInput, - timeout: 30_000, - maxBuffer: 10 * 1024 * 1024, + maxOutputBytes: { stdout: 10 * 1024 * 1024, stderr: 10 * 1024 * 1024 }, signal: cmdParams.signal, + timeoutMs: 30_000, }); - } catch (err: unknown) { - if (isAbortError(err)) { - throw err; + if (cmdParams.signal?.aborted) { + throw cmdParams.signal.reason instanceof Error + ? cmdParams.signal.reason + : (result.error ?? new Error("MXC command aborted")); } - const execErr = err as { - stdout?: Buffer | string; - stderr?: Buffer | string; - status?: number; - code?: number; - }; - if (cmdParams.allowFailure) { - return { - stdout: toOptionalBuffer(execErr.stdout), - stderr: toOptionalBuffer(execErr.stderr), - code: execErr.status ?? execErr.code ?? 1, - }; + const { stdout, stderr } = result; + const code = result.termination === "exit" ? (result.code ?? 1) : 1; + if ((result.termination !== "exit" || code !== 0) && !cmdParams.allowFailure) { + const commandError = + result.error ?? + new Error( + result.termination === "exit" + ? `MXC command exited with code ${code}` + : `MXC command terminated: ${result.termination}`, + ); + throw Object.assign(commandError, { + stdout, + stderr, + status: code, + }); } - throw err; + return { stdout, stderr, code }; } finally { cleanupLauncherPayloadFile(payloadFile); } @@ -335,69 +338,6 @@ export function createMxcSandboxBackendHandle(params: { }; } -function execFileBuffered( - binaryPath: string, - args: readonly string[], - options: { - env: NodeJS.ProcessEnv; - input: Buffer; - timeout: number; - maxBuffer: number; - signal?: AbortSignal; - }, -): Promise { - return new Promise((resolve, reject) => { - const child = execFile( - binaryPath, - [...args], - { - encoding: "buffer", - env: options.env, - timeout: options.timeout, - maxBuffer: options.maxBuffer, - signal: options.signal, - }, - (error, stdout, stderr) => { - const stdoutBuffer = toOptionalBuffer(stdout); - const stderrBuffer = toOptionalBuffer(stderr); - if (error) { - const errorStatus = (error as { status?: unknown }).status; - const status = - typeof error.code === "number" - ? error.code - : typeof errorStatus === "number" - ? errorStatus - : 1; - const rejection: Error = Object.assign(error, { - stdout: stdoutBuffer, - stderr: stderrBuffer, - status, - }); - reject(rejection); - return; - } - resolve({ stdout: stdoutBuffer, stderr: stderrBuffer, code: 0 }); - }, - ); - child.stdin?.end(options.input); - }); -} - -function isAbortError(err: unknown): boolean { - return ( - err instanceof Error && - (err.name === "AbortError" || - ("code" in err && (err as { code?: unknown }).code === "ABORT_ERR")) - ); -} - -function toOptionalBuffer(value: Buffer | string | undefined): Buffer { - if (value === undefined) { - return Buffer.alloc(0); - } - return toBuffer(value); -} - function toBuffer(value: Buffer | string): Buffer { if (Buffer.isBuffer(value)) { return value; diff --git a/extensions/mxc/test/mxc-backend.test.ts b/extensions/mxc/test/mxc-backend.test.ts index fbe0d5a9c247..217afcb5fee9 100644 --- a/extensions/mxc/test/mxc-backend.test.ts +++ b/extensions/mxc/test/mxc-backend.test.ts @@ -18,11 +18,10 @@ import { resolveConfig, type MxcConfig } from "../src/config.js"; import { createMxcSandboxBackendFactory } from "../src/mxc-backend-factory.js"; import { createMxcSandboxBackendHandle, mxcSandboxBackendManager } from "../src/mxc-backend.js"; -const { execFileMock, execFileSyncMock, mockedHomeDir, stdinEndMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), +const { spawnCommandMock, execFileSyncMock, mockedHomeDir } = vi.hoisted(() => ({ + spawnCommandMock: vi.fn(), execFileSyncMock: vi.fn(), mockedHomeDir: { value: undefined as string | undefined }, - stdinEndMock: vi.fn(), })); vi.mock("node:os", async (importOriginal) => { @@ -34,10 +33,13 @@ vi.mock("node:os", async (importOriginal) => { }); vi.mock("node:child_process", () => ({ - execFile: execFileMock, execFileSync: execFileSyncMock, })); +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandBuffered: spawnCommandMock, +})); + vi.mock("../src/binary-resolver.js", () => ({ resolveMxcBinaryPath: (configuredPath?: string) => configuredPath ?? "mxc-test-binary", })); @@ -204,19 +206,15 @@ async function withProcessEnv( describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests)", () => { beforeEach(() => { - execFileMock.mockReset(); - stdinEndMock.mockReset(); - execFileMock.mockImplementation( - ( - _binaryPath: string, - _args: readonly string[], - _options: unknown, - callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, - ) => { - callback(null, Buffer.from(""), Buffer.alloc(0)); - return { stdin: { end: stdinEndMock } }; - }, - ); + spawnCommandMock.mockReset(); + spawnCommandMock.mockResolvedValue({ + code: 0, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); mockedHomeDir.value = mkdtempSync(path.join(tmpdir(), "mxc-test-home-")); testDirs.push(mockedHomeDir.value); baseParams.workdir = mkdtempSync(path.join(tmpdir(), "mxc-test-workspace-")); @@ -1202,18 +1200,17 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests test("runShellCommand uses the inline Windows command line when no args are passed", async () => { let processConfig: Record | undefined; - execFileMock.mockImplementationOnce( - ( - _binaryPath: string, - args: readonly string[], - _options: unknown, - callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, - ) => { - processConfig = objectField(decodeContainerConfig(args), "process"); - callback(null, Buffer.from(""), Buffer.alloc(0)); - return { stdin: { end: stdinEndMock } }; - }, - ); + spawnCommandMock.mockImplementationOnce(async (argv: string[]) => { + processConfig = objectField(decodeContainerConfig(argv), "process"); + return { + code: 0, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }; + }); const handle = createMxcSandboxBackendHandle(baseParams); await handle.runShellCommand({ script: "echo hello", @@ -1228,18 +1225,17 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests test("runShellCommand timeout is capped by sandbox policy", async () => { let processConfig: Record | undefined; - execFileMock.mockImplementationOnce( - ( - _binaryPath: string, - args: readonly string[], - _options: unknown, - callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, - ) => { - processConfig = objectField(decodeContainerConfig(args), "process"); - callback(null, Buffer.from(""), Buffer.alloc(0)); - return { stdin: { end: stdinEndMock } }; - }, - ); + spawnCommandMock.mockImplementationOnce(async (argv: string[]) => { + processConfig = objectField(decodeContainerConfig(argv), "process"); + return { + code: 0, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }; + }); const handle = createMxcSandboxBackendHandle({ ...baseParams, config: sandboxPolicyConfig( @@ -1271,24 +1267,29 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests let bridgeScript: string | undefined; let commandFile: string | undefined; let launcherEnv: NodeJS.ProcessEnv | undefined; + let launcherInput: Uint8Array | string | undefined; let processConfig: Record | undefined; - execFileMock.mockImplementationOnce( - ( - _binaryPath: string, - args: readonly string[], - options: unknown, - callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, - ) => { - launcherEnv = (options as { env?: NodeJS.ProcessEnv }).env; - processConfig = objectField(decodeContainerConfig(args), "process"); - const commandLine = String(processConfig.commandLine); - commandFile = /""([^"]+\.cmd)"/u.exec(commandLine)?.[1]; - expect(commandFile).toEqual(expect.any(String)); - bridgeScript = readFileSync(commandFile ?? "", "utf-8"); - callback(null, Buffer.from(""), Buffer.alloc(0)); - return { stdin: { end: stdinEndMock } }; - }, - ); + spawnCommandMock.mockImplementationOnce(async (argv: string[], options: unknown) => { + const spawnOptions = options as { + baseEnv?: NodeJS.ProcessEnv; + input?: Uint8Array | string; + }; + launcherEnv = spawnOptions.baseEnv; + launcherInput = spawnOptions.input; + processConfig = objectField(decodeContainerConfig(argv), "process"); + const commandLine = String(processConfig.commandLine); + commandFile = /""([^"]+\.cmd)"/u.exec(commandLine)?.[1]; + expect(commandFile).toEqual(expect.any(String)); + bridgeScript = readFileSync(commandFile ?? "", "utf-8"); + return { + code: 0, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }; + }); const handle = createMxcSandboxBackendHandle({ ...baseParams, workdir: baseParams.workdir, @@ -1316,7 +1317,7 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests expect(env.some((entry) => entry.startsWith("OPENCLAW_MXC_SECRET_TEST="))).toBe(false); expect(launcherEnv?.SystemRoot).toBe("C:\\Windows"); expect(launcherEnv?.OPENCLAW_MXC_SECRET_TEST).toBeUndefined(); - expect(stdinEndMock).toHaveBeenCalledWith(Buffer.from("shell-input", "utf-8")); + expect(launcherInput).toEqual(Buffer.from("shell-input", "utf-8")); expect(commandFile ? existsSync(path.dirname(commandFile)) : true).toBe(false); }, ); @@ -1333,21 +1334,20 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests let commandFile: string | undefined; let filesystemConfig: Record | undefined; let processConfig: Record | undefined; - execFileMock.mockImplementationOnce( - ( - _binaryPath: string, - args: readonly string[], - _options: unknown, - callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, - ) => { - const config = decodeContainerConfig(args); - filesystemConfig = objectField(config, "filesystem"); - processConfig = objectField(config, "process"); - commandFile = /""([^"]+\.cmd)"/u.exec(String(processConfig.commandLine))?.[1]; - callback(null, Buffer.from(""), Buffer.alloc(0)); - return { stdin: { end: stdinEndMock } }; - }, - ); + spawnCommandMock.mockImplementationOnce(async (argv: string[]) => { + const config = decodeContainerConfig(argv); + filesystemConfig = objectField(config, "filesystem"); + processConfig = objectField(config, "process"); + commandFile = /""([^"]+\.cmd)"/u.exec(String(processConfig.commandLine))?.[1]; + return { + code: 0, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }; + }); try { const handle = createMxcSandboxBackendHandle({ ...baseParams, @@ -1395,35 +1395,19 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests signal: controller.signal, }); - const call = execFileMock.mock.calls[0] as unknown as [ - string, - string[], - { signal?: AbortSignal }, - ]; - const options = call[2]; - expect(options.signal).toBe(controller.signal); + const options = spawnCommandMock.mock.calls[0]?.[1] as { signal?: AbortSignal } | undefined; + expect(options?.signal).toBe(controller.signal); }); test("runShellCommand reports executor failures when allowed", async () => { - execFileMock.mockImplementationOnce( - ( - _binaryPath: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, - ) => { - const error = new Error("failed") as Error & { - stdout: Buffer; - stderr: Buffer; - status: number; - }; - error.stdout = Buffer.from("out"); - error.stderr = Buffer.from("err"); - error.status = 7; - callback(error, error.stdout, error.stderr); - return { stdin: { end: stdinEndMock } }; - }, - ); + spawnCommandMock.mockResolvedValueOnce({ + code: 7, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.from("out"), + stderr: Buffer.from("err"), + }); const handle = createMxcSandboxBackendHandle(baseParams); await expect( @@ -1431,6 +1415,48 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests ).resolves.toEqual({ stdout: Buffer.from("out"), stderr: Buffer.from("err"), code: 7 }); }); + test("runShellCommand rejects abnormal executor results even with a zero code", async () => { + spawnCommandMock.mockResolvedValueOnce({ + code: 0, + signal: null, + killed: false, + termination: "error", + stdout: Buffer.from("partial-out"), + stderr: Buffer.from("partial-err"), + error: new Error("stdout stream failed"), + }); + const handle = createMxcSandboxBackendHandle(baseParams); + + await expect( + handle.runShellCommand({ script: "echo partial", stdin: "", allowFailure: false }), + ).rejects.toMatchObject({ + message: "stdout stream failed", + status: 1, + stdout: Buffer.from("partial-out"), + stderr: Buffer.from("partial-err"), + }); + }); + + test("runShellCommand preserves timeout output when failures are allowed", async () => { + spawnCommandMock.mockResolvedValueOnce({ + code: null, + signal: "SIGTERM", + killed: true, + termination: "timeout", + stdout: Buffer.from("partial-out"), + stderr: Buffer.from("partial-err"), + }); + const handle = createMxcSandboxBackendHandle(baseParams); + + await expect( + handle.runShellCommand({ script: "sleep", stdin: "", allowFailure: true }), + ).resolves.toEqual({ + stdout: Buffer.from("partial-out"), + stderr: Buffer.from("partial-err"), + code: 1, + }); + }); + test("factory carries protected skill workspace context into the exec guard", async () => { const workdir = mkdtempSync(path.join(tmpdir(), "mxc-factory-workspace-")); const skillsWorkspaceDir = mkdtempSync(path.join(tmpdir(), "mxc-factory-skills-")); diff --git a/extensions/ollama/src/wsl2-crash-loop-check.test.ts b/extensions/ollama/src/wsl2-crash-loop-check.test.ts index 1f5e08af3ad0..5480bc4c705f 100644 --- a/extensions/ollama/src/wsl2-crash-loop-check.test.ts +++ b/extensions/ollama/src/wsl2-crash-loop-check.test.ts @@ -1,10 +1,9 @@ // Ollama tests cover wsl2 crash loop check plugin behavior. -import { promisify } from "node:util"; -import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { isWSL2SyncMock } = vi.hoisted(() => ({ +const { isWSL2SyncMock, runExecMock } = vi.hoisted(() => ({ isWSL2SyncMock: vi.fn(() => false), + runExecMock: vi.fn(), })); vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ @@ -15,15 +14,8 @@ vi.mock("node:fs/promises", () => ({ access: vi.fn(), })); -vi.mock("node:child_process", async () => { - const { promisify: realPromisify } = await import("node:util"); - const mockExecFile = vi.fn(); - const execFilePromise = vi.fn(); - (mockExecFile as unknown as Record)[realPromisify.custom] = execFilePromise; - return { execFile: mockExecFile }; -}); +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ runExec: runExecMock })); -import { execFile } from "node:child_process"; import { access } from "node:fs/promises"; import { checkWsl2CrashLoopRisk, @@ -33,12 +25,6 @@ import { } from "./wsl2-crash-loop-check.js"; const accessMock = vi.mocked(access); -const execFileMock = execFile as unknown as ReturnType & { - [key: symbol]: ReturnType; -}; -const execFilePromiseMock = vi.mocked( - expectDefined(execFileMock[promisify.custom], "promisified execFile mock"), -); function createLogger() { return { @@ -50,7 +36,7 @@ function createLogger() { } function mockSystemctl(stdout: string): void { - execFilePromiseMock.mockResolvedValue({ stdout, stderr: "" }); + runExecMock.mockResolvedValue({ stdout, stderr: "" }); } describe("wsl2 crash-loop check", () => { @@ -75,10 +61,10 @@ describe("wsl2 crash-loop check", () => { await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(true); - expect(execFilePromiseMock).toHaveBeenCalledWith( + expect(runExecMock).toHaveBeenCalledWith( "systemctl", ["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"], - { timeout: 5000 }, + { logOutput: false, timeoutMs: 5000 }, ); }); @@ -95,7 +81,7 @@ describe("wsl2 crash-loop check", () => { }); it("returns false when systemctl is unavailable", async () => { - execFilePromiseMock.mockRejectedValue(new Error("systemd unavailable")); + runExecMock.mockRejectedValue(new Error("systemd unavailable")); await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false); }); @@ -135,7 +121,7 @@ describe("wsl2 crash-loop check", () => { await checkWsl2CrashLoopRisk(logger); - expect(execFilePromiseMock).not.toHaveBeenCalled(); + expect(runExecMock).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); }); @@ -152,7 +138,7 @@ describe("wsl2 crash-loop check", () => { it("never throws from advisory checks", async () => { isWSL2SyncMock.mockReturnValue(true); - execFilePromiseMock.mockRejectedValue(new Error("boom")); + runExecMock.mockRejectedValue(new Error("boom")); const logger = createLogger(); await expect(checkWsl2CrashLoopRisk(logger)).resolves.toBeUndefined(); diff --git a/extensions/ollama/src/wsl2-crash-loop-check.ts b/extensions/ollama/src/wsl2-crash-loop-check.ts index de26779fd7f7..4e23334e2542 100644 --- a/extensions/ollama/src/wsl2-crash-loop-check.ts +++ b/extensions/ollama/src/wsl2-crash-loop-check.ts @@ -1,11 +1,9 @@ // Ollama plugin module implements wsl2 crash loop check behavior. -import { execFile } from "node:child_process"; import { access } from "node:fs/promises"; -import { promisify } from "node:util"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env"; -const execFileAsync = promisify(execFile); const SYSTEMCTL_TIMEOUT_MS = 5_000; const WSL_CUDA_MARKERS = [ "/dev/dxg", @@ -28,10 +26,10 @@ export function parseSystemctlShowProperties(stdout: string): Map { try { - const { stdout } = await execFileAsync( + const { stdout } = await runExec( "systemctl", ["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"], - { timeout: SYSTEMCTL_TIMEOUT_MS }, + { logOutput: false, timeoutMs: SYSTEMCTL_TIMEOUT_MS }, ); const properties = parseSystemctlShowProperties(stdout); return properties.get("UnitFileState") === "enabled" && properties.get("Restart") === "always"; diff --git a/extensions/onepassword/src/op-client.test.ts b/extensions/onepassword/src/op-client.test.ts index 31c6f8f14401..ce95914c0c9b 100644 --- a/extensions/onepassword/src/op-client.test.ts +++ b/extensions/onepassword/src/op-client.test.ts @@ -143,6 +143,7 @@ describe("OpClient", () => { ["FIELD_NOT_FOUND", { stderr: `"429 credential" isn't a field in the "Token" item`, code: 1 }], ["AUTH_FAILED", { stderr: "unauthorized service account", code: 1 }], ["TIMEOUT", { stderr: "", killed: true, signal: "SIGTERM" }], + ["TIMEOUT", { stderr: "", timedOut: true }], ["OP_ERROR", { stderr: "unexpected failure", code: 1 }], ] as const)("maps process failure to %s without retry", async (expectedCode, failure) => { const runner = vi.fn(async () => { diff --git a/extensions/onepassword/src/op-client.ts b/extensions/onepassword/src/op-client.ts index 07e9392ceea3..f8ff169db4ef 100644 --- a/extensions/onepassword/src/op-client.ts +++ b/extensions/onepassword/src/op-client.ts @@ -1,8 +1,8 @@ -import { execFile } from "node:child_process"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { OnePasswordError } from "./errors.js"; const MAX_STDOUT_BYTES = 1024 * 1024; @@ -46,37 +46,17 @@ type OpField = { value?: unknown; }; -function defaultRunner( +async function defaultRunner( file: string, args: string[], options: OpProcessOptions, ): Promise { - return new Promise((resolve, reject) => { - execFile( - file, - args, - { - env: options.env, - timeout: options.timeoutMs, - maxBuffer: options.maxBufferBytes, - encoding: "utf8", - windowsHide: true, - }, - (error, stdout, stderr) => { - if (error) { - reject( - Object.assign(new Error("1Password CLI process failed"), { - stderr, - code: error.code, - killed: error.killed, - signal: error.signal, - }), - ); - return; - } - resolve({ stdout, stderr }); - }, - ); + return await runExec(file, args, { + baseEnv: {}, + env: options.env, + logOutput: false, + maxBuffer: options.maxBufferBytes, + timeoutMs: options.timeoutMs, }); } @@ -120,7 +100,12 @@ function classifyOpError(error: unknown): OnePasswordError { if (record.code === "ENOENT") { return new OnePasswordError("OP_NOT_FOUND", "1Password CLI executable was not found"); } - if (record.killed === true || record.code === "ETIMEDOUT" || record.signal === "SIGTERM") { + if ( + record.killed === true || + record.timedOut === true || + record.code === "ETIMEDOUT" || + record.signal === "SIGTERM" + ) { return new OnePasswordError("TIMEOUT", "1Password CLI request timed out"); } if ( diff --git a/extensions/qa-lab/src/docker-harness.ts b/extensions/qa-lab/src/docker-harness.ts index ef55b1459e87..cb49bc06eeb7 100644 --- a/extensions/qa-lab/src/docker-harness.ts +++ b/extensions/qa-lab/src/docker-harness.ts @@ -1,9 +1,8 @@ // Qa Lab plugin module implements docker harness behavior. -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { toQaErrorObject } from "./errors.js"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { seedQaAgentWorkspace } from "./qa-agent-workspace.js"; import { createQaChannelGatewayConfig, @@ -345,15 +344,7 @@ export async function buildQaDockerHarnessImage( const runCommand = deps?.runCommand ?? (async (command: string, args: string[], cwd: string) => { - return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - execFile(command, args, { cwd }, (error, stdout, stderr) => { - if (error) { - reject(toQaErrorObject(error, "Non-Error rejection")); - return; - } - resolve({ stdout, stderr }); - }); - }); + return await runExec(command, args, { cwd, logOutput: false }); }); await runCommand( diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.setup.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.setup.ts index 766f75296196..8e5b37820648 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.setup.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.setup.ts @@ -1,12 +1,10 @@ // QA Lab WhatsApp auth archive and channel readiness setup. -import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { WhatsAppQaGateway } from "./whatsapp-live.contracts.js"; -const execFileAsync = promisify(execFile); const WHATSAPP_QA_READY_TIMEOUT_MS = 150_000; const WHATSAPP_QA_READY_STABILITY_MS = 20_000; const WHATSAPP_QA_SIGNAL_SESSION_FILE_RE = /^session-[^/\\]+\.json$/u; @@ -116,9 +114,7 @@ export async function waitForWhatsAppChannelStable(gateway: WhatsAppQaGateway, a } async function listTarEntries(archivePath: string): Promise { - const { stdout } = await execFileAsync("tar", ["-tzf", archivePath], { - maxBuffer: 1024 * 1024, - }); + const { stdout } = await runExec("tar", ["-tzf", archivePath], { logOutput: false }); return normalizeStringEntries(stdout.split("\n")); } @@ -145,7 +141,7 @@ export async function unpackWhatsAppAuthArchive(params: { await fs.writeFile(archivePath, Buffer.from(params.archiveBase64, "base64"), { mode: 0o600 }); const entries = await listTarEntries(archivePath); assertSafeArchiveEntries(entries); - await execFileAsync("tar", ["-xzf", archivePath, "-C", authDir], { maxBuffer: 1024 * 1024 }); + await runExec("tar", ["-xzf", archivePath, "-C", authDir], { logOutput: false }); await fs.rm(archivePath, { force: true }); if (params.clearSignalSessions === true) { await clearWhatsAppAuthSignalSessions(authDir); diff --git a/extensions/qa-lab/src/model-catalog.runtime.test.ts b/extensions/qa-lab/src/model-catalog.runtime.test.ts index 09286e40e44a..aa3ca2566af8 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.test.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.test.ts @@ -126,7 +126,6 @@ describe("qa runner model catalog", () => { const runPromise = loadQaRunnerModelOptions({ repoRoot, signal: controller.signal, - abortKillGraceMs: 100, }); await waitForFile(pidPath, 2_000); diff --git a/extensions/qa-lab/src/model-catalog.runtime.ts b/extensions/qa-lab/src/model-catalog.runtime.ts index 19ff7830a06b..098f115df23d 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.ts @@ -1,17 +1,9 @@ // Qa Lab plugin module implements model catalog behavior. -import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { - appendQaChildOutput, - appendQaChildOutputTail, - createQaChildOutputCapture, - createQaChildOutputTail, - formatQaChildOutputTail, - QA_CHILD_STDOUT_MAX_BYTES, - readQaChildOutput, -} from "./child-output.js"; +import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js"; import { resolveQaNodeExecPath } from "./node-exec.js"; import { isPreferredQaLiveFrontierCatalogModel, @@ -24,7 +16,6 @@ import { QA_CHANNEL_REQUIRED_PLUGIN_IDS, } from "./qa-channel-transport.js"; import { buildQaGatewayConfig } from "./qa-gateway-config.js"; -import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js"; type ModelRow = { key: string; @@ -108,77 +99,12 @@ function parseQaRunnerModelOptionsOutput(stdout: string): QaRunnerModelOption[] } const CATALOG_ABORT_ERROR_MESSAGE = "qa model catalog aborted"; -const CATALOG_ABORT_KILL_GRACE_MS = 1_000; -const CATALOG_ABORT_POLL_MS = 50; function createCatalogAbortError() { return new Error(CATALOG_ABORT_ERROR_MESSAGE); } -function killProcessTree(pid: number | undefined, signal: NodeJS.Signals) { - if (pid === undefined) { - return; - } - try { - if (process.platform === "win32") { - const killer = spawn( - resolveQaWindowsSystem32ExePath("taskkill.exe"), - ["/pid", String(pid), "/t", "/f"], - { - stdio: "ignore", - windowsHide: true, - }, - ); - killer.once("error", () => { - try { - process.kill(pid, signal); - } catch { - // The process already exited. - } - }); - return; - } - process.kill(-pid, signal); - } catch { - try { - process.kill(pid, signal); - } catch { - // The process already exited. - } - } -} - -function processTreeIsAlive(pid: number | undefined) { - if (pid === undefined || process.platform === "win32") { - return false; - } - try { - process.kill(-pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -} - -async function waitForProcessTreeExit(pid: number | undefined, timeoutMs: number) { - const deadlineAt = Date.now() + timeoutMs; - while (Date.now() < deadlineAt) { - if (!processTreeIsAlive(pid)) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, CATALOG_ABORT_POLL_MS); - }); - } - return !processTreeIsAlive(pid); -} - -export async function loadQaRunnerModelOptions(params: { - repoRoot: string; - signal?: AbortSignal; - abortKillGraceMs?: number; -}) { - const abortKillGraceMs = Math.max(1, params.abortKillGraceMs ?? CATALOG_ABORT_KILL_GRACE_MS); +export async function loadQaRunnerModelOptions(params: { repoRoot: string; signal?: AbortSignal }) { const tempRoot = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-qa-model-catalog-"), ); @@ -211,17 +137,12 @@ export async function loadQaRunnerModelOptions(params: { }); await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8"); - const stdout = createQaChildOutputCapture(); - const stderr = createQaChildOutputTail(); const nodeExecPath = await resolveQaNodeExecPath(); - await new Promise((resolve, reject) => { - let aborted = params.signal?.aborted === true; - let forceKillTimer: NodeJS.Timeout | undefined; - let forceKillAt: number | undefined; - const child = spawn(nodeExecPath, ["dist/index.js", "models", "list", "--all", "--json"], { + const result = await runCommandWithTimeout( + [nodeExecPath, "dist/index.js", "models", "list", "--all", "--json"], + { cwd: params.repoRoot, env: { - ...process.env, HOME: homeDir, OPENCLAW_HOME: homeDir, OPENCLAW_CONFIG_PATH: configPath, @@ -229,85 +150,36 @@ export async function loadQaRunnerModelOptions(params: { OPENCLAW_OAUTH_DIR: path.join(stateDir, "credentials"), OPENCLAW_CODEX_DISCOVERY_LIVE: "0", }, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }); - const cleanupAbortListener = () => { - params.signal?.removeEventListener("abort", abortCatalogLoad); - }; - const cleanup = () => { - cleanupAbortListener(); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - forceKillTimer = undefined; - } - }; - const finishAbortedCatalogLoad = async () => { - cleanupAbortListener(); - const graceRemainingMs = - forceKillAt === undefined ? abortKillGraceMs : Math.max(0, forceKillAt - Date.now()); - if (graceRemainingMs > 0) { - await waitForProcessTreeExit(child.pid, graceRemainingMs); - } - if (forceKillTimer) { - clearTimeout(forceKillTimer); - forceKillTimer = undefined; - } - if (processTreeIsAlive(child.pid)) { - killProcessTree(child.pid, "SIGKILL"); - await waitForProcessTreeExit(child.pid, abortKillGraceMs); - } - forceKillAt = undefined; - }; - const abortCatalogLoad = () => { - aborted = true; - killProcessTree(child.pid, "SIGTERM"); - forceKillAt = Date.now() + abortKillGraceMs; - forceKillTimer ??= setTimeout(() => { - forceKillAt = undefined; - killProcessTree(child.pid, "SIGKILL"); - }, abortKillGraceMs); - forceKillTimer.unref(); - }; - if (aborted) { - abortCatalogLoad(); - } else { - params.signal?.addEventListener("abort", abortCatalogLoad, { once: true }); - } - child.stdout.on("data", (chunk) => appendQaChildOutput(stdout, chunk)); - child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk)); - child.once("error", (error) => { - cleanup(); - reject(aborted ? createCatalogAbortError() : error); - }); - child.once("exit", (code) => { - cleanupAbortListener(); - if (aborted) { - void finishAbortedCatalogLoad().then( - () => reject(createCatalogAbortError()), - () => reject(createCatalogAbortError()), - ); - return; - } - cleanup(); - if (code === 0) { - if (stdout.exceeded) { - reject( - new Error( - `qa model catalog stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`, - ), - ); - return; - } - resolve(); - return; - } - const stderrText = formatQaChildOutputTail(stderr, "qa model catalog stderr"); - reject(new Error(`qa model catalog failed (${code ?? "unknown"}): ${stderrText}`)); - }); - }); + killProcessTree: true, + maxOutputBytes: { + stdout: QA_CHILD_STDOUT_MAX_BYTES, + stderr: QA_CHILD_STDERR_TAIL_BYTES, + }, + outputCapture: { stdout: "head", stderr: "tail" }, + signal: params.signal, + terminateOnOutputLimit: { stdout: true }, + }, + ); + if ( + params.signal?.aborted || + (result.termination === "signal" && !result.outputLimitExceeded) + ) { + throw createCatalogAbortError(); + } + if (result.outputLimitExceeded || result.stdoutTruncatedBytes) { + throw new Error( + `qa model catalog stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`, + ); + } + if (result.code !== 0) { + const stderrText = result.stderr.trim(); + const stderrDetail = result.stderrTruncatedBytes + ? `[qa model catalog stderr truncated to last ${QA_CHILD_STDERR_TAIL_BYTES} bytes]\n${stderrText}` + : stderrText; + throw new Error(`qa model catalog failed (${result.code ?? "unknown"}): ${stderrDetail}`); + } - return parseQaRunnerModelOptionsOutput(readQaChildOutput(stdout)); + return parseQaRunnerModelOptionsOutput(result.stdout); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/extensions/qa-lab/src/multipass.runtime.test.ts b/extensions/qa-lab/src/multipass.runtime.test.ts index c2d73c774c39..0aa51ae28773 100644 --- a/extensions/qa-lab/src/multipass.runtime.test.ts +++ b/extensions/qa-lab/src/multipass.runtime.test.ts @@ -3,9 +3,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const execFileMock = vi.hoisted(() => vi.fn()); +const runExecMock = vi.hoisted(() => vi.fn()); function readRootPackageManager() { const packageJson = JSON.parse( @@ -16,11 +16,11 @@ function readRootPackageManager() { return packageJson.packageManager; } -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); +vi.mock("openclaw/plugin-sdk/process-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - execFile: execFileMock, + runExec: runExecMock, }; }); @@ -265,12 +265,9 @@ describe("qa multipass runtime", () => { const outputDir = path.join(process.cwd(), ".artifacts", "qa-e2e", "multipass-missing-test"); vi.spyOn(Date, "now").mockReturnValue(1_717_171_717_171); vi.spyOn(Math, "random").mockReturnValue(0.123456789); - (execFileMock as unknown as Mock).mockImplementation((...args: unknown[]) => { - const callback = args[3] as (error: Error | null, stdout: string, stderr: string) => void; - const error = new Error("spawn multipass ENOENT") as NodeJS.ErrnoException; - error.code = "ENOENT"; - callback(error, "", ""); - }); + runExecMock.mockRejectedValueOnce( + Object.assign(new Error("spawn multipass ENOENT"), { code: "ENOENT" }), + ); const expectedVmName = createQaMultipassPlan({ repoRoot: process.cwd(), @@ -304,12 +301,13 @@ describe("qa multipass runtime", () => { "qa-e2e", "multipass-probe-error-test", ); - (execFileMock as unknown as Mock).mockImplementation((...args: unknown[]) => { - const callback = args[3] as (error: Error | null, stdout: string, stderr: string) => void; - const error = new Error("multipassd is not running") as NodeJS.ErrnoException; - error.code = "EACCES"; - callback(error, "", "multipassd is not running"); - }); + runExecMock.mockRejectedValueOnce( + Object.assign(new Error("multipassd is not running"), { + code: "EACCES", + stdout: "", + stderr: "multipassd is not running", + }), + ); await expect( runQaMultipass({ diff --git a/extensions/qa-lab/src/multipass.runtime.ts b/extensions/qa-lab/src/multipass.runtime.ts index 568689a22606..4e1963144bc8 100644 --- a/extensions/qa-lab/src/multipass.runtime.ts +++ b/extensions/qa-lab/src/multipass.runtime.ts @@ -1,10 +1,10 @@ // Qa Lab plugin module implements multipass behavior. -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { access, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime"; import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -119,28 +119,28 @@ function createVmSuffix() { return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; } -function execFileAsync(file: string, args: string[], options: ExecFileOptions = {}) { - return new Promise((resolve, reject) => { - execFile( - file, - args, - { - encoding: "utf8", - maxBuffer: MULTIPASS_EXEC_MAX_BUFFER, - timeout: options.timeoutMs, - }, - (error, stdout, stderr) => { - if (error) { - const message = stderr.trim() || stdout.trim() || error.message; - const wrappedError = new Error(message, { cause: error }) as ExecFileError; - wrappedError.code = (error as NodeJS.ErrnoException).code; - reject(wrappedError); - return; - } - resolve({ stdout, stderr }); - }, - ); - }); +async function execFileAsync( + file: string, + args: string[], + options: ExecFileOptions = {}, +): Promise { + try { + return await runExec(file, args, { + logOutput: false, + maxBuffer: MULTIPASS_EXEC_MAX_BUFFER, + timeoutMs: options.timeoutMs, + }); + } catch (error) { + const output = error as { code?: string; stdout?: unknown; stderr?: unknown }; + const stdout = typeof output.stdout === "string" ? output.stdout : ""; + const stderr = typeof output.stderr === "string" ? output.stderr : ""; + const message = stderr.trim() || stdout.trim() || (error instanceof Error ? error.message : ""); + const wrappedError = new Error(message || "Multipass command failed", { + cause: error, + }) as ExecFileError; + wrappedError.code = output.code; + throw wrappedError; + } } function resolveRealPath(value: string) { diff --git a/extensions/qa-lab/src/node-exec.test.ts b/extensions/qa-lab/src/node-exec.test.ts index a504f8d6c000..19cb628cdf30 100644 --- a/extensions/qa-lab/src/node-exec.test.ts +++ b/extensions/qa-lab/src/node-exec.test.ts @@ -1,9 +1,18 @@ // Qa Lab tests cover node exec plugin behavior. import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { runExecMock } = vi.hoisted(() => ({ runExecMock: vi.fn() })); + +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ runExec: runExecMock })); + import { resolveQaNodeExecPath } from "./node-exec.js"; describe("resolveQaNodeExecPath", () => { + beforeEach(() => { + runExecMock.mockReset(); + }); + it("reuses the current exec path when already running under Node", async () => { await expect( resolveQaNodeExecPath({ @@ -41,6 +50,25 @@ describe("resolveQaNodeExecPath", () => { ).resolves.toBe("/usr/local/bin/node"); }); + it("uses a supplied environment as the exact base for the default PATH probe", async () => { + const env = { PATH: "/qa/bin" }; + runExecMock.mockResolvedValueOnce({ stdout: "/qa/bin/node\n", stderr: "" }); + + await expect( + resolveQaNodeExecPath({ + execPath: "/opt/homebrew/bin/bun", + platform: "darwin", + versions: { ...process.versions, bun: "1.2.3" }, + env, + }), + ).resolves.toBe("/qa/bin/node"); + + expect(runExecMock).toHaveBeenCalledWith("which", ["node"], { + baseEnv: env, + logOutput: false, + }); + }); + it("uses trusted Windows where.exe when resolving node from PATH", async () => { await expect( resolveQaNodeExecPath({ diff --git a/extensions/qa-lab/src/node-exec.ts b/extensions/qa-lab/src/node-exec.ts index 9ba261a93689..44ed58001d74 100644 --- a/extensions/qa-lab/src/node-exec.ts +++ b/extensions/qa-lab/src/node-exec.ts @@ -1,7 +1,6 @@ // Qa Lab plugin module implements node exec behavior. -import { execFile } from "node:child_process"; import path from "node:path"; -import { promisify } from "node:util"; +import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js"; type ExecFileAsync = ( @@ -13,7 +12,8 @@ type ExecFileAsync = ( }, ) => Promise<{ stdout: string; stderr: string }>; -const execFileAsync = promisify(execFile) as unknown as ExecFileAsync; +const execFileAsync: ExecFileAsync = async (file, args, options) => + await runExec(file, [...args], { baseEnv: options.env, logOutput: false }); function isNodeExecPath(execPath: string, platform: NodeJS.Platform): boolean { const pathModule = platform === "win32" ? path.win32 : path.posix; diff --git a/extensions/tts-local-cli/speech-provider-stream-error.test.ts b/extensions/tts-local-cli/speech-provider-stream-error.test.ts index 40a0fecd983a..58337cdf0d4f 100644 --- a/extensions/tts-local-cli/speech-provider-stream-error.test.ts +++ b/extensions/tts-local-cli/speech-provider-stream-error.test.ts @@ -1,245 +1,131 @@ -import { EventEmitter } from "node:events"; +// TTS local CLI tests cover the canonical process-wrapper contract. import { writeFileSync } from "node:fs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { SpeechProviderConfig, SpeechSynthesisRequest } from "openclaw/plugin-sdk/speech-core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const spawnMock = vi.hoisted(() => vi.fn()); -const runFfmpegMock = vi.hoisted(() => vi.fn<(args: string[]) => Promise>()); +const { runCommandBufferedMock } = vi.hoisted(() => ({ runCommandBufferedMock: vi.fn() })); -vi.mock("node:child_process", async (importOriginal) => ({ - ...(await importOriginal()), - spawn: spawnMock, +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandBuffered: runCommandBufferedMock, })); vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ - runFfmpeg: runFfmpegMock, + runFfmpeg: vi.fn(), })); import { buildCliSpeechProvider } from "./speech-provider.js"; -type MockChild = EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - stdin: EventEmitter & { end: () => void; write: (data: string) => void }; - kill: ReturnType boolean>>; -}; - -function createMockChild(): MockChild { - const child = new EventEmitter() as MockChild; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - const stdin = new EventEmitter() as EventEmitter & { end: () => void; write: () => void }; - stdin.end = () => {}; - stdin.write = () => {}; - child.stdin = stdin; - child.kill = vi.fn(() => true); - return child; -} - const TEST_CFG = {} as OpenClawConfig; const MIB = 1024 * 1024; -type SpeechTarget = SpeechSynthesisRequest["target"]; - -function providerConfig(params: { args?: string[]; timeoutMs?: number }): SpeechProviderConfig { +function commandResult(overrides: Record = {}) { return { - command: "/fake/tts", - args: params.args, - outputFormat: "wav", - timeoutMs: params.timeoutMs ?? 5000, + code: 0, + signal: null, + killed: false, + termination: "exit", + stdout: Buffer.from("audio"), + stderr: Buffer.alloc(0), + ...overrides, }; } -async function waitForSpawn() { - await vi.waitUntil(() => spawnMock.mock.calls.length > 0, { timeout: 2000 }); - const args = spawnMock.mock.lastCall?.[1]; - if (!Array.isArray(args)) { - throw new Error("spawn args missing"); - } - return args as string[]; -} - -async function startSpeech(params: { - child: MockChild; - args?: string[]; - timeoutMs?: number; - target?: SpeechTarget; -}) { - spawnMock.mockReturnValue(params.child); - const promise = buildCliSpeechProvider().synthesize({ +async function synthesize(args = ["--voice", "test"]) { + return await buildCliSpeechProvider().synthesize({ text: "hello", cfg: TEST_CFG, - providerConfig: providerConfig(params), - providerOverrides: {}, - timeoutMs: params.timeoutMs ?? 5000, - target: params.target ?? "audio-file", - }); - const args = await waitForSpawn(); - return { args, promise }; -} - -async function startTelephony(params: { child: MockChild; args?: string[]; timeoutMs?: number }) { - spawnMock.mockReturnValue(params.child); - const promise = buildCliSpeechProvider().synthesizeTelephony?.({ - text: "hello", - cfg: TEST_CFG, - providerConfig: providerConfig(params), - providerOverrides: {}, - timeoutMs: params.timeoutMs ?? 5000, - }); - if (!promise) { - throw new Error("telephony synthesis missing"); - } - const args = await waitForSpawn(); - return { args, promise }; -} - -function requireOutputPath(args: string[]): string { - const outputIndex = args.indexOf("--out"); - const outputPath = args[outputIndex + 1]; - if (outputIndex < 0 || typeof outputPath !== "string") { - throw new Error("output path missing"); - } - return outputPath; -} - -async function expectStillPending(promise: Promise) { - let settled = false; - void promise.then( - () => { - settled = true; + providerConfig: { + command: "/fake/tts", + args, + outputFormat: "wav", + timeoutMs: 2_500, }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); + providerOverrides: {}, + timeoutMs: 2_500, + target: "audio-file", + }); } -describe("CLI TTS provider stream error handling", () => { +describe("CLI TTS process wrapper", () => { beforeEach(() => { - runFfmpegMock.mockImplementation(async (args) => { - const outputPath = args.at(-1); - if (!outputPath) { - throw new Error("ffmpeg output path missing"); - } - writeFileSync(outputPath, Buffer.from("converted")); + runCommandBufferedMock.mockReset(); + runCommandBufferedMock.mockResolvedValue(commandResult()); + }); + + it("uses Execa input, timeout, escalation, and asymmetric byte caps", async () => { + await expect(synthesize()).resolves.toMatchObject({ audioBuffer: Buffer.from("audio") }); + + expect(runCommandBufferedMock).toHaveBeenCalledWith( + ["/fake/tts", "--voice", "test"], + expect.objectContaining({ + input: "hello", + maxOutputBytes: { stdout: 50 * MIB, stderr: MIB }, + timeoutMs: 2_500, + }), + ); + }); + + it("maps timeout and output-limit failures", async () => { + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ code: null, termination: "timeout" }), + ); + await expect(synthesize()).rejects.toThrow("CLI TTS timed out after 2500ms"); + + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ + code: 0, + termination: "output-limit", + outputLimitStream: "stderr", + }), + ); + await expect(synthesize()).rejects.toThrow(`CLI TTS stderr exceeded ${MIB} bytes`); + }); + + it("keeps exit diagnostics", async () => { + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ code: 2, stderr: Buffer.from("bad voice") }), + ); + + await expect(synthesize()).rejects.toThrow("CLI TTS exit 2: bad voice"); + }); + + it("rejects errored stdout but keeps a generated audio file authoritative", async () => { + const streamError = new Error("stdout stream failed"); + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ + code: null, + error: streamError, + errorStream: "stdout", + stdout: Buffer.from("partial"), + termination: "error", + }), + ); + await expect(synthesize()).rejects.toThrow("CLI TTS failed: stdout stream failed"); + + runCommandBufferedMock.mockImplementationOnce(async (argv: string[]) => { + writeFileSync(argv[1]!, Buffer.from("file-audio")); + return commandResult({ + code: 0, + error: streamError, + stdout: Buffer.from("partial"), + termination: "error", + }); + }); + await expect(synthesize(["{{OutputPath}}"])).resolves.toMatchObject({ + audioBuffer: Buffer.from("file-audio"), + }); + + runCommandBufferedMock.mockResolvedValueOnce( + commandResult({ + code: 0, + error: new Error("stderr stream failed"), + errorStream: "stderr", + stdout: Buffer.from("stdout-audio"), + termination: "error", + }), + ); + await expect(synthesize()).resolves.toMatchObject({ + audioBuffer: Buffer.from("stdout-audio"), }); }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it("rejects partial stdout instead of returning truncated audio", async () => { - const child = createMockChild(); - const { promise } = await startSpeech({ child }); - - child.stdout.emit("data", Buffer.from("partial")); - child.stdout.emit("error", new Error("EPIPE: audio stream broken")); - child.emit("close", 0); - - await expect(promise).rejects.toThrow( - "CLI TTS stdout stream error: EPIPE: audio stream broken", - ); - expect(child.kill).not.toHaveBeenCalled(); - }); - - it.each(["speech", "telephony"] as const)( - "keeps valid %s file output when incidental stdout errors", - async (mode) => { - const child = createMockChild(); - const started = - mode === "speech" - ? await startSpeech({ child, args: ["--out", "{{OutputPath}}"] }) - : await startTelephony({ child, args: ["--out", "{{OutputPath}}"] }); - writeFileSync(requireOutputPath(started.args), Buffer.from("file-audio")); - - child.stdout.emit("error", new Error("EPIPE: unused stdout broken")); - child.emit("close", 0); - - await expect(started.promise).resolves.toMatchObject({ - audioBuffer: mode === "speech" ? Buffer.from("file-audio") : Buffer.from("converted"), - }); - expect(child.kill).not.toHaveBeenCalled(); - expect(runFfmpegMock).toHaveBeenCalledTimes(mode === "telephony" ? 1 : 0); - }, - ); - - it("keeps synthesized audio when only the diagnostic stream errors", async () => { - const child = createMockChild(); - const { promise } = await startSpeech({ child }); - - child.stdout.emit("data", Buffer.from("audio")); - child.stderr.emit("error", new Error("EPIPE: diagnostics stream broken")); - child.emit("close", 0); - - await expect(promise).resolves.toMatchObject({ audioBuffer: Buffer.from("audio") }); - expect(child.kill).not.toHaveBeenCalled(); - }); - - it("reports diagnostic stream loss when the child exits unsuccessfully", async () => { - const child = createMockChild(); - const { promise } = await startSpeech({ child }); - - child.stderr.emit("data", Buffer.from("partial diagnostic")); - child.stderr.emit("error", new Error("EIO: diagnostics stream broken")); - child.emit("close", 1); - - await expect(promise).rejects.toThrow( - "CLI TTS exit 1: partial diagnostic; CLI TTS stderr stream error: EIO: diagnostics stream broken", - ); - }); - - it.each([ - { stream: "stdout", chunkBytes: MIB, repeats: 51, limitBytes: 50 * MIB }, - { stream: "stderr", chunkBytes: MIB / 2, repeats: 3, limitBytes: MIB }, - ] as const)("terminates the child when $stream exceeds its byte cap", async (testCase) => { - const child = createMockChild(); - const { promise } = await startSpeech({ child }); - const chunk = Buffer.alloc(testCase.chunkBytes); - - for (let index = 0; index < testCase.repeats; index += 1) { - child[testCase.stream].emit("data", chunk); - } - expect(child.kill).toHaveBeenCalledTimes(1); - child.emit("close", null); - - await expect(promise).rejects.toThrow( - `CLI TTS ${testCase.stream} exceeded ${testCase.limitBytes} bytes`, - ); - }); - - it("waits for close before settling a process error", async () => { - const child = createMockChild(); - const { promise } = await startSpeech({ child }); - - child.emit("error", new Error("spawn failed")); - await expectStillPending(promise); - child.emit("close", null); - - await expect(promise).rejects.toThrow("CLI TTS failed: spawn failed"); - }); - - it("keeps timeout kill escalation armed across process errors", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const child = createMockChild(); - const { promise } = await startSpeech({ child, timeoutMs: 100 }); - - await vi.advanceTimersByTimeAsync(100); - expect(child.kill.mock.calls[0]).toEqual([]); - child.emit("error", new Error("SIGTERM delivery failed")); - await expectStillPending(promise); - - await vi.advanceTimersByTimeAsync(5000); - expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); - child.emit("close", null); - - await expect(promise).rejects.toThrow("CLI TTS timed out after 100ms"); - await vi.advanceTimersByTimeAsync(5000); - expect(child.kill).toHaveBeenCalledTimes(2); - }); }); diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index 1d71ffd94f51..b42bed3a9e94 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -1,8 +1,8 @@ // Tts Local Cli provider module implements model/runtime integration. -import { spawn } from "node:child_process"; import { readdirSync } from "node:fs"; import path from "node:path"; import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; +import { runCommandBuffered } from "openclaw/plugin-sdk/process-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { readRegularFileSync, @@ -181,34 +181,6 @@ function readAudioFile(filePath: string): Buffer { return readRegularFileSync({ filePath, maxBytes: MAX_AUDIO_OUTPUT_BYTES }).buffer; } -function createBoundedBuffer(label: string, maxBytes: number) { - const chunks: Buffer[] = []; - let totalBytes = 0; - let limitError: Error | undefined; - - return { - append(chunk: Buffer | string): Error | undefined { - if (limitError) { - return limitError; - } - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const nextTotal = totalBytes + buffer.byteLength; - if (nextTotal > maxBytes) { - limitError = new Error(`${label} exceeded ${maxBytes} bytes (${nextTotal} bytes received)`); - chunks.length = 0; - totalBytes = 0; - return limitError; - } - chunks.push(buffer); - totalBytes = nextTotal; - return undefined; - }, - concat(): Buffer { - return Buffer.concat(chunks, totalBytes); - }, - }; -} - async function runCli(params: { command: string; args: string[]; @@ -240,114 +212,60 @@ async function runCli(params: { const baseArgs = [...initialArgs, ...params.args]; const args = baseArgs.map((a) => applyTemplate(a, ctx)); - - return new Promise((resolve, reject) => { - const env = params.env ? { ...process.env, ...params.env } : process.env; - const proc = spawn(cmd, args, { cwd: params.cwd, env, stdio: ["pipe", "pipe", "pipe"] }); - let settled = false; - let terminalFailure: Error | undefined; - let stdoutError: Error | undefined; - let stderrError: Error | undefined; - let forceKillTimer: NodeJS.Timeout | undefined; - - const clearTimers = () => { - clearTimeout(timeoutTimer); - clearTimeout(forceKillTimer); - }; - const terminateFor = (error: Error) => { - if (settled || terminalFailure) { - return; - } - terminalFailure = error; - clearTimeout(timeoutTimer); - proc.kill(); - forceKillTimer = setTimeout(() => proc.kill("SIGKILL"), 5000); - forceKillTimer.unref(); - }; - const timeoutTimer = setTimeout(() => { - terminateFor(new Error(`CLI TTS timed out after ${params.timeoutMs}ms`)); - }, params.timeoutMs); - - const stdout = createBoundedBuffer("CLI TTS stdout", MAX_AUDIO_OUTPUT_BYTES); - const stderr = createBoundedBuffer("CLI TTS stderr", MAX_CLI_STDERR_BYTES); - proc.stdout.on("data", (chunk: Buffer) => { - const error = stdout.append(chunk); - if (error) { - terminateFor(error); - } - }); - proc.stdout.on("error", (e) => { - // A generated file is authoritative when present. Remember stdout - // failure so only the stdout-audio fallback is rejected after close. - stdoutError ??= new Error(`CLI TTS stdout stream error: ${e.message}`); - }); - proc.stderr.on("data", (chunk: Buffer) => { - const error = stderr.append(chunk); - if (error) { - terminateFor(error); - } - }); - proc.stderr.on("error", (e) => { - stderrError ??= new Error(`CLI TTS stderr stream error: ${e.message}`); - }); - - proc.on("error", (e) => { - // Process errors can also report failed kill delivery. Keep timeout - // escalation armed and let close own cleanup and promise settlement. - terminalFailure ??= new Error(`CLI TTS failed: ${e.message}`); - }); - - proc.on("close", (code) => { - if (settled) { - return; - } - settled = true; - clearTimers(); - if (terminalFailure) { - return reject(terminalFailure); - } - if (code !== 0) { - const stderrText = stderr.concat().toString("utf8"); - const diagnostic = stderrError - ? [stderrText, stderrError.message].filter(Boolean).join("; ") - : stderrText; - return reject(new Error(`CLI TTS exit ${code}: ${diagnostic}`)); - } - - const audioFile = findAudioFile(params.outputDir, params.filePrefix); - if (audioFile) { - const format = detectFormat(audioFile); - if (!format) { - return reject(new Error(`CLI TTS: unknown format for ${audioFile}`)); - } - try { - return resolve({ - buffer: readAudioFile(audioFile), - actualFormat: format, - audioPath: audioFile, - }); - } catch (error) { - return reject(error instanceof Error ? error : new Error(String(error))); - } - } - - if (stdoutError) { - return reject(stdoutError); - } - const stdoutBuffer = stdout.concat(); - if (stdoutBuffer.length > 0) { - // Assume WAV for stdout output; could be MP3 but caller should convert if needed - return resolve({ buffer: stdoutBuffer, actualFormat: "wav" }); - } - reject(new Error("CLI TTS produced no output")); - }); - - proc.stdin?.on("error", () => {}); // suppress EPIPE if child ignores stdin - if (!baseArgs.some((a) => /{{\s*text\s*}}/i.test(a))) { - proc.stdin?.write(cleanText); - } - proc.stdin?.end(); + const input = baseArgs.some((a) => /{{\s*text\s*}}/i.test(a)) ? "" : cleanText; + const result = await runCommandBuffered([cmd, ...args], { + cwd: params.cwd, + env: params.env, + input, + maxOutputBytes: { + stdout: MAX_AUDIO_OUTPUT_BYTES, + stderr: MAX_CLI_STDERR_BYTES, + }, + timeoutMs: params.timeoutMs, }); + if (result.termination === "timeout") { + throw new Error(`CLI TTS timed out after ${params.timeoutMs}ms`); + } + if (result.termination === "output-limit") { + const stream = result.outputLimitStream ?? "stdout"; + const maxBytes = stream === "stderr" ? MAX_CLI_STDERR_BYTES : MAX_AUDIO_OUTPUT_BYTES; + throw new Error(`CLI TTS ${stream} exceeded ${maxBytes} bytes`); + } + if (result.code !== null && result.code !== 0) { + throw new Error(`CLI TTS exit ${result.code}: ${result.stderr.toString("utf8")}`); + } + if (result.termination !== "exit" && result.termination !== "error") { + throw new Error(`CLI TTS failed: ${result.error?.message ?? result.termination}`); + } + if (result.termination === "error" && result.code !== 0) { + throw new Error(`CLI TTS failed: ${result.error?.message ?? result.termination}`); + } + + const audioFile = findAudioFile(params.outputDir, params.filePrefix); + if (audioFile) { + const format = detectFormat(audioFile); + if (!format) { + throw new Error(`CLI TTS: unknown format for ${audioFile}`); + } + return { + buffer: readAudioFile(audioFile), + actualFormat: format, + audioPath: audioFile, + }; + } + if (result.termination === "error" && result.errorStream !== "stderr") { + throw new Error(`CLI TTS failed: ${result.error?.message ?? result.termination}`); + } + + const stdout = result.stdout; + if (stdout.length > 0) { + // Assume WAV for stdout output; could be MP3 but caller should convert if needed + return { buffer: stdout, actualFormat: "wav" }; + } + if (result.termination === "error") { + throw new Error(`CLI TTS failed: ${result.error?.message ?? result.termination}`); + } + throw new Error("CLI TTS produced no output"); } async function runFfmpegToBuffer(params: { diff --git a/extensions/voice-call/src/tunnel.test.ts b/extensions/voice-call/src/tunnel.test.ts index a5d68867f007..d2a0660a71a3 100644 --- a/extensions/voice-call/src/tunnel.test.ts +++ b/extensions/voice-call/src/tunnel.test.ts @@ -25,6 +25,7 @@ class FakeChildProcess extends EventEmitter { const mocks = vi.hoisted(() => ({ spawn: vi.fn(), getTailscaleDnsName: vi.fn(), + runCommand: vi.fn(), })); vi.mock("node:child_process", () => ({ @@ -35,6 +36,10 @@ vi.mock("./webhook/tailscale.js", () => ({ getTailscaleDnsName: mocks.getTailscaleDnsName, })); +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandWithTimeout: mocks.runCommand, +})); + import { startNgrokTunnel, startTailscaleTunnel, startTunnel } from "./tunnel.js"; function nextProcess(): FakeChildProcess { @@ -47,10 +52,23 @@ function emitNgrokUrl(proc: FakeChildProcess, url: string): void { proc.stdout.emit("data", Buffer.from(`${JSON.stringify({ msg: "started tunnel", url })}\n`)); } +function commandResult(overrides: Record = {}) { + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, + }; +} + describe("voice-call tunnels", () => { beforeEach(() => { vi.clearAllMocks(); mocks.getTailscaleDnsName.mockReset(); + mocks.runCommand.mockResolvedValue(commandResult()); }); it("starts ngrok and appends the webhook path to the public URL", async () => { @@ -96,7 +114,6 @@ describe("voice-call tunnels", () => { }); it("sets ngrok auth token before starting the tunnel", async () => { - const authProc = nextProcess(); const tunnelProc = nextProcess(); const result = startNgrokTunnel({ port: 3334, @@ -104,32 +121,34 @@ describe("voice-call tunnels", () => { authToken: "token", }); - authProc.close(0); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(1)); emitNgrokUrl(tunnelProc, "https://auth.ngrok.io"); const tunnel = await result; expect(tunnel.publicUrl).toBe("https://auth.ngrok.io/hook"); expect(tunnel.provider).toBe("ngrok"); - expect(mocks.spawn).toHaveBeenNthCalledWith(1, "ngrok", ["config", "add-authtoken", "token"], { - stdio: ["ignore", "pipe", "pipe"], - }); + expect(mocks.runCommand).toHaveBeenCalledWith( + ["ngrok", "config", "add-authtoken", "token"], + expect.objectContaining({ timeoutMs: 30_000 }), + ); }); it("bounds ngrok command failure output", async () => { - const authProc = nextProcess(); + mocks.runCommand.mockResolvedValueOnce( + commandResult({ + code: 1, + stderr: `${"x".repeat(16_000)}-end`, + stderrTruncatedBytes: 4_000, + }), + ); const result = startNgrokTunnel({ port: 3334, path: "/hook", authToken: "token", }); - authProc.stderr.emit("data", Buffer.from(`start-${"x".repeat(20_000)}-end`)); - authProc.close(1); - await expect(result).rejects.toThrow("[output truncated]"); await expect(result).rejects.toThrow("-end"); - await expect(result).rejects.not.toThrow("start-"); }); it("rejects ngrok startup errors from stderr", async () => { @@ -143,23 +162,18 @@ describe("voice-call tunnels", () => { it("starts Tailscale serve using the resolved tailnet DNS name", async () => { mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net"); - const proc = nextProcess(); - const result = startTailscaleTunnel({ + const tunnel = await startTailscaleTunnel({ mode: "serve", port: 3334, path: "voice/webhook", }); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled()); - proc.close(0); - - const tunnel = await result; expect(tunnel.publicUrl).toBe("https://host.tailnet.ts.net/voice/webhook"); expect(tunnel.provider).toBe("tailscale-serve"); expect(tunnel.stop).toBeTypeOf("function"); - expect(mocks.spawn).toHaveBeenCalledWith( - "tailscale", + expect(mocks.runCommand).toHaveBeenCalledWith( [ + "tailscale", "serve", "--bg", "--yes", @@ -167,27 +181,28 @@ describe("voice-call tunnels", () => { "/voice/webhook", "http://127.0.0.1:3334/voice/webhook", ], - { stdio: ["ignore", "pipe", "pipe"] }, + expect.objectContaining({ timeoutMs: 10_000 }), ); }); it("drains and bounds Tailscale startup failure output", async () => { mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net"); - const proc = nextProcess(); + mocks.runCommand.mockResolvedValueOnce( + commandResult({ + code: 1, + stderr: `${"x".repeat(16_000)}-end`, + stderrTruncatedBytes: 4_000, + }), + ); const result = startTailscaleTunnel({ mode: "funnel", port: 3334, path: "/voice/webhook", }); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled()); - proc.stderr.emit("data", Buffer.from(`start-${"x".repeat(20_000)}-end`)); - proc.close(1); - await expect(result).rejects.toThrow("Tailscale funnel failed with code 1"); await expect(result).rejects.toThrow("[output truncated]"); await expect(result).rejects.toThrow("-end"); - await expect(result).rejects.not.toThrow("start-"); }); it("rejects Tailscale tunnel startup when the DNS name is unavailable", async () => { @@ -197,6 +212,7 @@ describe("voice-call tunnels", () => { startTailscaleTunnel({ mode: "funnel", port: 3334, path: "/hook" }), ).rejects.toThrow("Could not get Tailscale DNS name"); expect(mocks.spawn).not.toHaveBeenCalled(); + expect(mocks.runCommand).not.toHaveBeenCalled(); }); it("dispatches tunnel providers from config", async () => { @@ -211,24 +227,16 @@ describe("voice-call tunnels", () => { expect(tunnel?.provider).toBe("ngrok"); }); - it("handles spawn errors on tailscale stop cleanup without crashing", async () => { + it("handles wrapper errors on tailscale stop cleanup without crashing", async () => { mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net"); - // Start the tunnel — first spawn is tailscale serve (succeeds) - const startProc = nextProcess(); - const result = startTailscaleTunnel({ mode: "serve", port: 3334, path: "/voice/stop" }); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled()); - startProc.close(0); - const tunnel = await result; + const tunnel = await startTailscaleTunnel({ + mode: "serve", + port: 3334, + path: "/voice/stop", + }); + mocks.runCommand.mockRejectedValueOnce(new Error("tailscale not found")); - // Stop the tunnel — second spawn is tailscale stop (errors) - const stopProc = nextProcess(); - const stopPromise = tunnel.stop(); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(2)); - // Emit error on the stop process — without the fix this crashes - stopProc.fail(new Error("tailscale not found")); - - // The stop promise must still resolve despite the error - await expect(stopPromise).resolves.toBeUndefined(); + await expect(tunnel.stop()).resolves.toBeUndefined(); }); it("rejects when ngrok stdout emits an error before the tunnel is ready", async () => { @@ -247,12 +255,11 @@ describe("voice-call tunnels", () => { expect(proc.killedWith).toBe("SIGKILL"); }); - it("rejects and stops the ngrok auth command on stream errors", async () => { - const proc = nextProcess(); + it("preserves ngrok auth wrapper errors", async () => { + mocks.runCommand.mockRejectedValueOnce(new Error("ngrok auth failed")); const result = startNgrokTunnel({ port: 3334, path: "/hook", authToken: "token" }); - proc.stdout.emit("error", new Error("EPIPE")); - await expect(result).rejects.toThrow("ngrok command stdout error: EPIPE"); - expect(proc.killedWith).toBe("SIGKILL"); + await expect(result).rejects.toThrow("ngrok auth failed"); + expect(mocks.spawn).not.toHaveBeenCalled(); }); it("stops immediately when the ngrok process already exited", async () => { @@ -264,24 +271,4 @@ describe("voice-call tunnels", () => { await expect(tunnel.stop()).resolves.toBeUndefined(); expect(proc.killedWith).toBeNull(); }); - - it("rejects when Tailscale stdout emits an error before the tunnel is ready", async () => { - mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net"); - const proc = nextProcess(); - const result = startTailscaleTunnel({ mode: "serve", port: 3334, path: "/hook" }); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled()); - proc.stdout.emit("error", new Error("EPIPE")); - await expect(result).rejects.toThrow("Tailscale serve stdout error: EPIPE"); - expect(proc.killedWith).toBe("SIGKILL"); - }); - - it("rejects and stops Tailscale when stderr emits an error before readiness", async () => { - mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net"); - const proc = nextProcess(); - const result = startTailscaleTunnel({ mode: "funnel", port: 3334, path: "/hook" }); - await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled()); - proc.stderr.emit("error", new Error("EIO")); - await expect(result).rejects.toThrow("Tailscale funnel stderr error: EIO"); - expect(proc.killedWith).toBe("SIGKILL"); - }); }); diff --git a/extensions/voice-call/src/tunnel.ts b/extensions/voice-call/src/tunnel.ts index 7a97fa2dae8f..83a2c264dccc 100644 --- a/extensions/voice-call/src/tunnel.ts +++ b/extensions/voice-call/src/tunnel.ts @@ -1,5 +1,6 @@ // Voice Call plugin module implements tunnel behavior. import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { appendBoundedChildOutput, @@ -9,6 +10,7 @@ import { import { getTailscaleDnsName } from "./webhook/tailscale.js"; const NGROK_LOG_BUFFER_MAX_CHARS = 16_384; +const TUNNEL_COMMAND_OUTPUT_MAX_BYTES = 16_384; function listenForChildStreamErrors( proc: Pick, @@ -217,51 +219,22 @@ export async function startNgrokTunnel(config: { * Run an ngrok command and wait for completion. */ async function runNgrokCommand(args: string[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn("ngrok", args, { - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = emptyBoundedChildOutput(); - let stderr = emptyBoundedChildOutput(); - let settled = false; - - const rejectIfPending = (error: Error, kill = false) => { - if (settled) { - return; - } - settled = true; - if (kill) { - proc.kill("SIGKILL"); - } - reject(error); - }; - - proc.stdout.on("data", (data) => { - stdout = appendBoundedChildOutput(stdout, data.toString()); - }); - proc.stderr.on("data", (data) => { - stderr = appendBoundedChildOutput(stderr, data.toString()); - }); - listenForChildStreamErrors(proc, (stream, error) => { - rejectIfPending(new Error(`ngrok command ${stream} error: ${error.message}`), true); - }); - - proc.on("close", (code) => { - if (settled) { - return; - } - settled = true; - if (code === 0) { - resolve(stdout.text); - } else { - const output = stderr.text ? stderr : stdout; - reject(new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`)); - } - }); - - proc.on("error", (error) => rejectIfPending(error)); + const result = await runCommandWithTimeout(["ngrok", ...args], { + killProcessTree: true, + maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES, + outputCapture: "tail", + timeoutMs: 30_000, }); + if (result.termination === "timeout") { + throw new Error("ngrok command timed out"); + } + if (result.code === 0) { + return result.stdout; + } + const output = result.stderr + ? { text: result.stderr, truncated: Boolean(result.stderrTruncatedBytes) } + : { text: result.stdout, truncated: Boolean(result.stdoutTruncatedBytes) }; + throw new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`); } /** @@ -281,96 +254,46 @@ export async function startTailscaleTunnel(config: { const path = config.path.startsWith("/") ? config.path : `/${config.path}`; const localUrl = `http://127.0.0.1:${config.port}${path}`; - return new Promise((resolve, reject) => { - const proc = spawn("tailscale", [config.mode, "--bg", "--yes", "--set-path", path, localUrl], { - stdio: ["ignore", "pipe", "pipe"], - }); - let resolved = false; - let stdout = emptyBoundedChildOutput(); - let stderr = emptyBoundedChildOutput(); + const result = await runCommandWithTimeout( + ["tailscale", config.mode, "--bg", "--yes", "--set-path", path, localUrl], + { + killProcessTree: true, + maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES, + outputCapture: "tail", + timeoutMs: 10_000, + }, + ); + if (result.termination === "timeout") { + throw new Error(`Tailscale ${config.mode} timed out`); + } + if (result.code !== 0) { + const output = result.stderr + ? { text: result.stderr, truncated: Boolean(result.stderrTruncatedBytes) } + : { text: result.stdout, truncated: Boolean(result.stdoutTruncatedBytes) }; + const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : ""; + throw new Error(`Tailscale ${config.mode} failed with code ${result.code}${detail}`); + } + const publicUrl = `https://${dnsName}${path}`; + console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`); - const rejectIfPending = (error: Error, kill = false) => { - if (resolved) { - return; - } - resolved = true; - clearTimeout(timeout); - if (kill) { - proc.kill("SIGKILL"); - } - reject(error); - }; - - const timeout = setTimeout(() => { - rejectIfPending(new Error(`Tailscale ${config.mode} timed out`), true); - }, 10000); - - proc.stdout.on("data", (data) => { - stdout = appendBoundedChildOutput(stdout, data.toString()); - }); - proc.stderr.on("data", (data) => { - stderr = appendBoundedChildOutput(stderr, data.toString()); - }); - listenForChildStreamErrors(proc, (stream, error) => { - rejectIfPending( - new Error(`Tailscale ${config.mode} ${stream} error: ${error.message}`), - true, - ); - }); - - proc.on("close", (code) => { - clearTimeout(timeout); - if (resolved) { - return; - } - resolved = true; - if (code === 0) { - const publicUrl = `https://${dnsName}${path}`; - console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`); - - resolve({ - publicUrl, - provider: `tailscale-${config.mode}`, - stop: async () => { - await stopTailscaleTunnel(config.mode, path); - }, - }); - } else { - const output = stderr.text ? stderr : stdout; - const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : ""; - reject(new Error(`Tailscale ${config.mode} failed with code ${code}${detail}`)); - } - }); - - proc.on("error", (err) => { - rejectIfPending(err); - }); - }); + return { + publicUrl, + provider: `tailscale-${config.mode}`, + stop: async () => { + await stopTailscaleTunnel(config.mode, path); + }, + }; } /** * Stop a Tailscale serve/funnel tunnel. */ async function stopTailscaleTunnel(mode: "serve" | "funnel", path: string): Promise { - return new Promise((resolve) => { - const proc = spawn("tailscale", [mode, "off", path], { - stdio: "ignore", - }); - - const timeout = setTimeout(() => { - proc.kill("SIGKILL"); - resolve(); - }, 5000); - - proc.on("close", () => { - clearTimeout(timeout); - resolve(); - }); - proc.on("error", () => { - clearTimeout(timeout); - resolve(); - }); - }); + await runCommandWithTimeout(["tailscale", mode, "off", path], { + killProcessTree: true, + maxOutputBytes: 1, + timeoutMs: 5_000, + }).catch(() => {}); } /** diff --git a/extensions/voice-call/src/webhook/tailscale.test.ts b/extensions/voice-call/src/webhook/tailscale.test.ts index 9c16ce4aabef..cb4d1d4286dc 100644 --- a/extensions/voice-call/src/webhook/tailscale.test.ts +++ b/extensions/voice-call/src/webhook/tailscale.test.ts @@ -1,25 +1,13 @@ -// Voice Call tests cover tailscale plugin behavior. -import { EventEmitter } from "node:events"; +// Voice Call tests cover bounded Tailscale command execution. import { beforeEach, describe, expect, it, vi } from "vitest"; -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn(), +const { runCommandMock } = vi.hoisted(() => ({ runCommandMock: vi.fn() })); + +vi.mock("openclaw/plugin-sdk/process-runtime", () => ({ + runCommandWithTimeout: runCommandMock, })); -const tailscaleSpawnOptions = { stdio: ["ignore", "pipe", "ignore"] } as const; - -vi.mock("node:child_process", async () => { - const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks"); - return mockNodeBuiltinModule( - () => vi.importActual("node:child_process"), - { - spawn: spawnMock, - }, - ); -}); - import { - appendTailscaleCommandStdout, cleanupTailscaleExposure, cleanupTailscaleExposureRoute, getTailscaleDnsName, @@ -29,140 +17,72 @@ import { TAILSCALE_COMMAND_STDOUT_MAX_BYTES, } from "./tailscale.js"; -function createProc(params?: { code?: number; stdout?: string }) { - const proc = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - kill: ReturnType; +function commandResult(overrides: Record = {}) { + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, }; - proc.stdout = new EventEmitter(); - proc.kill = vi.fn(); - const originalOn = proc.on.bind(proc); - proc.on = ((eventName: string | symbol, listener: (...args: unknown[]) => void) => { - const result = originalOn(eventName, listener); - if (eventName === "close") { - if (params?.stdout) { - proc.stdout.emit("data", Buffer.from(params.stdout)); - } - listener(params?.code ?? 0); - } - return result; - }) as typeof proc.on; - return proc; -} - -function createErrorProc() { - const proc = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - kill: ReturnType; - }; - proc.stdout = new EventEmitter(); - proc.kill = vi.fn(); - const originalOn = proc.on.bind(proc); - proc.on = ((eventName: string | symbol, listener: (...args: unknown[]) => void) => { - const result = originalOn(eventName, listener); - if (eventName === "error") { - listener(Object.assign(new Error("spawn tailscale ENOENT"), { code: "ENOENT" })); - } - return result; - }) as typeof proc.on; - return proc; -} - -function createPendingProc() { - const proc = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - kill: ReturnType; - }; - proc.stdout = new EventEmitter(); - proc.kill = vi.fn(); - return proc; } describe("voice-call tailscale helpers", () => { beforeEach(() => { - vi.useRealTimers(); vi.clearAllMocks(); + runCommandMock.mockResolvedValue(commandResult()); }); - it("reads dns and node id from tailscale status json", async () => { - spawnMock - .mockReturnValueOnce( - createProc({ - stdout: JSON.stringify({ - Self: { - DNSName: "bot.example.ts.net.", - ID: "node-123", - }, - }), - }), - ) - .mockReturnValueOnce( - createProc({ - stdout: JSON.stringify({ - Self: { - DNSName: "bot.example.ts.net.", - ID: "node-123", - }, - }), - }), - ); + it("reads dns and node id through the canonical bounded wrapper", async () => { + const stdout = JSON.stringify({ + Self: { DNSName: "bot.example.ts.net.", ID: "node-123" }, + }); + runCommandMock.mockResolvedValue(commandResult({ stdout })); await expect(getTailscaleSelfInfo()).resolves.toEqual({ dnsName: "bot.example.ts.net", nodeId: "node-123", }); await expect(getTailscaleDnsName()).resolves.toBe("bot.example.ts.net"); + expect(runCommandMock).toHaveBeenCalledWith( + ["tailscale", "status", "--json", "--peers=false"], + expect.objectContaining({ + killProcessTree: true, + maxOutputBytes: { stdout: TAILSCALE_COMMAND_STDOUT_MAX_BYTES, stderr: 1 }, + terminateOnOutputLimit: { stdout: true }, + timeoutMs: 2500, + }), + ); }); - it("returns null for failing or invalid status responses", async () => { - spawnMock.mockReturnValueOnce(createProc({ code: 1, stdout: "bad" })); + it("returns null for command, timeout, output-limit, and JSON failures", async () => { + runCommandMock.mockResolvedValueOnce(commandResult({ code: 1, stdout: "bad" })); await expect(getTailscaleSelfInfo()).resolves.toBeNull(); - spawnMock.mockReturnValueOnce(createProc({ stdout: "{not-json" })); + runCommandMock.mockResolvedValueOnce(commandResult({ stdout: "{not-json" })); await expect(getTailscaleSelfInfo()).resolves.toBeNull(); - }); - - it("treats missing tailscale binary as unavailable instead of leaking spawn errors", async () => { - spawnMock.mockReturnValueOnce(createErrorProc()); + runCommandMock.mockRejectedValueOnce(new Error("tailscale missing")); await expect(getTailscaleSelfInfo()).resolves.toBeNull(); - }); - - it("treats a tailscale stdout stream error as unavailable and stops the child", async () => { - const proc = createPendingProc(); - spawnMock.mockReturnValueOnce(proc); - - const result = getTailscaleSelfInfo(); - proc.stdout.emit("error", new Error("EPIPE")); - - await expect(result).resolves.toBeNull(); - expect(proc.kill).toHaveBeenCalledWith("SIGKILL"); - }); - - it("tracks tailscale stdout without retaining over-limit output", () => { - let stdout = appendTailscaleCommandStdout({ bytes: 0, exceeded: false, text: "" }, "ok", 4); - stdout = appendTailscaleCommandStdout(stdout, "boom", 4); - - expect(stdout).toEqual({ bytes: 6, exceeded: true, text: "" }); - }); - - it("kills tailscale status when stdout exceeds the capture limit", async () => { - const proc = createProc({ stdout: "x".repeat(TAILSCALE_COMMAND_STDOUT_MAX_BYTES + 1) }); - spawnMock.mockReturnValueOnce(proc); + runCommandMock.mockResolvedValueOnce(commandResult({ code: null, termination: "timeout" })); + await expect(getTailscaleSelfInfo()).resolves.toBeNull(); + + runCommandMock.mockResolvedValueOnce( + commandResult({ code: null, termination: "signal", outputLimitExceeded: true }), + ); await expect(getTailscaleSelfInfo()).resolves.toBeNull(); - expect(proc.kill).toHaveBeenCalledWith("SIGKILL"); }); it("sets up and cleans up exposure routes with the selected mode", async () => { - spawnMock - .mockReturnValueOnce( - createProc({ - stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }), - }), + runCommandMock + .mockResolvedValueOnce( + commandResult({ stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }) }), ) - .mockReturnValueOnce(createProc({ code: 0 })) - .mockReturnValueOnce(createProc({ code: 0 })); + .mockResolvedValueOnce(commandResult()) + .mockResolvedValueOnce(commandResult()); await expect( setupTailscaleExposureRoute({ @@ -171,38 +91,35 @@ describe("voice-call tailscale helpers", () => { localUrl: "http://127.0.0.1:8787/webhook", }), ).resolves.toBe("https://bot.example.ts.net/voice"); - await cleanupTailscaleExposureRoute({ mode: "serve", path: "/voice" }); - expect(spawnMock).toHaveBeenNthCalledWith( - 1, - "tailscale", - ["status", "--json", "--peers=false"], - tailscaleSpawnOptions, - ); - expect(spawnMock).toHaveBeenNthCalledWith( + expect(runCommandMock).toHaveBeenNthCalledWith( 2, - "tailscale", - ["serve", "--bg", "--yes", "--set-path", "/voice", "http://127.0.0.1:8787/webhook"], - tailscaleSpawnOptions, + [ + "tailscale", + "serve", + "--bg", + "--yes", + "--set-path", + "/voice", + "http://127.0.0.1:8787/webhook", + ], + expect.any(Object), ); - expect(spawnMock).toHaveBeenNthCalledWith( + expect(runCommandMock).toHaveBeenNthCalledWith( 3, - "tailscale", - ["serve", "off", "/voice"], - tailscaleSpawnOptions, + ["tailscale", "serve", "off", "/voice"], + expect.any(Object), ); }); it("returns null when setup cannot resolve dns or route activation fails", async () => { - spawnMock - .mockReturnValueOnce(createProc({ code: 1 })) - .mockReturnValueOnce( - createProc({ - stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }), - }), + runCommandMock + .mockResolvedValueOnce(commandResult({ code: 1 })) + .mockResolvedValueOnce( + commandResult({ stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }) }), ) - .mockReturnValueOnce(createProc({ code: 1 })); + .mockResolvedValueOnce(commandResult({ code: 1 })); await expect( setupTailscaleExposureRoute({ @@ -211,7 +128,6 @@ describe("voice-call tailscale helpers", () => { localUrl: "http://127.0.0.1:8787/webhook", }), ).resolves.toBeNull(); - await expect( setupTailscaleExposureRoute({ mode: "funnel", @@ -222,14 +138,12 @@ describe("voice-call tailscale helpers", () => { }); it("maps config modes to serve or funnel and skips off", async () => { - spawnMock - .mockReturnValueOnce( - createProc({ - stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }), - }), + runCommandMock + .mockResolvedValueOnce( + commandResult({ stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }) }), ) - .mockReturnValueOnce(createProc({ code: 0 })) - .mockReturnValueOnce(createProc({ code: 0 })); + .mockResolvedValueOnce(commandResult()) + .mockResolvedValueOnce(commandResult()); await expect( setupTailscaleExposure({ @@ -237,30 +151,26 @@ describe("voice-call tailscale helpers", () => { serve: { port: 8787, path: "/webhook" }, } as never), ).resolves.toBeNull(); - await expect( setupTailscaleExposure({ tailscale: { mode: "funnel", path: "/voice" }, serve: { port: 8787, path: "/webhook" }, } as never), ).resolves.toBe("https://bot.example.ts.net/voice"); - await cleanupTailscaleExposure({ tailscale: { mode: "serve", path: "/voice" }, serve: { port: 8787, path: "/webhook" }, } as never); - expect(spawnMock).toHaveBeenNthCalledWith( - 2, + expect(runCommandMock.mock.calls[1]?.[0]).toEqual([ "tailscale", - ["funnel", "--bg", "--yes", "--set-path", "/voice", "http://127.0.0.1:8787/webhook"], - tailscaleSpawnOptions, - ); - expect(spawnMock).toHaveBeenNthCalledWith( - 3, - "tailscale", - ["serve", "off", "/voice"], - tailscaleSpawnOptions, - ); + "funnel", + "--bg", + "--yes", + "--set-path", + "/voice", + "http://127.0.0.1:8787/webhook", + ]); + expect(runCommandMock.mock.calls[2]?.[0]).toEqual(["tailscale", "serve", "off", "/voice"]); }); }); diff --git a/extensions/voice-call/src/webhook/tailscale.ts b/extensions/voice-call/src/webhook/tailscale.ts index 462c4b4f4f63..b4b792b266ac 100644 --- a/extensions/voice-call/src/webhook/tailscale.ts +++ b/extensions/voice-call/src/webhook/tailscale.ts @@ -1,5 +1,5 @@ // Voice Call plugin module implements tailscale behavior. -import { spawn } from "node:child_process"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import type { VoiceCallConfig } from "../config.js"; type TailscaleSelfInfo = { @@ -9,73 +9,25 @@ type TailscaleSelfInfo = { export const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024; -type TailscaleCommandStdout = { - bytes: number; - exceeded: boolean; - text: string; -}; - -export function appendTailscaleCommandStdout( - current: TailscaleCommandStdout, - data: Buffer | string, - maxBytes = TAILSCALE_COMMAND_STDOUT_MAX_BYTES, -): TailscaleCommandStdout { - if (current.exceeded) { - return current; - } - const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); - const bytes = current.bytes + buffer.byteLength; - if (bytes > maxBytes) { - return { bytes, exceeded: true, text: "" }; - } - return { bytes, exceeded: false, text: `${current.text}${buffer.toString("utf8")}` }; -} - -function runTailscaleCommand( +async function runTailscaleCommand( args: string[], timeoutMs = 2500, ): Promise<{ code: number; stdout: string }> { - return new Promise((resolve) => { - const proc = spawn("tailscale", args, { - stdio: ["ignore", "pipe", "ignore"], + try { + const result = await runCommandWithTimeout(["tailscale", ...args], { + killProcessTree: true, + maxOutputBytes: { stdout: TAILSCALE_COMMAND_STDOUT_MAX_BYTES, stderr: 1 }, + outputCapture: "head", + terminateOnOutputLimit: { stdout: true }, + timeoutMs, }); - - let stdout: TailscaleCommandStdout = { bytes: 0, exceeded: false, text: "" }; - let settled = false; - const finish = (result: { code: number; stdout: string }) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve(result); - }; - - const timer = setTimeout(() => { - proc.kill("SIGKILL"); - finish({ code: -1, stdout: "" }); - }, timeoutMs); - - proc.stdout.on("data", (data) => { - stdout = appendTailscaleCommandStdout(stdout, data); - if (stdout.exceeded) { - proc.kill("SIGKILL"); - finish({ code: -1, stdout: "" }); - } - }); - proc.stdout.on("error", () => { - proc.kill("SIGKILL"); - finish({ code: -1, stdout: "" }); - }); - - proc.on("error", () => { - finish({ code: -1, stdout: "" }); - }); - - proc.on("close", (code) => { - finish({ code: code ?? -1, stdout: stdout.text }); - }); - }); + if (result.termination !== "exit" || result.outputLimitExceeded) { + return { code: -1, stdout: "" }; + } + return { code: result.code ?? -1, stdout: result.stdout }; + } catch { + return { code: -1, stdout: "" }; + } } export async function getTailscaleSelfInfo(): Promise { diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index db0f2d69efb5..33409aef5a19 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -149,6 +149,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/file-transfer/src/node-host/dir-list.ts: DIR_LIST_HARD_MAX_ENTRIES", "extensions/file-transfer/src/node-host/file-fetch.ts: FILE_FETCH_DEFAULT_MAX_BYTES", "extensions/file-transfer/src/node-host/file-fetch.ts: FILE_FETCH_HARD_MAX_BYTES", + "extensions/file-transfer/src/shared/append-bounded-text-tail.ts: appendBoundedTextTail", "extensions/file-transfer/src/shared/errors.ts: err", "extensions/file-transfer/src/shared/node-invoke-policy.ts: testing", "extensions/file-transfer/src/tools/dir-fetch-tool.ts: testing", @@ -178,6 +179,8 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/googlechat/src/monitor.ts: testing", "extensions/googlechat/src/targets.ts: resolveGoogleChatSpaceChatType", "extensions/imessage/src/approval-reaction-poller.ts: clearIMessageApprovalReactionPollerStateForTest", + "extensions/imessage/src/cli-output.ts: IMESSAGE_CLI_STDERR_TAIL_BYTES", + "extensions/imessage/src/cli-output.ts: IMESSAGE_CLI_STDOUT_MAX_BYTES", "extensions/imessage/src/client.ts: PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR", "extensions/imessage/src/monitor-reply-cache.ts: resetIMessageShortIdState", "extensions/imessage/src/monitor/catchup.ts: loadIMessageCatchupCursor", @@ -440,7 +443,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/qa-lab/src/agentic-parity.ts: QA_AGENTIC_PARITY_SCENARIO_IDS", "extensions/qa-lab/src/character-eval.ts: QaCharacterEvalJudgment", "extensions/qa-lab/src/character-eval.ts: QaCharacterEvalParams", - "extensions/qa-lab/src/child-output.ts: QA_CHILD_STDERR_TAIL_BYTES", "extensions/qa-lab/src/confidence-report.ts: buildQaConfidenceSelfTestSummary", "extensions/qa-lab/src/confidence-report.ts: QaConfidenceManifest", "extensions/qa-lab/src/gateway-child.ts: buildQaForcedRuntimeEnvPatch", @@ -734,7 +736,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/voice-call/src/webhook-exposure.ts: isLocalOnlyWebhookHost", "extensions/voice-call/src/webhook/realtime-audio-pacer.ts: calculateMulawRms", "extensions/voice-call/src/webhook/realtime-audio-pacer.ts: RealtimeAudioSerializer", - "extensions/voice-call/src/webhook/tailscale.ts: appendTailscaleCommandStdout", "extensions/voice-call/src/webhook/tailscale.ts: TAILSCALE_COMMAND_STDOUT_MAX_BYTES", "extensions/whatsapp/src/agent-tools-call.ts: createWhatsAppCallTool", "extensions/whatsapp/src/agent-tools-call.ts: testing", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 16f9a2b7a4bf..96e73b75ead2 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -204,12 +204,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10646, + 10647, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5358, + 5359, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 3adaacef7b01..27a78780e5eb 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -15,6 +15,10 @@ export const DEFAULT_SANDBOX_WORKDIR = "/workspace"; export const DEFAULT_SANDBOX_IDLE_HOURS = 24; export const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7; +// Shell bridges materialize complete stdout/stderr buffers in the gateway. +// Bound sandbox-controlled output before it can exhaust the host heap. +export const SANDBOX_COMMAND_MAX_BUFFER_BYTES = 100 * 1024 * 1024; + export const DEFAULT_TOOL_ALLOW = [ "exec", "process", diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 06e87cc91d1a..9c1c1f413bb7 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -1,10 +1,8 @@ // Docker sandbox recreation tests cover config-hash labels, bind ordering, and // mount labels used to decide when shared containers must be rebuilt. -import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { computeSandboxConfigHash, @@ -20,13 +18,6 @@ type SpawnCall = { args: string[]; }; -type MockDockerChild = EventEmitter & { - stdout: Readable; - stderr: Readable; - stdin: EventEmitter & { end: (input?: string | Buffer) => void }; - kill: (signal?: NodeJS.Signals) => void; -}; - const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], inspectRunning: true, @@ -59,20 +50,11 @@ vi.mock("../../runtime.js", () => ({ defaultRuntime: runtimeMocks, })); -function createMockDockerChild(): MockDockerChild { - const child = new EventEmitter() as MockDockerChild; - child.stdout = new Readable({ read() {} }); - child.stderr = new Readable({ read() {} }); - child.stdin = Object.assign(new EventEmitter(), { end: () => undefined }); - child.kill = () => undefined; - return child; -} - -function spawnDockerProcess(command: string, args: string[]) { +async function spawnDockerProcess(commandAndArgs: string[]) { + const [command = "", ...args] = commandAndArgs; // The tests assert docker CLI arguments without requiring Docker; this mock // implements only the inspect/create/start/rm calls used by ensureSandboxContainer. spawnState.calls.push({ command, args }); - const child = createMockDockerChild(); let code = 0; let stdout = ""; @@ -99,28 +81,19 @@ function spawnDockerProcess(command: string, args: string[]) { code = 1; stderr = `unexpected docker args: ${args.join(" ")}`; } - - queueMicrotask(() => { - if (stdout) { - child.stdout.emit("data", Buffer.from(stdout)); - } - if (stderr) { - child.stderr.emit("data", Buffer.from(stderr)); - } - child.emit("close", code); - }); - return child; -} - -async function createChildProcessMock() { - const actual = await vi.importActual("node:child_process"); return { - ...actual, - spawn: spawnDockerProcess, + failed: code !== 0, + isCanceled: false, + exitCode: code, + stdout: Buffer.from(stdout), + stderr: Buffer.from(stderr), }; } -vi.mock("node:child_process", async () => createChildProcessMock()); +vi.mock("../../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + spawnCommand: spawnDockerProcess, +})); let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer; let resolveDockerEnvPolicyEpoch: typeof import("./docker.js").resolveDockerEnvPolicyEpoch; @@ -131,7 +104,10 @@ async function loadFreshDockerModuleForTest() { readRegistryEntry: registryMocks.readRegistryEntry, updateRegistry: registryMocks.updateRegistry, })); - vi.doMock("node:child_process", async () => createChildProcessMock()); + vi.doMock("../../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + spawnCommand: spawnDockerProcess, + })); ({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch } = await import("./docker.js")); } diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index 383d8ddb3291..a2f7c2801e12 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -1,44 +1,44 @@ // Docker image tests cover sandbox image inspection and actionable setup errors // without invoking a real Docker daemon. -import { EventEmitter } from "node:events"; -import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_SANDBOX_IMAGE } from "./constants.js"; +import { DEFAULT_SANDBOX_IMAGE, SANDBOX_COMMAND_MAX_BUFFER_BYTES } from "./constants.js"; type SpawnCall = { command: string; args: string[]; }; -type MockDockerChild = EventEmitter & { - stdout: Readable; - stderr: Readable; - stdin: EventEmitter & { end: (input?: string | Buffer) => void }; - kill: (signal?: NodeJS.Signals) => void; +type SpawnCallOptions = { + maxBuffer?: number; }; const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], imageExists: true, inspectError: "", - streamError: undefined as "stdin" | "stdout" | "stderr" | undefined, - killSignals: [] as (NodeJS.Signals | undefined)[], + lastOptions: undefined as SpawnCallOptions | undefined, + executionError: undefined as Error | undefined, + transportFailure: false, + transportExitCode: 0, })); -function createMockDockerChild(): MockDockerChild { - const child = new EventEmitter() as MockDockerChild; - child.stdout = new Readable({ read() {} }); - child.stderr = new Readable({ read() {} }); - child.stdin = Object.assign(new EventEmitter(), { end: () => undefined }); - child.kill = (signal) => { - spawnState.killSignals.push(signal); - }; - return child; -} - -function spawnDockerProcess(command: string, args: string[]) { +async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallOptions) { + const [command = "", ...args] = commandAndArgs; spawnState.calls.push({ command, args }); - const child = createMockDockerChild(); + spawnState.lastOptions = options; + if (spawnState.executionError) { + throw spawnState.executionError; + } + if (spawnState.transportFailure) { + return Object.assign(new Error("docker stream failed"), { + cause: new Error("docker stream failed"), + failed: true, + isCanceled: false, + exitCode: spawnState.transportExitCode, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + } let code = 0; let stderr = ""; @@ -50,42 +50,33 @@ function spawnDockerProcess(command: string, args: string[]) { stderr = spawnState.imageExists ? "" : spawnState.inspectError || `Error response from daemon: No such image: ${args[2]}`; - } else if (args[0] === "pull" || args[0] === "tag") { - code = 0; - } else { + } else if (args[0] !== "pull" && args[0] !== "tag") { code = 1; stderr = `unexpected docker args: ${args.join(" ")}`; } - - queueMicrotask(() => { - if (spawnState.streamError) { - const stream = child[spawnState.streamError] as EventEmitter; - stream.emit("error", new Error(`${spawnState.streamError} read failed`)); - } - if (stderr) { - child.stderr.emit("data", Buffer.from(stderr)); - } - child.emit("close", code); - }); - return child; -} - -async function createChildProcessMock() { - const actual = await vi.importActual("node:child_process"); return { - ...actual, - spawn: spawnDockerProcess, + failed: code !== 0, + isCanceled: false, + exitCode: code, + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), }; } -vi.mock("node:child_process", async () => createChildProcessMock()); +vi.mock("../../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + spawnCommand: spawnDockerProcess, +})); let ensureDockerImage: typeof import("./docker.js").ensureDockerImage; let execDockerRaw: typeof import("./docker.js").execDockerRaw; async function loadFreshDockerModuleForTest() { vi.resetModules(); - vi.doMock("node:child_process", async () => createChildProcessMock()); + vi.doMock("../../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + spawnCommand: spawnDockerProcess, + })); ({ ensureDockerImage, execDockerRaw } = await import("./docker.js")); } @@ -94,8 +85,10 @@ describe("ensureDockerImage", () => { spawnState.calls.length = 0; spawnState.imageExists = true; spawnState.inspectError = ""; - spawnState.streamError = undefined; - spawnState.killSignals.length = 0; + spawnState.lastOptions = undefined; + spawnState.executionError = undefined; + spawnState.transportFailure = false; + spawnState.transportExitCode = 0; await loadFreshDockerModuleForTest(); }); @@ -156,20 +149,41 @@ describe("execDockerRaw", () => { spawnState.calls.length = 0; spawnState.imageExists = true; spawnState.inspectError = ""; - spawnState.streamError = undefined; - spawnState.killSignals.length = 0; + spawnState.lastOptions = undefined; + spawnState.executionError = undefined; + spawnState.transportFailure = false; + spawnState.transportExitCode = 0; await loadFreshDockerModuleForTest(); }); - it.each(["stdin", "stdout", "stderr"] as const)( - "rejects and terminates Docker when %s fails", - async (stream) => { - spawnState.streamError = stream; + it("preserves canonical wrapper execution errors", async () => { + spawnState.executionError = new Error("docker execution failed"); - await expect( - execDockerRaw(["image", "inspect", DEFAULT_SANDBOX_IMAGE], { allowFailure: true }), - ).rejects.toThrow(`${stream} read failed`); - expect(spawnState.killSignals).toEqual(["SIGTERM"]); - }, - ); + await expect( + execDockerRaw(["image", "inspect", DEFAULT_SANDBOX_IMAGE], { allowFailure: true }), + ).rejects.toThrow("docker execution failed"); + }); + + it("applies the sandbox output cap explicitly", async () => { + await execDockerRaw(["image", "inspect", DEFAULT_SANDBOX_IMAGE]); + + expect(spawnState.lastOptions?.maxBuffer).toBe(SANDBOX_COMMAND_MAX_BUFFER_BYTES); + }); + + it("rejects transport failures even when Docker exits zero", async () => { + spawnState.transportFailure = true; + + await expect(execDockerRaw(["version"], { allowFailure: true })).rejects.toThrow( + "docker stream failed", + ); + }); + + it("rejects transport failures even when Docker exits nonzero", async () => { + spawnState.transportFailure = true; + spawnState.transportExitCode = 7; + + await expect(execDockerRaw(["version"], { allowFailure: true })).rejects.toThrow( + "docker stream failed", + ); + }); }); diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index a0a09f2e9b0a..cb1147e34c7e 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -3,13 +3,10 @@ * * Wraps Docker spawn, environment sanitization, container inspection, creation, and exec behavior. */ -import { spawn } from "node:child_process"; import { createAbortError } from "../../infra/abort-signal.js"; +import { toErrorObject } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { - materializeWindowsSpawnProgram, - resolveWindowsSpawnProgram, -} from "../../plugin-sdk/windows-spawn.js"; +import { isPlainCommandExitFailure, spawnCommand } from "../../process/exec.js"; import { sanitizeEnvVars, sanitizeExplicitSandboxEnvVars, @@ -34,152 +31,60 @@ type ExecDockerRawError = Error & { stderr: Buffer; }; -type DockerSpawnRuntime = { - platform: NodeJS.Platform; - env: NodeJS.ProcessEnv; - execPath: string; -}; - -const DEFAULT_DOCKER_SPAWN_RUNTIME: DockerSpawnRuntime = { - platform: process.platform, - env: process.env, - execPath: process.execPath, -}; - -export function resolveDockerSpawnInvocation( - args: string[], - runtime: DockerSpawnRuntime = DEFAULT_DOCKER_SPAWN_RUNTIME, -): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } { - const program = resolveWindowsSpawnProgram({ - command: "docker", - platform: runtime.platform, - env: runtime.env, - execPath: runtime.execPath, - packageName: "docker", - allowShellFallback: false, - }); - const resolved = materializeWindowsSpawnProgram(program, args); - return { - command: resolved.command, - args: resolved.argv, - shell: resolved.shell, - windowsHide: resolved.windowsHide, - }; -} - -export function execDockerRaw( +export async function execDockerRaw( args: string[], opts?: ExecDockerRawOptions, ): Promise { - return new Promise((resolve, reject) => { - const spawnInvocation = resolveDockerSpawnInvocation(args); - const child = spawn(spawnInvocation.command, spawnInvocation.args, { - stdio: ["pipe", "pipe", "pipe"], - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, + let result; + try { + result = await spawnCommand(["docker", ...args], { + cancelSignal: opts?.signal, + encoding: "buffer", + input: opts?.input ?? Buffer.alloc(0), + maxBuffer: SANDBOX_COMMAND_MAX_BUFFER_BYTES, + reject: false, + stripFinalNewline: false, }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let aborted = false; - let outputStreamError: Error | undefined; - - const signal = opts?.signal; - const handleAbort = () => { - if (aborted) { - return; - } - aborted = true; - child.kill("SIGTERM"); - }; - if (signal) { - if (signal.aborted) { - handleAbort(); - } else { - signal.addEventListener("abort", handleAbort, { once: true }); - } + } catch (error) { + if (opts?.signal?.aborted) { + throw createAbortError("Aborted"); } - - const handleStreamError = (error: Error) => { - if (outputStreamError) { - return; - } - // Broken stdio means the command exchange is incomplete, so it cannot - // report success even if Docker later exits with code 0. - outputStreamError = error; - child.kill("SIGTERM"); - }; - child.stdout?.on("error", handleStreamError); - child.stdout?.on("data", (chunk) => { - stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - child.stderr?.on("error", handleStreamError); - child.stderr?.on("data", (chunk) => { - stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - - child.on("error", (error) => { - if (signal) { - signal.removeEventListener("abort", handleAbort); - } - if ( - error && - typeof error === "object" && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ) { - const friendly = Object.assign( - new Error( - 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.', - ), - { code: "INVALID_CONFIG", cause: error }, - ); - reject(friendly); - return; - } - reject(error); - }); - - child.on("close", (code) => { - if (signal) { - signal.removeEventListener("abort", handleAbort); - } - const stdout = Buffer.concat(stdoutChunks); - const stderr = Buffer.concat(stderrChunks); - if (aborted || signal?.aborted) { - reject(createAbortError("Aborted")); - return; - } - if (outputStreamError) { - reject(outputStreamError); - return; - } - const exitCode = code ?? 0; - if (exitCode !== 0 && !opts?.allowFailure) { - const message = stderr.length > 0 ? stderr.toString("utf8").trim() : ""; - const error: ExecDockerRawError = Object.assign( - new Error(message || `docker ${args.join(" ")} failed`), - { - code: exitCode, - stdout, - stderr, - }, - ); - reject(error); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - - const stdin = child.stdin; - if (stdin) { - stdin.on("error", handleStreamError); - if (opts?.input !== undefined) { - stdin.end(opts.input); - } else { - stdin.end(); - } + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw Object.assign( + new Error( + 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.', + ), + { code: "INVALID_CONFIG", cause: error }, + ); } - }); + throw error; + } + if (opts?.signal?.aborted || result.isCanceled) { + throw createAbortError("Aborted"); + } + if (result.failed && !isPlainCommandExitFailure(result)) { + if (result.code === "ENOENT") { + throw Object.assign( + new Error( + 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.', + ), + { code: "INVALID_CONFIG", cause: result }, + ); + } + throw toErrorObject(result, "Docker command execution failed"); + } + const stdout = Buffer.from(result.stdout); + const stderr = Buffer.from(result.stderr); + const exitCode = result.exitCode ?? (result.failed ? 1 : 0); + if (exitCode !== 0 && !opts?.allowFailure) { + const message = stderr.length > 0 ? stderr.toString("utf8").trim() : ""; + const error: ExecDockerRawError = Object.assign( + new Error(message || `docker ${args.join(" ")} failed`), + { code: exitCode, stdout, stderr }, + ); + throw error; + } + return { stdout, stderr, code: exitCode }; } import { formatCliCommand } from "../../cli/command-format.js"; @@ -189,7 +94,11 @@ import { computeSandboxConfigHash, SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH, } from "./config-hash.js"; -import { DEFAULT_SANDBOX_IMAGE, SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js"; +import { + DEFAULT_SANDBOX_IMAGE, + SANDBOX_COMMAND_MAX_BUFFER_BYTES, + SANDBOX_DOCKER_CREATE_ARGS_EPOCH, +} from "./constants.js"; import { readRegistryEntry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; diff --git a/src/agents/sandbox/docker.windows.test.ts b/src/agents/sandbox/docker.windows.test.ts deleted file mode 100644 index cb0a17e9d9af..000000000000 --- a/src/agents/sandbox/docker.windows.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Docker Windows invocation tests cover safe docker executable resolution -// without shelling through wrapper scripts. -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; -import { resolveDockerSpawnInvocation } from "./docker.js"; - -const tempDirs = createTrackedTempDirs(); -const createTempDir = () => tempDirs.make("openclaw-docker-spawn-test-"); - -afterEach(async () => { - await tempDirs.cleanup(); -}); - -describe("resolveDockerSpawnInvocation", () => { - it("keeps non-windows invocation unchanged", () => { - const resolved = resolveDockerSpawnInvocation(["version"], { - platform: "darwin", - env: {}, - execPath: "/usr/bin/node", - }); - expect(resolved).toEqual({ - command: "docker", - args: ["version"], - shell: undefined, - windowsHide: undefined, - }); - }); - - it("prefers docker.exe entrypoint over cmd shell fallback on windows", async () => { - const dir = await createTempDir(); - const exePath = path.join(dir, "docker.exe"); - const cmdPath = path.join(dir, "docker.cmd"); - await writeFile(exePath, "", "utf8"); - await writeFile(cmdPath, `@ECHO off\r\n"%~dp0\\docker.exe" %*\r\n`, "utf8"); - - const resolved = resolveDockerSpawnInvocation(["version"], { - platform: "win32", - env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, - execPath: "C:\\node\\node.exe", - }); - - expect(resolved).toEqual({ - command: exePath, - args: ["version"], - shell: undefined, - windowsHide: true, - }); - }); - - it("rejects unresolved docker.cmd wrappers instead of shelling out", async () => { - // Shell fallback would reinterpret docker args on Windows; require a real - // executable or Node entrypoint instead. - const dir = await createTempDir(); - const cmdPath = path.join(dir, "docker.cmd"); - await mkdir(path.dirname(cmdPath), { recursive: true }); - await writeFile(cmdPath, "@ECHO off\r\necho docker\r\n", "utf8"); - - expect(() => - resolveDockerSpawnInvocation(["ps"], { - platform: "win32", - env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, - execPath: "C:\\node\\node.exe", - }), - ).toThrow( - /wrapper resolved, but no executable\/Node entrypoint could be resolved without shell execution\./i, - ); - }); -}); diff --git a/src/agents/sandbox/ssh.spawn-env.test.ts b/src/agents/sandbox/ssh.spawn-env.test.ts index 953873e66c0f..1a39e9ccce75 100644 --- a/src/agents/sandbox/ssh.spawn-env.test.ts +++ b/src/agents/sandbox/ssh.spawn-env.test.ts @@ -8,8 +8,12 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureFullEnv } from "../../test-utils/env.js"; +import { SANDBOX_COMMAND_MAX_BUFFER_BYTES } from "./constants.js"; -const spawnMock = vi.hoisted(() => vi.fn()); +const { spawnMock, spawnCommandMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + spawnCommandMock: vi.fn(), +})); type MockChildProcess = EventEmitter & { stdin: PassThrough; @@ -35,6 +39,11 @@ vi.mock("node:child_process", async () => { }; }); +vi.mock("../../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + spawnCommand: spawnCommandMock, +})); + function mockSuccessfulSpawnCalls(times = 1) { let chain = spawnMock; for (let i = 0; i < times; i += 1) { @@ -60,6 +69,19 @@ function spawnOptionsAt(index: number): SpawnOptions { return options; } +function spawnCommandOptions(): { + baseEnv: Record; + maxBuffer?: number; +} { + const options = spawnCommandMock.mock.calls[0]?.[1] as + | { baseEnv?: Record; maxBuffer?: number } + | undefined; + if (!options?.baseEnv) { + throw new Error("expected spawnCommand options"); + } + return { ...options, baseEnv: options.baseEnv }; +} + let runSshSandboxCommand: typeof import("./ssh.js").runSshSandboxCommand; let uploadDirectoryToSshTarget: typeof import("./ssh.js").uploadDirectoryToSshTarget; @@ -71,6 +93,13 @@ describe("ssh subprocess env sanitization", () => { envSnapshot = captureFullEnv(); vi.resetModules(); vi.clearAllMocks(); + spawnCommandMock.mockResolvedValue({ + failed: false, + isCanceled: false, + exitCode: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); ({ runSshSandboxCommand, uploadDirectoryToSshTarget } = await import("./ssh.js")); }); @@ -84,9 +113,7 @@ describe("ssh subprocess env sanitization", () => { }); it("filters blocked secrets before spawning ssh commands", async () => { - mockSuccessfulSpawnCalls(); - - process.env.OPENAI_API_KEY = "sk-test-secret"; + process.env.OPENAI_API_KEY = "x"; process.env.LANG = "en_US.UTF-8"; await runSshSandboxCommand({ @@ -98,15 +125,65 @@ describe("ssh subprocess env sanitization", () => { remoteCommand: "true", }); - const env = spawnOptionsAt(0).env; - expect(env?.OPENAI_API_KEY).toBeUndefined(); - expect(env?.LANG).toBe("en_US.UTF-8"); + const options = spawnCommandOptions(); + const baseEnv = options.baseEnv; + expect(baseEnv.OPENAI_API_KEY).toBeUndefined(); + expect(baseEnv.LANG).toBe("en_US.UTF-8"); + expect(options.maxBuffer).toBe(SANDBOX_COMMAND_MAX_BUFFER_BYTES); + }); + + it("rejects transport failures even when ssh exits zero", async () => { + spawnCommandMock.mockResolvedValueOnce( + Object.assign(new Error("ssh stream failed"), { + failed: true, + isCanceled: false, + exitCode: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }), + ); + + await expect( + runSshSandboxCommand({ + session: { + command: "ssh", + configPath: "/tmp/openclaw-test-ssh-config", + host: "openclaw-sandbox", + }, + remoteCommand: "true", + }), + ).rejects.toThrow("ssh stream failed"); + }); + + it("rejects transport failures even when ssh exits nonzero", async () => { + spawnCommandMock.mockResolvedValueOnce( + Object.assign(new Error("ssh stream failed"), { + cause: new Error("ssh stream failed"), + failed: true, + isCanceled: false, + exitCode: 7, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }), + ); + + await expect( + runSshSandboxCommand({ + session: { + command: "ssh", + configPath: "/tmp/openclaw-test-ssh-config", + host: "openclaw-sandbox", + }, + remoteCommand: "false", + allowFailure: true, + }), + ).rejects.toThrow("ssh stream failed"); }); it("filters blocked secrets before spawning ssh uploads", async () => { mockSuccessfulSpawnCalls(2); - process.env.ANTHROPIC_API_KEY = "sk-test-secret"; + process.env.ANTHROPIC_API_KEY = "x"; process.env.NODE_ENV = "test"; const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-upload-env-")); tempDirs.push(localDir); diff --git a/src/agents/sandbox/ssh.stream-errors.test.ts b/src/agents/sandbox/ssh.stream-errors.test.ts index bd3f13aa4e38..1f8ce851374d 100644 --- a/src/agents/sandbox/ssh.stream-errors.test.ts +++ b/src/agents/sandbox/ssh.stream-errors.test.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; @@ -32,16 +32,14 @@ vi.mock("node:child_process", async () => { }; }); -const spawnMocked = vi.mocked(spawn); const tempDirs: string[] = []; -let runSshSandboxCommand: typeof import("./ssh.js").runSshSandboxCommand; let uploadDirectoryToSshTarget: typeof import("./ssh.js").uploadDirectoryToSshTarget; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); - ({ runSshSandboxCommand, uploadDirectoryToSshTarget } = await import("./ssh.js")); + ({ uploadDirectoryToSshTarget } = await import("./ssh.js")); }); afterEach(async () => { @@ -61,28 +59,6 @@ function fakeSession(): import("./ssh.js").SshSandboxSession { } describe("SSH sandbox stream errors", () => { - it.each(["stdout", "stderr", "stdin"] as const)( - "rejects and terminates once when command %s fails", - async (streamName) => { - const child = createMockChildProcess(); - spawnMocked.mockReturnValueOnce(child as unknown as ChildProcess); - const expected = `${streamName} failed`; - const result = runSshSandboxCommand({ - session: fakeSession(), - remoteCommand: "echo hi", - }); - - child[streamName].emit("error", new Error(expected)); - - await expect(result).rejects.toThrow(expected); - expect(child.kill).toHaveBeenCalledExactlyOnceWith("SIGKILL"); - - child.emit("close", 0); - child[streamName].emit("error", new Error("late stream error")); - expect(child.kill).toHaveBeenCalledOnce(); - }, - ); - it.each(["tar.stdout", "tar.stderr", "ssh.stdin", "ssh.stdout", "ssh.stderr"] as const)( "rejects and terminates both upload children once when %s fails", async (stream) => { @@ -90,7 +66,7 @@ describe("SSH sandbox stream errors", () => { tempDirs.push(localDir); const tar = createMockChildProcess(); const ssh = createMockChildProcess(); - spawnMocked + spawnMock .mockReturnValueOnce(tar as unknown as ChildProcess) .mockReturnValueOnce(ssh as unknown as ChildProcess); const expected = `${stream} failed`; @@ -100,7 +76,7 @@ describe("SSH sandbox stream errors", () => { remoteDir: "/remote/workspace", }); const rejection = expect(result).rejects.toThrow(expected); - await vi.waitFor(() => expect(spawnMocked).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)); const [childName, streamName] = stream.split(".") as ["tar" | "ssh", keyof MockChildProcess]; const failedStream = { tar, ssh }[childName][streamName] as PassThrough; diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts index b10825008325..7f91125d526c 100644 --- a/src/agents/sandbox/ssh.ts +++ b/src/agents/sandbox/ssh.ts @@ -7,12 +7,15 @@ import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { createAbortError } from "../../infra/abort-signal.js"; import { resolveRootPath } from "../../infra/boundary-path.js"; import { toErrorObject } from "../../infra/errors.js"; import { parseSshTarget } from "../../infra/ssh-tunnel.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { isPlainCommandExitFailure, spawnCommand } from "../../process/exec.js"; import { resolveUserPath } from "../../utils.js"; import type { SandboxBackendCommandResult } from "./backend-handle.types.js"; +import { SANDBOX_COMMAND_MAX_BUFFER_BYTES } from "./constants.js"; import { sanitizeEnvVars } from "./sanitize-env-vars.js"; export type SshSandboxSettings = { @@ -681,67 +684,32 @@ export async function runSshSandboxCommand( throw new Error("SSH command argv is empty"); } const sshEnv = sanitizeEnvVars(process.env).allowed; - return await new Promise((resolve, reject) => { - const child = spawn(executable, args, { - stdio: ["pipe", "pipe", "pipe"], - env: sshEnv, - signal: params.signal, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let settled = false; - - // Child and stdio errors can race with close. Settle once so an unusable - // transport is terminated exactly once and later events stay harmless. - const finish = (complete: () => void, terminate = false) => { - if (settled) { - return; - } - settled = true; - if (terminate) { - try { - child.kill("SIGKILL"); - } catch { - // Preserve the stream error that made the transport unusable. - } - } - complete(); - }; - const fail = (error: unknown, terminate = false) => { - finish(() => reject(toErrorObject(error, "Non-Error rejection")), terminate); - }; - - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stdout.on("error", (error) => fail(error, true)); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.stderr.on("error", (error) => fail(error, true)); - child.on("error", fail); - child.on("close", (code) => { - finish(() => { - const stdout = Buffer.concat(stdoutChunks); - const stderr = Buffer.concat(stderrChunks); - const exitCode = code ?? 0; - if (exitCode !== 0 && !params.allowFailure) { - reject( - Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), { - code: exitCode, - stdout, - stderr, - }), - ); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - }); - - child.stdin?.on("error", (error) => fail(error, true)); - try { - child.stdin.end(params.stdin); - } catch (error) { - fail(error, true); - } + const result = await spawnCommand([executable, ...args], { + baseEnv: sshEnv, + cancelSignal: params.signal, + encoding: "buffer", + input: params.stdin ?? Buffer.alloc(0), + maxBuffer: SANDBOX_COMMAND_MAX_BUFFER_BYTES, + reject: false, + stripFinalNewline: false, }); + if (params.signal?.aborted || result.isCanceled) { + throw createAbortError("Aborted"); + } + if (result.failed && !isPlainCommandExitFailure(result)) { + throw toErrorObject(result, "SSH command execution failed"); + } + const stdout = Buffer.from(result.stdout); + const stderr = Buffer.from(result.stderr); + const exitCode = result.exitCode ?? (result.failed ? 1 : 0); + if (exitCode !== 0 && !params.allowFailure) { + throw Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), { + code: exitCode, + stdout, + stderr, + }); + } + return { stdout, stderr, code: exitCode }; } export const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [ diff --git a/src/agents/sessions/footer-data-provider.ts b/src/agents/sessions/footer-data-provider.ts index c738782dae6e..e53822efcb52 100644 --- a/src/agents/sessions/footer-data-provider.ts +++ b/src/agents/sessions/footer-data-provider.ts @@ -3,7 +3,7 @@ * * Watches git metadata and exposes current branch/repository state without blocking rendering. */ -import { type ExecFileException, execFile, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { existsSync, type FSWatcher, @@ -13,6 +13,7 @@ import { watchFile, } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { runExec } from "../../process/exec.js"; import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js"; type GitPaths = { @@ -81,25 +82,17 @@ function resolveBranchWithGitSync(repoDir: string): string | null { } /** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */ -function resolveBranchWithGitAsync(repoDir: string): Promise { - return new Promise((resolvePromise) => { - execFile( +async function resolveBranchWithGitAsync(repoDir: string): Promise { + try { + const { stdout } = await runExec( "git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], - { - cwd: repoDir, - encoding: "utf8", - }, - (error: ExecFileException | null, stdout: string) => { - if (error) { - resolvePromise(null); - return; - } - const branch = stdout.trim(); - resolvePromise(branch || null); - }, + { cwd: repoDir, logOutput: false }, ); - }); + return stdout.trim() || null; + } catch { + return null; + } } /** diff --git a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts index 709e95b7dde9..e254f7c58317 100644 --- a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts +++ b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts @@ -1,8 +1,6 @@ /** Tests sandbox media staging for SCP remote-path inputs. */ -import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import { basename, join } from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { slugifySessionKey } from "../agents/sandbox/shared.js"; import { CONFIG_DIR } from "../utils.js"; @@ -15,8 +13,8 @@ import { const sandboxMocks = vi.hoisted(() => ({ ensureSandboxWorkspaceForSession: vi.fn(), })); -const childProcessMocks = vi.hoisted(() => ({ - spawn: vi.fn(), +const processExecMocks = vi.hoisted(() => ({ + runCommandWithTimeout: vi.fn(), })); const mediaRootMocks = vi.hoisted(() => ({ resolveChannelRemoteInboundAttachmentRoots: vi.fn(), @@ -24,11 +22,11 @@ const mediaRootMocks = vi.hoisted(() => ({ vi.mock("../agents/sandbox.js", () => sandboxMocks); vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks); -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); +vi.mock("../process/exec.js", async () => { + const actual = await vi.importActual("../process/exec.js"); return { ...actual, - spawn: childProcessMocks.spawn, + runCommandWithTimeout: processExecMocks.runCommandWithTimeout, }; }); @@ -40,7 +38,7 @@ import { afterEach(() => { vi.restoreAllMocks(); - childProcessMocks.spawn.mockClear(); + processExecMocks.runCommandWithTimeout.mockReset(); mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReset(); }); @@ -122,7 +120,7 @@ describe("stageSandboxMedia scp remote paths", () => { workspaceDir, }); - expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(processExecMocks.runCommandWithTimeout).not.toHaveBeenCalled(); await expectPathMissing(join(remoteCacheDir, basename(remotePath))); expect(ctx.MediaPath).toBe(remotePath); expect(sessionCtx.MediaPath).toBe(remotePath); @@ -137,9 +135,7 @@ describe("stageSandboxMedia scp remote paths", () => { const sessionKey = "agent:main:explicit:../../escape"; const remotePath = "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg"; const { ctx, sessionCtx } = createRemoteContexts(remotePath); - childProcessMocks.spawn.mockImplementation(() => { - throw new Error("stop before scp"); - }); + processExecMocks.runCommandWithTimeout.mockRejectedValue(new Error("stop before scp")); await stageSandboxMedia({ ctx, @@ -149,8 +145,8 @@ describe("stageSandboxMedia scp remote paths", () => { workspaceDir, }); - const [command] = requireFirstMockCall(childProcessMocks.spawn, "scp spawn"); - expect(command).toBe("scp"); + const [command] = requireFirstMockCall(processExecMocks.runCommandWithTimeout, "scp command"); + expect(command).toEqual(expect.arrayContaining(["scp"])); const remoteCacheRoot = join(CONFIG_DIR, "media", "remote-cache"); const expectedSafeDir = join(remoteCacheRoot, slugifySessionKey(sessionKey)); try { @@ -170,24 +166,14 @@ describe("stageSandboxMedia scp remote paths", () => { const { ctx, sessionCtx } = createRemoteContexts(remotePath); ctx.MediaPaths = [remotePath]; sessionCtx.MediaPaths = [remotePath]; - childProcessMocks.spawn.mockImplementation((_command, argsUnknown) => { - const args = argsUnknown as string[]; - const localPath = expectDefined( - args[args.length - 1], - "args[args.length - 1] test invariant", - ); - const child = new EventEmitter() as EventEmitter & { - stderr: EventEmitter & { setEncoding: (_encoding: string) => void }; - }; - child.stderr = Object.assign(new EventEmitter(), { - setEncoding: () => undefined, - }); - queueMicrotask(() => { - void fs.writeFile(localPath, "staged-image-bytes").then(() => { - child.emit("close", 0); - }); - }); - return child; + processExecMocks.runCommandWithTimeout.mockImplementation(async (argvUnknown) => { + const argv = argvUnknown as string[]; + const localPath = argv.at(-1); + if (!localPath) { + throw new Error("missing scp destination"); + } + await fs.writeFile(localPath, "staged-image-bytes"); + return { code: 0, stdout: "", stderr: "" }; }); const result = await stageSandboxMedia({ @@ -232,24 +218,14 @@ describe("stageSandboxMedia scp remote paths", () => { const { ctx, sessionCtx } = createRemoteContexts(remotePath); ctx.MediaPaths = [remotePath]; sessionCtx.MediaPaths = [remotePath]; - childProcessMocks.spawn.mockImplementation((_command, argsUnknown) => { - const args = argsUnknown as string[]; - const localPath = expectDefined( - args[args.length - 1], - "args[args.length - 1] test invariant", - ); - const child = new EventEmitter() as EventEmitter & { - stderr: EventEmitter & { setEncoding: (_encoding: string) => void }; - }; - child.stderr = Object.assign(new EventEmitter(), { - setEncoding: () => undefined, - }); - queueMicrotask(() => { - void fs.writeFile(localPath, "staged-image-bytes").then(() => { - child.emit("close", 0); - }); - }); - return child; + processExecMocks.runCommandWithTimeout.mockImplementation(async (argvUnknown) => { + const argv = argvUnknown as string[]; + const localPath = argv.at(-1); + if (!localPath) { + throw new Error("missing scp destination"); + } + await fs.writeFile(localPath, "staged-image-bytes"); + return { code: 0, stdout: "", stderr: "" }; }); const result = await stageSandboxMedia({ diff --git a/src/auto-reply/reply/stage-sandbox-media.scp.test.ts b/src/auto-reply/reply/stage-sandbox-media.scp.test.ts index 6a2fc18f26fc..583e683bdce6 100644 --- a/src/auto-reply/reply/stage-sandbox-media.scp.test.ts +++ b/src/auto-reply/reply/stage-sandbox-media.scp.test.ts @@ -1,72 +1,56 @@ -import type { ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SCP_STDERR_TAIL_CHARS, testing } from "./stage-sandbox-media.js"; const hasUnpairedUtf16Surrogate = (text: string): boolean => /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? ({ spawnMock: vi.fn() })); +const { runCommandWithTimeoutMock } = vi.hoisted(() => ({ + runCommandWithTimeoutMock: vi.fn(), +})); -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: spawnMock, - }; -}); +vi.mock("../../process/exec.js", () => ({ + runCommandWithTimeout: runCommandWithTimeoutMock, +})); describe("scpFile", () => { beforeEach(() => { - spawnMock.mockReset(); + runCommandWithTimeoutMock.mockReset(); }); - function createChild() { - const stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - const kill = vi.fn(() => true); - const child = Object.assign(new EventEmitter(), { kill, stderr }); - spawnMock.mockReturnValue(child as unknown as ChildProcess); - return { child, kill, stderr }; - } + it("runs scp through the canonical bounded wrapper", async () => { + runCommandWithTimeoutMock.mockResolvedValue({ code: 0, stdout: "", stderr: "" }); - it("keeps child close authoritative when stderr emits an error", async () => { - const { child, kill, stderr } = createChild(); - - const resultPromise = testing.scpFile("host", "/remote/path", "/local/path"); - - expect(() => stderr.emit("error", new Error("stderr EPIPE"))).not.toThrow(); - expect(kill).not.toHaveBeenCalled(); - child.emit("close", 0); - - await expect(resultPromise).resolves.toBeUndefined(); - }); - - it("includes the stderr stream error when scp exits unsuccessfully", async () => { - const { child, stderr } = createChild(); - - const resultPromise = testing.scpFile("host", "/remote/path", "/local/path"); - stderr.emit("error", new Error("stderr EPIPE")); - child.emit("close", 1); - - await expect(resultPromise).rejects.toThrow("scp failed (1): stderr EPIPE"); + await expect(testing.scpFile("host", "/remote/path", "/local/path")).resolves.toBeUndefined(); + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( + [ + "scp", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + "--", + "host:/remote/path", + "/local/path", + ], + { maxOutputBytes: { stdout: 1, stderr: SCP_STDERR_TAIL_CHARS * 4 } }, + ); }); it("surfaces UTF-16 safe scp stderr when transfer fails with emoji at tail boundary", async () => { - const { child, stderr } = createChild(); // Place the retained tail window on the emoji's low surrogate so raw slicing // would keep a lone surrogate half before the thrown error is built. const lowSurrogateTailStart = 100; const padding = "n".repeat(lowSurrogateTailStart - 1); const recent = "🤖" + "n".repeat(SCP_STDERR_TAIL_CHARS - 5) + "fail"; - - const resultPromise = testing.scpFile("host", "/remote/path", "/local/path"); - stderr.emit("data", padding); - stderr.emit("data", recent); - child.emit("close", 1); + runCommandWithTimeoutMock.mockResolvedValue({ + code: 1, + stdout: "", + stderr: `${padding}${recent}`, + }); let message = ""; try { - await resultPromise; + await testing.scpFile("host", "/remote/path", "/local/path"); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -76,13 +60,10 @@ describe("scpFile", () => { expect(hasUnpairedUtf16Surrogate(message)).toBe(false); }); - it("does not terminate scp again when spawning fails", async () => { - const { child, kill } = createChild(); + it("preserves wrapper execution errors", async () => { + const spawnError = new Error("spawn failed"); + runCommandWithTimeoutMock.mockRejectedValue(spawnError); - const resultPromise = testing.scpFile("host", "/remote/path", "/local/path"); - child.emit("error", new Error("spawn failed")); - - await expect(resultPromise).rejects.toThrow("spawn failed"); - expect(kill).not.toHaveBeenCalled(); + await expect(testing.scpFile("host", "/remote/path", "/local/path")).rejects.toBe(spawnError); }); }); diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 4f08d3978216..f8cd87d2d019 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -1,5 +1,4 @@ // Stages inbound media into sandbox workspaces before agent execution. -import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; @@ -12,13 +11,13 @@ import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; import { slugifySessionKey } from "../../agents/sandbox/shared.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; -import { formatErrorMessage } from "../../infra/errors.js"; import { root as fsRoot, FsSafeError } from "../../infra/fs-safe.js"; import { normalizeScpRemoteHost, normalizeScpRemotePath } from "../../infra/scp-host.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { resolveChannelRemoteInboundAttachmentRoots } from "../../media/channel-inbound-roots.js"; import { resolveInboundMediaReference } from "../../media/media-reference.js"; import { getMediaDir, MEDIA_MAX_BYTES } from "../../media/store.js"; +import { runCommandWithTimeout } from "../../process/exec.js"; import { CONFIG_DIR } from "../../utils.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -369,54 +368,27 @@ async function scpFile(remoteHost: string, remotePath: string, localPath: string if (!safeRemotePath) { throw new Error("invalid remote path for SCP"); } - return new Promise((resolve, reject) => { - const child = spawn( + const result = await runCommandWithTimeout( + [ "scp", - [ - "-o", - "BatchMode=yes", - "-o", - "StrictHostKeyChecking=yes", - "--", - `${safeRemoteHost}:${safeRemotePath}`, - localPath, - ], - { stdio: ["ignore", "ignore", "pipe"] }, - ); - - let stderr = ""; - let settled = false; - const finish = (error?: Error) => { - if (settled) { - return; - } - settled = true; - if (error) { - reject(error); - } else { - resolve(); - } - }; - - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk) => { - stderr = appendScpStderrTail(stderr, chunk); - }); - child.stderr?.on("error", (error) => { - // stderr is diagnostic; child close remains transfer authority so the - // caller cannot remove the staging directory while scp is still alive. - stderr = appendScpStderrTail(stderr, formatErrorMessage(error)); - }); - - child.once("error", finish); - child.once("close", (code) => { - if (code === 0) { - finish(); - } else { - finish(new Error(`scp failed (${code}): ${stderr.trim()}`)); - } - }); - }); + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + "--", + `${safeRemoteHost}:${safeRemotePath}`, + localPath, + ], + { + // Four UTF-8 bytes per code point preserves enough data for the existing + // UTF-16 diagnostic tail contract without retaining unbounded stderr. + maxOutputBytes: { stdout: 1, stderr: SCP_STDERR_TAIL_CHARS * 4 }, + }, + ); + if (result.code !== 0) { + const stderr = appendScpStderrTail("", result.stderr).trim(); + throw new Error(`scp failed (${result.code}): ${stderr}`); + } } export function appendScpStderrTail( diff --git a/src/cli/logs-cli.runtime.test.ts b/src/cli/logs-cli.runtime.test.ts index e45004b26305..c2f05b34ff91 100644 --- a/src/cli/logs-cli.runtime.test.ts +++ b/src/cli/logs-cli.runtime.test.ts @@ -1,60 +1,20 @@ -import type { ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { execFileUtf8Tail } from "./logs-cli.runtime.js"; -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: spawnMock, - }; -}); - -function mockSpawnedChild() { - const stdout = new EventEmitter(); - const stderr = new EventEmitter(); - const kill = vi.fn(() => true); - const child = Object.assign(new EventEmitter(), { kill, stderr, stdout }); - spawnMock.mockReturnValue(child as unknown as ChildProcess); - return { child, kill, stderr, stdout }; -} - describe("execFileUtf8Tail", () => { - beforeEach(() => { - spawnMock.mockReset(); - }); - - it.each(["stdout", "stderr"] as const)( - "terminates the child when %s emits an error", - async (streamName) => { - const { kill, stderr, stdout } = mockSpawnedChild(); - - const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 }); - stdout.emit("data", Buffer.from("partial output")); - const streamError = new Error(`${streamName} read failed`); - (streamName === "stdout" ? stdout : stderr).emit("error", streamError); - - await expect(resultPromise).resolves.toEqual({ - code: 1, - stderr: streamError.message, - stdout: "partial output", - truncated: false, - }); - expect(kill).toHaveBeenCalledOnce(); - }, - ); - - it("does not kill the child when spawning fails", async () => { - const { child, kill } = mockSpawnedChild(); - - const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 }); - child.emit("error", new Error("spawn failed")); - - await expect(resultPromise).resolves.toMatchObject({ code: 1, stderr: "spawn failed" }); - expect(kill).not.toHaveBeenCalled(); + it("replaces the ambient environment when an explicit environment is supplied", async () => { + process.env.OPENCLAW_LOG_ENV_LEAK_TEST = "ambient"; + try { + await expect( + execFileUtf8Tail( + process.execPath, + ["-e", "process.stdout.write(process.env.OPENCLAW_LOG_ENV_LEAK_TEST ?? 'missing')"], + { env: {}, maxBytes: 1024 }, + ), + ).resolves.toMatchObject({ code: 0, stdout: "missing" }); + } finally { + delete process.env.OPENCLAW_LOG_ENV_LEAK_TEST; + } }); it.each([ @@ -63,15 +23,13 @@ describe("execFileUtf8Tail", () => { { label: "four-byte", text: "😀z", maxBytes: 4, expected: "z" }, { label: "complete", text: "a¢z", maxBytes: 3, expected: "¢z" }, ])("decodes a $label character at the stdout tail boundary", async (testCase) => { - const { child, stdout } = mockSpawnedChild(); - const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { - maxBytes: testCase.maxBytes, - }); - - stdout.emit("data", Buffer.from(testCase.text, "utf8")); - child.emit("close", 0); - - await expect(resultPromise).resolves.toEqual({ + await expect( + execFileUtf8Tail( + process.execPath, + ["-e", `process.stdout.write(${JSON.stringify(testCase.text)})`], + { maxBytes: testCase.maxBytes }, + ), + ).resolves.toEqual({ code: 0, stderr: "", stdout: testCase.expected, @@ -79,16 +37,22 @@ describe("execFileUtf8Tail", () => { }); }); - it("decodes a truncated stderr tail at a UTF-8 boundary", async () => { - const { child, stderr } = mockSpawnedChild(); - const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 }); - - stderr.emit("data", Buffer.concat([Buffer.from("😀"), Buffer.alloc(64 * 1024 - 3, "x")])); - child.emit("close", 1); - - const result = await resultPromise; - expect(result.stderr).toBe("x".repeat(64 * 1024 - 3)); + it("keeps a bounded stderr tail for failed commands", async () => { + const result = await execFileUtf8Tail( + process.execPath, + ["-e", "process.stderr.write('😀' + 'x'.repeat(64 * 1024)); process.exit(1)"], + { maxBytes: 1024 }, + ); + expect(result.code).toBe(1); + expect(result.stderr).toBe("x".repeat(64 * 1024)); expect(result.stderr).not.toContain("�"); expect(result.truncated).toBe(false); }); + + it("returns a soft failure when command launch fails", async () => { + const command = `openclaw-missing-${process.pid}-${Date.now()}`; + const result = await execFileUtf8Tail(command, [], { maxBytes: 1024 }); + expect(result).toMatchObject({ code: 1, stdout: "", truncated: false }); + expect(result.stderr).toMatch(/ENOENT|not found/i); + }); }); diff --git a/src/cli/logs-cli.runtime.ts b/src/cli/logs-cli.runtime.ts index e559dcd37507..d7306aceff1f 100644 --- a/src/cli/logs-cli.runtime.ts +++ b/src/cli/logs-cli.runtime.ts @@ -1,6 +1,5 @@ // Runtime helpers for bounded subprocess log tails and service runtime lookups. -import { spawn } from "node:child_process"; -import { expectDefined } from "@openclaw/normalization-core"; +import { runCommandWithTimeout } from "../process/exec.js"; export { buildGatewayConnectionDetails } from "../gateway/call.js"; export { resolveGatewaySystemdServiceName } from "../daemon/constants.js"; @@ -8,98 +7,30 @@ export { readSystemdServiceRuntime } from "../daemon/systemd.js"; type ExecFileTailResult = { stdout: string; stderr: string; code: number; truncated: boolean }; -type ByteTail = { chunks: Buffer[]; bytes: number; truncated: boolean }; - const STDERR_MAX_BYTES = 64 * 1024; -function appendByteTail(tail: ByteTail, chunk: Buffer, maxBytes: number): void { - tail.chunks.push(chunk); - tail.bytes += chunk.length; - while (tail.bytes > maxBytes && tail.chunks.length > 0) { - const first = expectDefined(tail.chunks[0], "chunks entry at 0"); - const overflow = tail.bytes - maxBytes; - if (first.length <= overflow) { - tail.chunks.shift(); - tail.bytes -= first.length; - } else { - tail.chunks[0] = first.subarray(overflow); - tail.bytes -= overflow; - } - tail.truncated = true; - } -} - -function decodeUtf8Tail(tail: ByteTail): string { - const buffer = Buffer.concat(tail.chunks, tail.bytes); - if (!tail.truncated || buffer.length === 0) { - return buffer.toString("utf8"); - } - // A byte cap can cut the leading code point. Skip only its continuation - // bytes so decoding cannot invent a replacement character at the boundary. - let offset = 0; - while ( - offset < buffer.length && - (expectDefined(buffer[offset], "buffer entry at offset") & 0xc0) === 0x80 - ) { - offset += 1; - } - return buffer.subarray(offset).toString("utf8"); -} - export async function execFileUtf8Tail( command: string, args: string[], options: { env?: NodeJS.ProcessEnv; maxBytes: number }, ): Promise { - // Keep only the newest stdout bytes; log commands should not buffer unbounded output. - return await new Promise((resolve) => { - const child = spawn(command, args, { - env: options.env, - stdio: ["ignore", "pipe", "pipe"], + try { + const result = await runCommandWithTimeout([command, ...args], { + baseEnv: options.env, + maxOutputBytes: { stdout: options.maxBytes, stderr: STDERR_MAX_BYTES }, }); - const stdoutTail: ByteTail = { chunks: [], bytes: 0, truncated: false }; - const stderrTail: ByteTail = { chunks: [], bytes: 0, truncated: false }; - let settled = false; - - child.stdout?.on("data", (chunk: Buffer) => { - appendByteTail(stdoutTail, chunk, options.maxBytes); - }); - child.stderr?.on("data", (chunk: Buffer) => { - appendByteTail(stderrTail, chunk, STDERR_MAX_BYTES); - }); - - const resolveWithError = (error: unknown, terminateChild = false) => { - if (settled) { - return; - } - settled = true; - if (terminateChild) { - // Journal output is only useful when fully readable. Stop the child so - // a failed pipe cannot leave a live command holding the CLI open. - child.kill(); - } - resolve({ - stdout: decodeUtf8Tail(stdoutTail), - stderr: error instanceof Error ? error.message : String(error), - code: 1, - truncated: stdoutTail.truncated, - }); + return { + stdout: result.stdout, + stderr: result.stderr, + code: result.code ?? 1, + truncated: Boolean(result.stdoutTruncatedBytes), }; - - child.stdout?.on("error", (error) => resolveWithError(error, true)); - child.stderr?.on("error", (error) => resolveWithError(error, true)); - child.on("error", resolveWithError); - child.on("close", (code) => { - if (settled) { - return; - } - settled = true; - resolve({ - stdout: decodeUtf8Tail(stdoutTail), - stderr: decodeUtf8Tail(stderrTail), - code: typeof code === "number" ? code : 1, - truncated: stdoutTail.truncated, - }); - }); - }); + } catch (error) { + return { + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + code: 1, + truncated: false, + }; + } } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index b8577f971ad9..52232d5dbd85 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -228,6 +228,10 @@ vi.mock("node:child_process", async () => { vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: vi.fn(), + runExec: vi.fn(async () => ({ + stdout: new Date(Date.now() - 1000).toString(), + stderr: "", + })), })); vi.mock("../utils.js", async (importOriginal) => { @@ -419,7 +423,7 @@ const { } = await import("../infra/update-check.js"); const { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV } = await import("../infra/update-control-plane-sentinel.js"); -const { runCommandWithTimeout } = await import("../process/exec.js"); +const { runCommandWithTimeout, runExec } = await import("../process/exec.js"); const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js"); const { doctorCommand } = await import("../commands/doctor.js"); const { defaultRuntime } = await import("../runtime.js"); @@ -5919,15 +5923,13 @@ describe("update-cli", () => { config, outcomes: [], })); - execFile.mockImplementationOnce((...args: unknown[]) => { - const [file, commandArgs] = args; + vi.mocked(runExec).mockImplementationOnce(async (file, commandArgs) => { expect(file).toBe("powershell.exe"); expect(commandArgs).toContain("-NonInteractive"); - const callback = args.at(-1); - if (typeof callback === "function") { - callback(null, new Date(Date.now() - 1_000).toISOString(), ""); - } - return new EventEmitter(); + return { + stdout: new Date(Date.now() - 1_000).toISOString(), + stderr: "", + }; }); const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { @@ -6001,13 +6003,7 @@ describe("update-cli", () => { config, outcomes: [], })); - execFile.mockImplementationOnce((...args: unknown[]) => { - const callback = args.at(-1); - if (typeof callback === "function") { - callback(new Error("ps unavailable"), "", ""); - } - return new EventEmitter(); - }); + vi.mocked(runExec).mockRejectedValueOnce(new Error("ps unavailable")); await withEnvAsync( { diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index f9dbc50d1aed..a6705df32dba 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -1,5 +1,5 @@ // Post-core plugin finalization, fresh-process handoff, and control-plane sentinel updates. -import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -48,6 +48,7 @@ import { loadInstalledPluginIndexInstallRecords, writePersistedInstalledPluginIndexInstallRecords, } from "../../plugins/installed-plugin-index-records.js"; +import { runExec } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; import { VERSION } from "../../version.js"; import { printResult } from "./progress.js"; @@ -336,11 +337,10 @@ export async function readPostCorePluginInstallRecordsFile( } async function execFileStdout(file: string, args: string[]): Promise { - return await new Promise((resolve) => { - execFile(file, args, { timeout: 1000, windowsHide: true }, (error, stdout) => { - resolve(error ? undefined : stdout); - }); - }); + return await runExec(file, args, { logOutput: false, timeoutMs: 1000 }).then( + ({ stdout }) => stdout, + () => undefined, + ); } async function readProcessStartTimeMs(pid: number): Promise { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index ad5ee5a0bce2..b0b9f34fab11 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -1,9 +1,7 @@ /** Doctor repairs for installed gateway service config and duplicate legacy services. */ -import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -38,6 +36,7 @@ import { import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { readWindowsProcessArgsSync } from "../infra/windows-port-pids.js"; +import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { buildGatewayInstallPlan } from "./daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js"; @@ -104,11 +103,12 @@ function updateParentAllowsGatewayServiceRepair(env: NodeJS.ProcessEnv): boolean return repairPolicy !== undefined && isTruthyEnvValue(repairPolicy); } -const execFileAsync = promisify(execFile); const EXECSTART_REPAIR_CODES = new Set([ SERVICE_AUDIT_CODES.gatewayCommandMissing, SERVICE_AUDIT_CODES.gatewayEntrypointMismatch, ]); +const runLaunchctlQuietly = (args: string[]) => + runExec("launchctl", args, { logOutput: false }).catch(() => undefined); const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra"; function detectGatewayRuntime(programArguments: string[] | undefined): GatewayDaemonRuntime { @@ -344,8 +344,8 @@ async function cleanupLegacyLaunchdService(params: { plistPath: string; }): Promise { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - await execFileAsync("launchctl", ["bootout", domain, params.plistPath]).catch(() => undefined); - await execFileAsync("launchctl", ["unload", params.plistPath]).catch(() => undefined); + await runLaunchctlQuietly(["bootout", domain, params.plistPath]); + await runLaunchctlQuietly(["unload", params.plistPath]); const trashDir = path.join(os.homedir(), ".Trash"); try { diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index b44841a5fc43..7d7a995e5d2b 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -1,9 +1,7 @@ /** Platform-specific doctor notes for macOS gateway launchd state and startup tuning. */ -import { execFile } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -11,10 +9,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { findStaleOpenClawUpdateLaunchdJobs } from "../daemon/launchd.js"; import { resolveGatewayService, type GatewayService } from "../daemon/service.js"; +import { runExec } from "../process/exec.js"; import { shortenHomePath } from "../utils.js"; -const execFileAsync = promisify(execFile); - function resolveHomeDir(): string { return process.env.HOME ?? os.homedir(); } @@ -107,8 +104,8 @@ export async function noteMacStaleOpenClawUpdateLaunchdJobs(deps?: { async function launchctlGetenv(name: string): Promise { try { - const result = await execFileAsync("/bin/launchctl", ["getenv", name], { encoding: "utf8" }); - const value = normalizeOptionalString(result.stdout ?? "") ?? ""; + const result = await runExec("/bin/launchctl", ["getenv", name], { logOutput: false }); + const value = normalizeOptionalString(result.stdout) ?? ""; return value.length > 0 ? value : undefined; } catch { return undefined; diff --git a/src/commands/doctor/cron/warnings.ts b/src/commands/doctor/cron/warnings.ts index ed2b2f906626..72ad18697ec9 100644 --- a/src/commands/doctor/cron/warnings.ts +++ b/src/commands/doctor/cron/warnings.ts @@ -1,6 +1,4 @@ // Doctor cron warnings for model overrides and stale WhatsApp crontab health scripts. -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { normalizeOptionalString } from "../../../../packages/normalization-core/src/string-coerce.js"; import { note } from "../../../../packages/terminal-core/src/note.js"; import { normalizeChatChannelId } from "../../../channels/ids.js"; @@ -10,11 +8,11 @@ import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolveCronDeliveryPlan } from "../../../cron/delivery-plan.js"; import type { CronJob } from "../../../cron/types.js"; +import { runExec } from "../../../process/exec.js"; import { shortenHomePath } from "../../../utils.js"; type CrontabReader = () => Promise<{ stdout?: unknown; stderr?: unknown }>; -const execFileAsync = promisify(execFile); const LEGACY_WHATSAPP_HEALTH_SCRIPT_RE = /(?:^|\s)(?:"[^"]*ensure-whatsapp\.sh"|'[^']*ensure-whatsapp\.sh'|[^\s#;|&]*ensure-whatsapp\.sh)\b/u; const CRON_MODEL_OVERRIDE_EXAMPLE_LIMIT = 3; @@ -244,10 +242,7 @@ export function noteCronDeliveryTargetAdvisory(params: { } async function readUserCrontab(): Promise<{ stdout: string; stderr?: string }> { - const result = await execFileAsync("crontab", ["-l"], { - encoding: "utf8", - windowsHide: true, - }); + const result = await runExec("crontab", ["-l"], { logOutput: false }); return { stdout: result.stdout, stderr: result.stderr, diff --git a/src/crestodian/probes.stream-errors.test.ts b/src/crestodian/probes.stream-errors.test.ts deleted file mode 100644 index 8a5e97442a5d..000000000000 --- a/src/crestodian/probes.stream-errors.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Crestodian probe stream-error handling tests. -import type { ChildProcess, SpawnOptions } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const spawnMock = vi.hoisted(() => vi.fn()); - -type MockChildProcess = EventEmitter & { - stdin: PassThrough; - stdout: PassThrough; - stderr: PassThrough; - kill: ReturnType; -}; - -function createMockChildProcess(): MockChildProcess { - const child = new EventEmitter() as MockChildProcess; - child.stdin = new PassThrough(); - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - child.kill = vi.fn(); - return child; -} - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { ...actual, spawn: spawnMock }; -}); - -describe("probeLocalCommand stream error handling", () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - }); - - it("keeps child close authoritative when stdout and stderr emit errors", async () => { - const child = createMockChildProcess(); - - spawnMock.mockImplementationOnce( - (_cmd: string, _args: readonly string[], _opts: SpawnOptions): ChildProcess => { - process.nextTick(() => { - child.stdout.emit("error", new Error("stdout closed")); - child.stderr.emit("error", new Error("stderr closed")); - child.emit("close", 0); - }); - return child as unknown as ChildProcess; - }, - ); - - const { probeLocalCommand } = await import("./probes.js"); - - await expect(probeLocalCommand("echo", ["test"], { timeoutMs: 1000 })).resolves.toEqual({ - command: "echo", - found: true, - version: undefined, - error: undefined, - }); - }); -}); diff --git a/src/crestodian/probes.test.ts b/src/crestodian/probes.test.ts index c8f92dad5394..d044e5f99bf2 100644 --- a/src/crestodian/probes.test.ts +++ b/src/crestodian/probes.test.ts @@ -27,7 +27,7 @@ describe("crestodian probes", () => { const result = await probeLocalCommand( process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"], - { timeoutKillGraceMs: 25, timeoutMs: 25 }, + { timeoutMs: 25 }, ); expect(result).toMatchObject({ diff --git a/src/crestodian/probes.ts b/src/crestodian/probes.ts index bb8cc739c841..805148cdb0e4 100644 --- a/src/crestodian/probes.ts +++ b/src/crestodian/probes.ts @@ -1,6 +1,6 @@ // Crestodian probes check local tools and Gateway health with bounded subprocess/network work. -import { spawn } from "node:child_process"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { runCommandWithTimeout } from "../process/exec.js"; /** * Local environment probes used by Crestodian overview loading. @@ -17,98 +17,43 @@ export type LocalCommandProbe = { }; const LOCAL_COMMAND_PROBE_OUTPUT_MAX_CHARS = 16 * 1024; -const LOCAL_COMMAND_PROBE_KILL_GRACE_MS = 500; - -// The child close/error events own the probe result; pipe errors must not escape and crash setup. -const ignoreOutputStreamError = () => {}; - -function appendBounded(previous: string, chunk: string, limit: number): string { - const next = previous + chunk; - return next.length > limit ? next.slice(-limit) : next; -} - /** Probe a command by running a small version command with bounded output and timeout. */ export async function probeLocalCommand( command: string, args: string[] = ["--version"], - opts: { outputLimit?: number; timeoutKillGraceMs?: number; timeoutMs?: number } = {}, + opts: { outputLimit?: number; timeoutMs?: number } = {}, ): Promise { const timeoutMs = resolveTimerTimeoutMs(opts.timeoutMs, 1_500); const outputLimit = opts.outputLimit ?? LOCAL_COMMAND_PROBE_OUTPUT_MAX_CHARS; - const timeoutKillGraceMs = resolveTimerTimeoutMs( - opts.timeoutKillGraceMs, - LOCAL_COMMAND_PROBE_KILL_GRACE_MS, - 0, - ); - return await new Promise((resolve) => { - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - let killTimer: NodeJS.Timeout | undefined; - const child = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"], + try { + const result = await runCommandWithTimeout([command, ...args], { + killProcessTree: true, + maxOutputBytes: outputLimit, + timeoutMs, }); - const timeoutResult = (): LocalCommandProbe => ({ + if (result.termination === "timeout") { + return { + command, + found: true, + error: `timed out after ${timeoutMs}ms`, + }; + } + // Version output can arrive on stdout or stderr depending on the CLI. + const text = `${result.stdout}\n${result.stderr}`.trim().split(/\r?\n/)[0]?.trim(); + return { command, - found: true, - error: `timed out after ${timeoutMs}ms`, - }); - const finish = (result: LocalCommandProbe) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - if (killTimer) { - clearTimeout(killTimer); - } - resolve(result); + found: result.code === 0 || Boolean(text), + version: text || undefined, + error: result.code === 0 ? undefined : `exited ${String(result.code)}`, }; - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - // Some CLIs ignore SIGTERM; destroy pipes after a short grace window to finish promptly. - killTimer = setTimeout(() => { - child.kill("SIGKILL"); - child.stdout.destroy(); - child.stderr.destroy(); - finish(timeoutResult()); - }, timeoutKillGraceMs); - killTimer.unref?.(); - }, timeoutMs); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout = appendBounded(stdout, String(chunk), outputLimit); - }); - child.stdout.on("error", ignoreOutputStreamError); - child.stderr.on("data", (chunk) => { - stderr = appendBounded(stderr, String(chunk), outputLimit); - }); - child.stderr.on("error", ignoreOutputStreamError); - child.on("error", (err: NodeJS.ErrnoException) => { - finish({ - command, - found: err.code !== "ENOENT", - error: err.code === "ENOENT" ? "not found" : err.message, - }); - }); - child.on("close", (code) => { - if (timedOut) { - finish(timeoutResult()); - return; - } - // Version output can arrive on stdout or stderr depending on the CLI. - const text = `${stdout}\n${stderr}`.trim().split(/\r?\n/)[0]?.trim(); - finish({ - command, - found: code === 0 || Boolean(text), - version: text || undefined, - error: code === 0 ? undefined : `exited ${String(code)}`, - }); - }); - }); + } catch (error) { + const spawnError = error as NodeJS.ErrnoException; + return { + command, + found: spawnError.code !== "ENOENT", + error: spawnError.code === "ENOENT" ? "not found" : spawnError.message, + }; + } } /** Probe a Gateway URL by translating it to its HTTP /healthz endpoint. */ diff --git a/src/daemon/exec-file.ts b/src/daemon/exec-file.ts index 16b6ebd3920c..34b1535d9b82 100644 --- a/src/daemon/exec-file.ts +++ b/src/daemon/exec-file.ts @@ -1,5 +1,5 @@ /** Child-process wrapper used by daemon installers to preserve stdout/stderr on failure. */ -import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; +import { runCommandWithTimeout } from "../process/exec.js"; type ExecResult = { stdout: string; stderr: string; code: number }; @@ -7,28 +7,29 @@ type ExecResult = { stdout: string; stderr: string; code: number }; export async function execFileUtf8( command: string, args: string[], - options: Omit = {}, + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + killSignal?: NodeJS.Signals | number; + windowsHide?: boolean; + } = {}, ): Promise { - return await new Promise((resolve) => { - execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { - if (!error) { - resolve({ - stdout: stdout ?? "", - stderr: stderr ?? "", - code: 0, - }); - return; - } - - const e = error as { code?: unknown; message?: unknown }; - const stderrText = stderr ?? ""; - resolve({ - stdout: stdout ?? "", - stderr: - stderrText || - (typeof e.message === "string" ? e.message : typeof error === "string" ? error : ""), - code: typeof e.code === "number" ? e.code : 1, - }); + try { + const result = await runCommandWithTimeout([command, ...args], { + baseEnv: options.env, + cwd: options.cwd, + killSignal: options.killSignal, + maxOutputBytes: 1024 * 1024, + timeoutMs: options.timeout, }); - }); + return { + stdout: result.stdout, + stderr: result.stderr, + code: result.code ?? 1, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { stdout: "", stderr: message, code: 1 }; + } } diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index 4ad956e22d80..f2df0e736797 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -1,13 +1,12 @@ /** Selects stable Node runtime paths for daemon installs across platforms. */ -import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { isSupportedNodeVersion } from "../infra/runtime-guard.js"; import { isSqliteWalResetSafeVersion } from "../infra/sqlite-runtime-version.js"; import { resolveStableNodePath } from "../infra/stable-node-path.js"; import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js"; +import { runExec } from "../process/exec.js"; const VERSION_MANAGER_MARKERS = [ "/.nvm/", @@ -79,7 +78,8 @@ type ExecFileAsync = ( options: { encoding: "utf8" }, ) => Promise<{ stdout: string; stderr: string }>; -const execFileAsync = promisify(execFile) as unknown as ExecFileAsync; +const execFileAsync: ExecFileAsync = async (file, args) => + await runExec(file, [...args], { logOutput: false }); const NODE_RUNTIME_PROBE = String.raw` let sqliteVersion = null; diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index 282159cf35e6..a3d369ebf3a1 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -1,15 +1,13 @@ // Gateway live agent probe helpers. // Builds prompts and verification helpers for live image and cron probe tests. -import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { promisify } from "node:util"; import { resolveExpiresAtMsFromDurationSeconds, resolveTimestampMsToIsoString, } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { runExec } from "../process/exec.js"; -const execFileAsync = promisify(execFile); const LIVE_CRON_PROBE_DELAY_SECONDS = 7 * 24 * 60 * 60; const OPENCLAW_CLI_GATEWAY_TIMEOUT_MS = 30_000; const OPENCLAW_CLI_CHILD_TIMEOUT_MS = OPENCLAW_CLI_GATEWAY_TIMEOUT_MS + 45_000; @@ -149,11 +147,12 @@ export async function runOpenClawCliJson(args: string[], env: NodeJS.ProcessE const cliArgs = args.includes("--timeout") ? args : [...args, "--timeout", String(OPENCLAW_CLI_GATEWAY_TIMEOUT_MS)]; - const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...cliArgs], { + const { stdout, stderr } = await runExec(process.execPath, ["openclaw.mjs", ...cliArgs], { + baseEnv: childEnv, cwd: process.cwd(), - env: childEnv, - timeout: OPENCLAW_CLI_CHILD_TIMEOUT_MS, + logOutput: false, maxBuffer: 1024 * 1024, + timeoutMs: OPENCLAW_CLI_CHILD_TIMEOUT_MS, }); const trimmed = stdout.trim(); if (!trimmed) { diff --git a/src/gateway/node-pairing-ssh-verify.runtime.ts b/src/gateway/node-pairing-ssh-verify.runtime.ts index c742ff06f671..26c65e5cb98d 100644 --- a/src/gateway/node-pairing-ssh-verify.runtime.ts +++ b/src/gateway/node-pairing-ssh-verify.runtime.ts @@ -1,7 +1,7 @@ // SSH probe execution for SSH-verified node pairing. -// Kept as a narrow runtime boundary so gateway tests can mock the spawn +// Kept as a narrow runtime boundary so gateway tests can mock the probe // without touching the eligibility/verification policy. -import { spawn } from "node:child_process"; +import { runCommandWithTimeout } from "../process/exec.js"; export type NodeIdentityProbeParams = { user: string; @@ -67,59 +67,25 @@ export async function runNodeIdentityProbe( // Security: '--' prevents the user@host target from being read as an option. args.push("--", `${params.user}@${params.host}`, REMOTE_IDENTITY_COMMAND); - return await new Promise((resolve) => { - let settled = false; - const settle = (result: NodeIdentityProbeResult) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve(result); - }; - + try { // PATH-resolved `ssh` keeps Windows OpenSSH working; the gateway process // environment is operator-owned, so PATH lookup is not an injection risk. - const child = spawn("ssh", args, { stdio: ["ignore", "pipe", "pipe"] }); - const timer = setTimeout( - () => { - try { - child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 1_500).unref(); - } catch { - // Best-effort teardown; the probe already reports timeout. - } - settle({ status: "timeout" }); - }, - Math.max(250, params.timeoutMs), - ); - timer.unref?.(); - - let stdout = ""; - let stderr = ""; - const append = (current: string, chunk: unknown): string => - current.length >= MAX_PROBE_OUTPUT_BYTES - ? current - : (current + String(chunk)).slice(0, MAX_PROBE_OUTPUT_BYTES); - child.stdout?.setEncoding("utf8"); - child.stderr?.setEncoding("utf8"); - child.stdout?.on("data", (chunk) => { - stdout = append(stdout, chunk); + const result = await runCommandWithTimeout(["ssh", ...args], { + maxOutputBytes: MAX_PROBE_OUTPUT_BYTES, + outputCapture: "head", + timeoutMs: Math.max(250, params.timeoutMs), }); - child.stderr?.on("data", (chunk) => { - stderr = append(stderr, chunk); - }); - child.stdout?.on("error", () => {}); - child.stderr?.on("error", () => {}); - child.once("error", (error) => { - settle({ status: "spawn-error", message: error.message }); - }); - child.once("close", (code) => { - if (code === 0) { - settle({ status: "ok", stdout }); - } else { - settle({ status: "failed", code, stderr }); - } - }); - }); + if (result.termination === "timeout") { + return { status: "timeout" }; + } + if (result.code === 0) { + return { status: "ok", stdout: result.stdout }; + } + return { status: "failed", code: result.code, stderr: result.stderr }; + } catch (error) { + return { + status: "spawn-error", + message: error instanceof Error ? error.message : String(error), + }; + } } diff --git a/src/gateway/server-methods/config.test.ts b/src/gateway/server-methods/config.test.ts index 8917ae9f0888..60285376c855 100644 --- a/src/gateway/server-methods/config.test.ts +++ b/src/gateway/server-methods/config.test.ts @@ -13,8 +13,8 @@ import { } from "./config.js"; import { createConfigHandlerHarness } from "./config.test-helpers.js"; -const { execFileMock, loadGatewayRuntimeConfigSchemaMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), +const { runExecMock, loadGatewayRuntimeConfigSchemaMock } = vi.hoisted(() => ({ + runExecMock: vi.fn(), loadGatewayRuntimeConfigSchemaMock: vi.fn(() => ({ schema: { type: "object" }, uiHints: undefined, @@ -22,32 +22,14 @@ const { execFileMock, loadGatewayRuntimeConfigSchemaMock } = vi.hoisted(() => ({ })), })); -vi.mock("node:child_process", async () => { - const { mockNodeChildProcessModule } = await import("./node-child-process.test-support.js"); - return mockNodeChildProcessModule({ - execFile: Object.assign(execFileMock, { - __promisify__: vi.fn(), - }) as typeof import("node:child_process").execFile, - }); -}); +vi.mock("../../process/exec.js", () => ({ runExec: runExecMock })); vi.mock("../../config/runtime-schema.js", () => ({ loadGatewayRuntimeConfigSchema: loadGatewayRuntimeConfigSchemaMock, })); -function invokeExecFileCallback(args: unknown[], error: Error | null) { - const callback = args.at(-1); - if (typeof callback !== "function") { - throw new Error("expected execFile callback"); - } - callback(error); -} - -function mockExecFileError(error: Error) { - execFileMock.mockImplementation((...args: unknown[]) => { - invokeExecFileCallback(args, error); - return {} as never; - }); +function mockRunExecError(error: Error) { + runExecMock.mockRejectedValue(error); } async function invokeConfigOpenFile() { @@ -96,11 +78,10 @@ describe("resolveConfigOpenCommand", () => { describe("config.openFile", () => { it("opens the configured file without shell interpolation", async () => { await withEnvAsync({ OPENCLAW_CONFIG_PATH: "/tmp/config $(touch pwned).json" }, async () => { - execFileMock.mockImplementation((...args: unknown[]) => { - expect(["open", "xdg-open", "powershell.exe"]).toContain(args[0]); - expect(args[1]).toEqual(["/tmp/config $(touch pwned).json"]); - invokeExecFileCallback(args, null); - return {} as never; + runExecMock.mockImplementation(async (command: string, args: string[]) => { + expect(["open", "xdg-open", "powershell.exe"]).toContain(command); + expect(args).toEqual(["/tmp/config $(touch pwned).json"]); + return { stdout: "", stderr: "" }; }); const { respond } = await invokeConfigOpenFile(); @@ -118,7 +99,7 @@ describe("config.openFile", () => { it("returns a detailed error and logs details when the opener fails", async () => { await withEnvAsync({ OPENCLAW_CONFIG_PATH: "/tmp/config.json" }, async () => { - mockExecFileError(Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT" })); + mockRunExecError(Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT" })); const { respond, logGateway } = await invokeConfigOpenFile(); @@ -140,7 +121,7 @@ describe("config.openFile", () => { it("does not split surrogate pairs when truncating the failed config path", async () => { const pathPrefix = `/tmp/${"a".repeat(111)}`; await withEnvAsync({ OPENCLAW_CONFIG_PATH: `${pathPrefix}😀tail.json` }, async () => { - mockExecFileError(new Error("open failed")); + mockRunExecError(new Error("open failed")); const { logGateway } = await invokeConfigOpenFile(); @@ -152,7 +133,7 @@ describe("config.openFile", () => { it("returns actionable headless environment error when xdg-open reports no method available", async () => { await withEnvAsync({ OPENCLAW_CONFIG_PATH: "/tmp/config.json" }, async () => { - mockExecFileError(new Error("xdg-open: no method available for opening '/tmp/config.json'")); + mockRunExecError(new Error("xdg-open: no method available for opening '/tmp/config.json'")); const { respond, logGateway } = await invokeConfigOpenFile(); diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index d970235376ca..f930e0b2dc67 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,6 +1,5 @@ // Config gateway methods expose config get/set/patch/apply/schema operations // with validation, redaction restoration, secret prep, and reload planning. -import { execFile } from "node:child_process"; import { isDeepStrictEqual } from "node:util"; import { asDateTimestampMs, @@ -45,8 +44,9 @@ import { validateConfigObjectWithPlugins, } from "../../config/validation.js"; import { isBuiltInModelProviderOverlayId } from "../../config/zod-schema.core.js"; -import { formatErrorMessage, toErrorObject } from "../../infra/errors.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { isPlainObject } from "../../infra/plain-object.js"; +import { runExec } from "../../process/exec.js"; import { prepareSecretsRuntimeSnapshot, type PreparedSecretsRuntimeSnapshot, @@ -386,16 +386,8 @@ export function resolveConfigOpenCommand( }; } -function execConfigOpenCommand(command: ConfigOpenCommand): Promise { - return new Promise((resolve, reject) => { - execFile(command.command, command.args, (error) => { - if (error) { - reject(toErrorObject(error, "Non-Error rejection")); - return; - } - resolve(); - }); - }); +async function execConfigOpenCommand(command: ConfigOpenCommand): Promise { + await runExec(command.command, command.args, { logOutput: false }); } function formatConfigOpenError(error: unknown): string { diff --git a/src/gateway/worker-environments/bootstrap.test.ts b/src/gateway/worker-environments/bootstrap.test.ts index 7fcc9c36e810..fd503909554f 100644 --- a/src/gateway/worker-environments/bootstrap.test.ts +++ b/src/gateway/worker-environments/bootstrap.test.ts @@ -434,7 +434,8 @@ describe("bootstrapWorker", () => { await fs.copyFile(artifact.tarballPath, remoteTarball); return result(); } - const isPreflight = options.input?.includes("expected_receipt=$2") ?? false; + const isPreflight = + typeof options.input === "string" && options.input.includes("expected_receipt=$2"); const scriptArgs = isPreflight ? [artifact.bundleHash, receiptJson, "bundle"] : [ diff --git a/src/gateway/worker-environments/tunnel.test.ts b/src/gateway/worker-environments/tunnel.test.ts index 7b91ec9fe520..683865628dc4 100644 --- a/src/gateway/worker-environments/tunnel.test.ts +++ b/src/gateway/worker-environments/tunnel.test.ts @@ -134,7 +134,10 @@ function localWorkspaceRunner(remoteHome: string) { return await runCommandWithTimeout(localArgv, options); } if (argv[0] === "ssh") { - if (options.input?.includes("unsafe worker tunnel directory")) { + if ( + typeof options.input === "string" && + options.input.includes("unsafe worker tunnel directory") + ) { return success(); } const remoteCommand = argv.at(-1); @@ -228,7 +231,10 @@ describe("worker tunnel manager", () => { if (argv.includes("--verify")) { return success(`${commit}\n`); } - if (options.input?.includes("unsafe worker workspace directory")) { + if ( + typeof options.input === "string" && + options.input.includes("unsafe worker workspace directory") + ) { return success(`${remoteWorkspaceDir}\n`); } if (argv.at(-1)?.includes("worker workspace symlink escapes")) { @@ -286,7 +292,10 @@ describe("worker tunnel manager", () => { if (argv[0] === "rsync") { return { ...success("", "transfer denied"), code: 23 }; } - if (options.input?.includes("unsafe worker workspace directory")) { + if ( + typeof options.input === "string" && + options.input.includes("unsafe worker workspace directory") + ) { return success(`${remoteWorkspaceDir}\n`); } return undefined; diff --git a/src/infra/machine-name.ts b/src/infra/machine-name.ts index c01cfbb00d4c..d93453316e56 100644 --- a/src/infra/machine-name.ts +++ b/src/infra/machine-name.ts @@ -1,10 +1,7 @@ // Resolves a human-readable machine name for gateway display. -import { execFile } from "node:child_process"; import os from "node:os"; -import { promisify } from "node:util"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; - -const execFileAsync = promisify(execFile); +import { runExec } from "../process/exec.js"; // Machine display names prefer macOS ComputerName when available and fall back // to hostname for deterministic tests and non-macOS hosts. @@ -12,11 +9,11 @@ let cachedPromise: Promise | null = null; async function tryScutil(key: "ComputerName" | "LocalHostName") { try { - const { stdout } = await execFileAsync("/usr/sbin/scutil", ["--get", key], { - timeout: 1000, - windowsHide: true, + const { stdout } = await runExec("/usr/sbin/scutil", ["--get", key], { + logOutput: false, + timeoutMs: 1000, }); - const value = normalizeOptionalString(stdout ?? "") ?? ""; + const value = normalizeOptionalString(stdout) ?? ""; return value.length > 0 ? value : null; } catch { return null; diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index 79d365c2803c..3e705051b7ad 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -1,105 +1,83 @@ -// Tests SSH config parsing and spawned command options. -import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +// Tests SSH config parsing and canonical command execution. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { + parseSshConfigOutput, + resolveSshConfig, + SSH_CONFIG_OUTPUT_MAX_CHARS, +} from "./ssh-config.js"; -type MockSpawnChild = EventEmitter & { - stdout?: EventEmitter & { setEncoding?: (enc: string) => void }; - kill?: (signal?: string) => void; -}; +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: vi.fn(), +})); -function createMockSpawnChild() { - const child = new EventEmitter() as MockSpawnChild; - const stdout = new EventEmitter() as NonNullable; - stdout.setEncoding = vi.fn(); - child.stdout = stdout; - child.kill = vi.fn(); - return { child, stdout }; +const runCommandMock = vi.mocked(runCommandWithTimeout); +const sshOutput = [ + "user steipete", + "hostname peters-mac-studio-1.sheep-coho.ts.net", + "port 2222", + "identityfile none", + "identityfile /tmp/id_ed25519", + "", +].join("\n"); + +function commandResult( + overrides: Partial>> = {}, +): Awaited> { + return { + stdout: sshOutput, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + ...overrides, + }; } -vi.mock("node:child_process", async () => { - const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks"); - const spawnLocal = vi.fn(() => { - const { child, stdout } = createMockSpawnChild(); - process.nextTick(() => { - stdout?.emit( - "data", - [ - "user steipete", - "hostname peters-mac-studio-1.sheep-coho.ts.net", - "port 2222", - "identityfile none", - "identityfile /tmp/id_ed25519", - "", - ].join("\n"), - ); - child.emit("exit", 0); - }); - return child; - }); - return mockNodeBuiltinModule( - () => vi.importActual("node:child_process"), - { - spawn: spawnLocal as unknown as typeof import("node:child_process").spawn, - }, - ); -}); - -const spawnMock = vi.mocked(spawn); - -function requireSpawnArgs(index: number): string[] { - const args = spawnMock.mock.calls[index]?.[1] as string[] | undefined; - if (!args) { - throw new Error("expected ssh spawn args"); - } - return args; -} - -let parseSshConfigOutput: typeof import("./ssh-config.js").parseSshConfigOutput; -let resolveSshConfig: typeof import("./ssh-config.js").resolveSshConfig; -let appendSshConfigOutput: typeof import("./ssh-config.js").appendSshConfigOutput; -let sshConfigOutputMaxChars: number; - describe("ssh-config", () => { - beforeAll(async () => { - const sshConfig = await import("./ssh-config.js"); - ({ appendSshConfigOutput, parseSshConfigOutput, resolveSshConfig } = sshConfig); - sshConfigOutputMaxChars = sshConfig.SSH_CONFIG_OUTPUT_MAX_CHARS; + beforeEach(() => { + runCommandMock.mockReset(); + runCommandMock.mockResolvedValue(commandResult()); }); it("parses ssh -G output", () => { const parsed = parseSshConfigOutput( "user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n", ); - expect(parsed.user).toBe("bob"); - expect(parsed.host).toBe("example.com"); - expect(parsed.port).toBe(2222); - expect(parsed.identityFiles).toEqual(["/tmp/id"]); + expect(parsed).toEqual({ + user: "bob", + host: "example.com", + port: 2222, + identityFiles: ["/tmp/id"], + }); }); - it("ignores invalid ports and blank lines in ssh -G output", () => { + it("ignores invalid ports and blank lines", () => { const parsed = parseSshConfigOutput( "user bob\nhostname example.com\nport not-a-number\nidentityfile none\nidentityfile \n", ); - - expect(parsed.user).toBe("bob"); - expect(parsed.host).toBe("example.com"); expect(parsed.port).toBeUndefined(); expect(parsed.identityFiles).toStrictEqual([]); - }); - - it("ignores partial and out-of-range ssh -G ports", () => { expect(parseSshConfigOutput("hostname example.com\nport 2222abc\n").port).toBeUndefined(); expect(parseSshConfigOutput("hostname example.com\nport 70000\n").port).toBeUndefined(); }); - it("resolves ssh config via ssh -G", async () => { - const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 }); - expect(config?.user).toBe("steipete"); - expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net"); - expect(config?.port).toBe(2222); - expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]); - expect(requireSpawnArgs(0).slice(-2)).toEqual(["--", "me@alias"]); + it("resolves ssh config through the canonical command wrapper", async () => { + await expect(resolveSshConfig({ user: "me", host: "alias", port: 22 })).resolves.toEqual({ + user: "steipete", + host: "peters-mac-studio-1.sheep-coho.ts.net", + port: 2222, + identityFiles: ["/tmp/id_ed25519"], + }); + expect(runCommandMock).toHaveBeenCalledWith( + ["/usr/bin/ssh", "-G", "--", "me@alias"], + expect.objectContaining({ + maxOutputBytes: SSH_CONFIG_OUTPUT_MAX_CHARS, + outputCapture: "head", + terminateOnOutputLimit: true, + }), + ); }); it("adds non-default port and trimmed identity arguments", async () => { @@ -107,87 +85,30 @@ describe("ssh-config", () => { { user: "me", host: "alias", port: 2022 }, { identity: " /tmp/custom_id " }, ); - - const args = requireSpawnArgs(spawnMock.mock.calls.length - 1); - expect(args).toEqual(["-G", "-p", "2022", "-i", "/tmp/custom_id", "--", "me@alias"]); - }); - - it("returns null when ssh -G fails", async () => { - spawnMock.mockImplementationOnce( - (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { - const { child } = createMockSpawnChild(); - process.nextTick(() => { - child.emit("exit", 1); - }); - return child as unknown as ChildProcess; - }, - ); - - const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 }); - expect(config).toBeNull(); - }); - - it("returns null when the ssh process emits an error", async () => { - spawnMock.mockImplementationOnce( - (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { - const { child } = createMockSpawnChild(); - process.nextTick(() => { - child.emit("error", new Error("spawn boom")); - }); - return child as unknown as ChildProcess; - }, - ); - - await expect(resolveSshConfig({ user: "me", host: "bad-host", port: 22 })).resolves.toBeNull(); + expect(runCommandMock.mock.calls[0]?.[0]).toEqual([ + "/usr/bin/ssh", + "-G", + "-p", + "2022", + "-i", + "/tmp/custom_id", + "--", + "me@alias", + ]); }); it.each([ - { - name: "stdout emits an error", - emit: (stdout: EventEmitter) => stdout.emit("error", new Error("stdout boom")), - }, - { - name: "stdout exceeds the output limit", - emit: (stdout: EventEmitter) => stdout.emit("data", "x".repeat(sshConfigOutputMaxChars + 1)), - }, - ])("returns null and terminates ssh when $name", async ({ emit }) => { - let capturedChild: MockSpawnChild | undefined; - spawnMock.mockImplementationOnce( - (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { - const { child, stdout } = createMockSpawnChild(); - capturedChild = child; - process.nextTick(() => emit(stdout)); - return child as unknown as ChildProcess; - }, - ); - - await expect(resolveSshConfig({ user: "me", host: "bad-host", port: 22 })).resolves.toBeNull(); - expect(capturedChild?.kill).toHaveBeenCalledWith("SIGKILL"); - }); - - it("returns null when terminating ssh throws", async () => { - spawnMock.mockImplementationOnce( - (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { - const { child, stdout } = createMockSpawnChild(); - child.kill = vi.fn(() => { - throw new Error("kill failed"); - }); - process.nextTick(() => stdout?.emit("error", new Error("stdout boom"))); - return child as unknown as ChildProcess; - }, - ); - + commandResult({ code: 1 }), + commandResult({ termination: "timeout", code: 124 }), + commandResult({ outputLimitExceeded: true, termination: "signal", code: null }), + commandResult({ stdout: "" }), + ])("returns null for an unusable command result", async (result) => { + runCommandMock.mockResolvedValueOnce(result); await expect(resolveSshConfig({ user: "me", host: "bad-host", port: 22 })).resolves.toBeNull(); }); - it("rejects oversized ssh -G output while preserving the parser contract", () => { - expect(appendSshConfigOutput("user bob", "\nhostname example.com", 128)).toEqual({ - ok: true, - value: "user bob\nhostname example.com", - }); - expect(appendSshConfigOutput("x".repeat(8), "y".repeat(8), 12)).toEqual({ - ok: false, - reason: "too-large", - }); + it("returns null when command launch fails", async () => { + runCommandMock.mockRejectedValueOnce(new Error("spawn boom")); + await expect(resolveSshConfig({ user: "me", host: "bad-host", port: 22 })).resolves.toBeNull(); }); }); diff --git a/src/infra/ssh-config.ts b/src/infra/ssh-config.ts index 9b7d165edd6f..dc0ed38df9e4 100644 --- a/src/infra/ssh-config.ts +++ b/src/infra/ssh-config.ts @@ -1,5 +1,5 @@ // Reads effective SSH target config from the local ssh client. -import { spawn } from "node:child_process"; +import { runCommandWithTimeout } from "../process/exec.js"; import { parseStrictPositiveInteger } from "./parse-finite-number.js"; import type { SshParsedTarget } from "./ssh-tunnel.js"; @@ -12,8 +12,6 @@ export type SshResolvedConfig = { identityFiles: string[]; }; -type AppendSshConfigOutputResult = { ok: true; value: string } | { ok: false; reason: "too-large" }; - function parsePort(value: string | undefined): number | undefined { if (!value) { return undefined; @@ -60,18 +58,6 @@ export function parseSshConfigOutput(output: string): SshResolvedConfig { return result; } -export function appendSshConfigOutput( - current: string, - chunk: unknown, - maxChars = SSH_CONFIG_OUTPUT_MAX_CHARS, -): AppendSshConfigOutputResult { - const next = current + String(chunk); - if (next.length > maxChars) { - return { ok: false, reason: "too-large" }; - } - return { ok: true, value: next }; -} - export async function resolveSshConfig( target: SshParsedTarget, opts: { identity?: string; timeoutMs?: number } = {}, @@ -88,48 +74,18 @@ export async function resolveSshConfig( // Use "--" so userHost can't be parsed as an ssh option. args.push("--", userHost); - return await new Promise((resolve) => { - const child = spawn(sshPath, args, { - stdio: ["ignore", "pipe", "ignore"], + try { + const result = await runCommandWithTimeout([sshPath, ...args], { + maxOutputBytes: SSH_CONFIG_OUTPUT_MAX_CHARS, + outputCapture: "head", + terminateOnOutputLimit: true, + timeoutMs: Math.max(200, opts.timeoutMs ?? 800), }); - let stdout = ""; - let settled = false; - const settle = (result: SshResolvedConfig | null, options?: { terminate?: boolean }) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - if (options?.terminate) { - try { - child.kill("SIGKILL"); - } catch { - // A failed best-effort kill must not strand gateway discovery. - } - } - resolve(result); - }; - - const timeoutMs = Math.max(200, opts.timeoutMs ?? 800); - const timer = setTimeout(() => settle(null, { terminate: true }), timeoutMs); - - child.stdout?.setEncoding("utf8"); - child.stdout?.on("data", (chunk) => { - const appended = appendSshConfigOutput(stdout, chunk); - if (!appended.ok) { - settle(null, { terminate: true }); - return; - } - stdout = appended.value; - }); - child.stdout?.on("error", () => settle(null, { terminate: true })); - child.once("error", () => settle(null)); - child.once("exit", (code) => { - if (code !== 0 || !stdout.trim()) { - settle(null); - return; - } - settle(parseSshConfigOutput(stdout)); - }); - }); + if (result.code !== 0 || result.termination !== "exit" || !result.stdout.trim()) { + return null; + } + return parseSshConfigOutput(result.stdout); + } catch { + return null; + } } diff --git a/src/infra/tls/gateway.ts b/src/infra/tls/gateway.ts index 8aff06b44ef1..14e03cf2f09e 100644 --- a/src/infra/tls/gateway.ts +++ b/src/infra/tls/gateway.ts @@ -1,19 +1,16 @@ // Gateway TLS runtime loads configured certificates or generates a local // self-signed pair, returning server-ready options plus client fingerprint. -import { execFile } from "node:child_process"; import { X509Certificate } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import tls from "node:tls"; -import { promisify } from "node:util"; import type { GatewayTlsConfig } from "../../config/types.gateway.js"; +import { runExec } from "../../process/exec.js"; import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js"; import { pathExists } from "../fs-safe.js"; import { resolveSystemBin } from "../resolve-system-bin.js"; import { normalizeFingerprint } from "./fingerprint.js"; -const execFileAsync = promisify(execFile); - // Gateway TLS runtime carries loaded cert material plus the normalized SHA-256 // fingerprint advertised to clients. export type GatewayTlsRuntime = { @@ -44,24 +41,28 @@ async function generateSelfSignedCert(params: { "openssl not found in trusted system directories. Install it in an OS-managed location.", ); } - // Use execFile with a trusted system binary; certificate paths are arguments, + // Use argv execution with a trusted system binary; certificate paths are arguments, // not shell text. - await execFileAsync(opensslBin, [ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-sha256", - "-days", - "3650", - "-nodes", - "-keyout", - params.keyPath, - "-out", - params.certPath, - "-subj", - "/CN=openclaw-gateway", - ]); + await runExec( + opensslBin, + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-sha256", + "-days", + "3650", + "-nodes", + "-keyout", + params.keyPath, + "-out", + params.certPath, + "-subj", + "/CN=openclaw-gateway", + ], + { logOutput: false }, + ); await fs.chmod(params.keyPath, 0o600).catch(() => {}); await fs.chmod(params.certPath, 0o600).catch(() => {}); params.log?.info?.( diff --git a/src/media/audio-transcode.ts b/src/media/audio-transcode.ts index e95ff8962e0a..f7b1dd3023fe 100644 --- a/src/media/audio-transcode.ts +++ b/src/media/audio-transcode.ts @@ -1,10 +1,10 @@ // Audio transcode helpers run ffmpeg to convert audio for provider requirements. -import { spawn } from "node:child_process"; import path from "node:path"; import { basenameFromAnyPath } from "@openclaw/media-core/file-name"; import { writeExternalFileWithinRoot } from "../infra/fs-safe.js"; import { tempWorkspaceSync, withTempWorkspace } from "../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { runFfmpeg } from "./ffmpeg-exec.js"; const DEFAULT_OPUS_SAMPLE_RATE_HZ = 48_000; @@ -175,23 +175,22 @@ function pickAfconvertRecipe(_source: string, target: string): string[] | undefi return undefined; } -function runAfconvert(params: { +async function runAfconvert(params: { args: string[]; timeoutMs: number; }): Promise<{ ok: true } | { ok: false; detail: string }> { - return new Promise((resolve) => { - const child = spawn("/usr/bin/afconvert", params.args, { stdio: "ignore" }); - const timer = setTimeout(() => { - child.kill("SIGKILL"); - resolve({ ok: false, detail: `timeout-${params.timeoutMs}ms` }); - }, params.timeoutMs); - child.once("error", (err) => { - clearTimeout(timer); - resolve({ ok: false, detail: err.message }); + try { + const result = await runCommandWithTimeout(["/usr/bin/afconvert", ...params.args], { + maxOutputBytes: 1024, + timeoutMs: params.timeoutMs, }); - child.once("exit", (code) => { - clearTimeout(timer); - resolve(code === 0 ? { ok: true } : { ok: false, detail: `exit-${code ?? "unknown"}` }); - }); - }); + if (result.termination === "timeout") { + return { ok: false, detail: `timeout-${params.timeoutMs}ms` }; + } + return result.code === 0 + ? { ok: true } + : { ok: false, detail: `exit-${result.code ?? "unknown"}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } } diff --git a/src/media/ffmpeg-exec.test.ts b/src/media/ffmpeg-exec.test.ts index 1ede3bd54751..1226d2abe486 100644 --- a/src/media/ffmpeg-exec.test.ts +++ b/src/media/ffmpeg-exec.test.ts @@ -1,7 +1,4 @@ // FFmpeg exec tests cover command execution wrappers and error mapping. -import type { ChildProcess, ExecFileOptions } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { parseFfprobeCodecAndSampleRate, @@ -10,54 +7,21 @@ import { runFfprobe, } from "./ffmpeg-exec.js"; -const { execFileMock, resolveSystemBinMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), +const { runExecMock, resolveSystemBinMock } = vi.hoisted(() => ({ + runExecMock: vi.fn(), resolveSystemBinMock: vi.fn(), })); -vi.mock("node:child_process", async (importOriginal) => ({ - ...(await importOriginal()), - execFile: execFileMock, +vi.mock("../process/exec.js", () => ({ + runExec: runExecMock, })); vi.mock("../infra/resolve-system-bin.js", () => ({ resolveSystemBin: resolveSystemBinMock, })); -type ExecFileCallback = ( - error: Error | null, - stdout: string | Buffer, - stderr: string | Buffer, -) => void; - -function createExecFileChild(): ChildProcess { - const child = new EventEmitter() as ChildProcess; - child.stdin = new PassThrough() as ChildProcess["stdin"]; - return child; -} - -function mockFfprobeExecFile(child: ChildProcess): { - execCallback: () => ExecFileCallback; -} { - let execCallback: ExecFileCallback | undefined; - execFileMock.mockImplementationOnce( - (_file: string, _args: string[], _options: ExecFileOptions, callback: ExecFileCallback) => { - execCallback = callback; - return child; - }, - ); - return { - execCallback: () => { - if (!execCallback) { - throw new Error("execFile callback was not captured"); - } - return execCallback; - }, - }; -} - beforeEach(() => { - execFileMock.mockReset(); + runExecMock.mockReset(); resolveSystemBinMock.mockReset(); resolveSystemBinMock.mockReturnValue("/usr/bin/ffprobe"); }); @@ -138,31 +102,27 @@ describe("parseFfprobeCodecAndSampleRate", () => { }); describe("runFfprobe", () => { - it("handles stdin EPIPE without overriding successful ffprobe stdout", async () => { - const child = createExecFileChild(); - const { execCallback } = mockFfprobeExecFile(child); + it("passes stdin and limits through the canonical exec wrapper", async () => { + const input = Buffer.from("audio"); + runExecMock.mockResolvedValue({ stdout: "ok", stderr: "" }); - const promise = runFfprobe(["pipe:0"], { input: Buffer.alloc(1024) }); + await expect( + runFfprobe(["pipe:0"], { input, timeoutMs: 1234, maxBufferBytes: 5678 }), + ).resolves.toBe("ok"); - const stdinError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); - child.stdin?.emit("error", stdinError); - execCallback()(null, Buffer.from("ok"), Buffer.alloc(0)); - - await expect(promise).resolves.toBe("ok"); + expect(runExecMock).toHaveBeenCalledWith("/usr/bin/ffprobe", ["pipe:0"], { + input, + logOutput: false, + maxBuffer: 5678, + timeoutMs: 1234, + }); }); - it("preserves the child callback error after stdin EPIPE", async () => { - const child = createExecFileChild(); - const { execCallback } = mockFfprobeExecFile(child); - - const promise = runFfprobe(["pipe:0"], { input: Buffer.alloc(1024) }); - - const stdinError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); - child.stdin?.emit("error", stdinError); + it("preserves wrapper execution errors", async () => { const childError = new Error("ffprobe failed"); - execCallback()(childError, "", ""); + runExecMock.mockRejectedValue(childError); - await expect(promise).rejects.toBe(childError); + await expect(runFfprobe(["pipe:0"], { input: Buffer.from("audio") })).rejects.toBe(childError); }); }); diff --git a/src/media/ffmpeg-exec.ts b/src/media/ffmpeg-exec.ts index b2dbb3f09161..6dbb56ce732f 100644 --- a/src/media/ffmpeg-exec.ts +++ b/src/media/ffmpeg-exec.ts @@ -1,17 +1,13 @@ // FFmpeg exec helpers run ffmpeg and ffprobe with normalized errors. -import { execFile, type ExecFileOptions } from "node:child_process"; -import { promisify } from "node:util"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { toErrorObject } from "../infra/errors.js"; import { resolveSystemBin } from "../infra/resolve-system-bin.js"; +import { runExec, type RunExecOptions } from "../process/exec.js"; import { MEDIA_FFMPEG_MAX_BUFFER_BYTES, MEDIA_FFMPEG_TIMEOUT_MS, MEDIA_FFPROBE_TIMEOUT_MS, } from "./ffmpeg-limits.js"; -const execFileAsync = promisify(execFile); - /** Process limits and optional stdin payload for ffmpeg/ffprobe helper calls. */ export type MediaExecOptions = { timeoutMs?: number; @@ -22,10 +18,12 @@ export type MediaExecOptions = { function resolveExecOptions( defaultTimeoutMs: number, options: MediaExecOptions | undefined, -): ExecFileOptions { +): RunExecOptions { return { - timeout: options?.timeoutMs ?? defaultTimeoutMs, + input: options?.input, + logOutput: false, maxBuffer: options?.maxBufferBytes ?? MEDIA_FFMPEG_MAX_BUFFER_BYTES, + timeoutMs: options?.timeoutMs ?? defaultTimeoutMs, }; } @@ -49,46 +47,24 @@ export function resolveFfmpegBin(): string { return requireSystemBin("ffmpeg"); } -function isBrokenPipeError(error: Error): boolean { - return (error as NodeJS.ErrnoException).code === "EPIPE"; -} - -/** Runs ffprobe with optional stdin input, ignoring benign stdin EPIPE after successful output. */ +/** Runs ffprobe with optional stdin input. */ export async function runFfprobe(args: string[], options?: MediaExecOptions): Promise { - const execOptions = resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options); - if (options?.input == null) { - const { stdout } = await execFileAsync(requireSystemBin("ffprobe"), args, execOptions); - return stdout.toString(); - } - - return await new Promise((resolve, reject) => { - let stdinWriteError: Error | undefined; - const proc = execFile(requireSystemBin("ffprobe"), args, execOptions, (err, stdout) => { - if (err) { - reject(toErrorObject(err, "Non-Error rejection")); - return; - } - if (stdinWriteError && !isBrokenPipeError(stdinWriteError)) { - reject(stdinWriteError); - return; - } - resolve(stdout.toString()); - }); - proc.stdin?.once("error", (err: Error) => { - stdinWriteError = err; - }); - proc.stdin?.end(options.input); - }); + const { stdout } = await runExec( + requireSystemBin("ffprobe"), + args, + resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options), + ); + return stdout; } /** Runs ffmpeg with bounded timeout and buffer settings. */ export async function runFfmpeg(args: string[], options?: MediaExecOptions): Promise { - const { stdout } = await execFileAsync( + const { stdout } = await runExec( resolveFfmpegBin(), args, resolveExecOptions(MEDIA_FFMPEG_TIMEOUT_MS, options), ); - return stdout.toString(); + return stdout; } /** Splits ffprobe CSV-ish output into normalized lowercase fields. */ diff --git a/src/node-host/invoke.run-command.test.ts b/src/node-host/invoke.run-command.test.ts index e9ae81529b42..0d9fd26b0eee 100644 --- a/src/node-host/invoke.run-command.test.ts +++ b/src/node-host/invoke.run-command.test.ts @@ -1,133 +1,104 @@ -import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { testing } from "./invoke.js"; -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: vi.fn(), - }; -}); - -const { spawn } = await import("node:child_process"); - -type MockChild = EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: ReturnType; -}; - -function createMockChild(): MockChild { - return Object.assign(new EventEmitter(), { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - kill: vi.fn(), - }); -} - -function mockNextSpawn(child: MockChild): void { - vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); -} - describe("runCommand", () => { afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); + vi.restoreAllMocks(); }); - it.each(["stdout", "stderr"] as const)( - "settles after child exit when %s emits an error", - async (streamName) => { - const child = createMockChild(); - mockNextSpawn(child); - - const resultPromise = testing.runCommand(["echo", "hello"], undefined, undefined, undefined); - child.stdout.emit("data", Buffer.from("captured stdout")); - child.stderr.emit("data", Buffer.from("captured stderr")); - child[streamName].emit("error", new Error(`${streamName} broke`)); - - let settled = false; - void resultPromise.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - expect(child.kill).toHaveBeenCalledWith("SIGTERM"); - child.stdout.emit("error", new Error("later stdout error")); - child.stderr.emit("error", new Error("later stderr error")); - expect(child.kill).toHaveBeenCalledTimes(1); - child.emit("exit", 1); - - await expect(resultPromise).resolves.toEqual({ - exitCode: 1, - timedOut: false, - success: false, - stdout: "captured stdout", - stderr: "captured stderr", - error: `${streamName} broke`, - truncated: false, - }); - }, - ); - - it("escalates stream-error termination when the child does not exit", async () => { - vi.useFakeTimers(); - const child = createMockChild(); - mockNextSpawn(child); - - const resultPromise = testing.runCommand(["slow"], undefined, undefined, undefined); - child.stderr.emit("error", new Error("stderr broke")); - - expect(child.kill).toHaveBeenCalledWith("SIGTERM"); - await vi.advanceTimersByTimeAsync(testing.STREAM_ERROR_KILL_GRACE_MS); - expect(child.kill).toHaveBeenLastCalledWith("SIGKILL"); - child.emit("exit", null); - await expect(resultPromise).resolves.toMatchObject({ - exitCode: undefined, + it("captures stdout, stderr, and exit status", async () => { + await expect( + testing.runCommand( + [ + process.execPath, + "-e", + "process.stdout.write('captured stdout'); process.stderr.write('captured stderr')", + ], + undefined, + undefined, + undefined, + ), + ).resolves.toEqual({ + exitCode: 0, timedOut: false, - success: false, - error: "stderr broke", + success: true, + stdout: "captured stdout", + stderr: "captured stderr", + error: null, + truncated: false, }); }); - it("preserves child spawn errors", async () => { - const child = createMockChild(); - mockNextSpawn(child); - - const resultPromise = testing.runCommand(["missing"], undefined, undefined, undefined); - child.emit("error", new Error("spawn failed")); - - await expect(resultPromise).resolves.toMatchObject({ - exitCode: undefined, - timedOut: false, - success: false, - error: "spawn failed", - }); - expect(child.kill).not.toHaveBeenCalled(); + it("closes stdin for commands that wait for EOF", async () => { + await expect( + testing.runCommand( + [ + process.execPath, + "-e", + "process.stdin.resume(); process.stdin.once('end', () => process.stdout.write('eof'))", + ], + undefined, + undefined, + 2_000, + ), + ).resolves.toMatchObject({ success: true, stdout: "eof" }); }); - it("preserves timeout termination and exit settlement", async () => { - vi.useFakeTimers(); - const child = createMockChild(); - mockNextSpawn(child); - - const resultPromise = testing.runCommand(["slow"], undefined, undefined, 10); - await vi.advanceTimersByTimeAsync(10); - - expect(child.kill).toHaveBeenCalledWith("SIGKILL"); - child.emit("exit", null); - await expect(resultPromise).resolves.toMatchObject({ - exitCode: undefined, - timedOut: true, + it("preserves nonzero command results", async () => { + await expect( + testing.runCommand( + [process.execPath, "-e", "process.stderr.write('failed'); process.exit(7)"], + undefined, + undefined, + undefined, + ), + ).resolves.toMatchObject({ + exitCode: 7, + timedOut: false, success: false, + stderr: "failed", error: null, }); }); + it.runIf(process.platform !== "win32")("force-kills timed-out command trees", async () => { + const startedAt = Date.now(); + const result = await testing.runCommand( + [process.execPath, "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], + undefined, + undefined, + 25, + ); + expect(result).toMatchObject({ timedOut: true, success: false, error: null }); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); + + it("keeps the combined output prefix bounded", async () => { + const result = await testing.runCommand( + [process.execPath, "-e", "process.stdout.write('x'.repeat(200_001))"], + undefined, + undefined, + undefined, + ); + expect(result.stdout).toHaveLength(200_000); + expect(result.stdout).toBe("x".repeat(200_000)); + expect(result.truncated).toBe(true); + }); + + it("preserves child launch errors", async () => { + const result = await testing.runCommand( + [`openclaw-missing-${process.pid}-${Date.now()}`], + undefined, + undefined, + undefined, + ); + expect(result).toMatchObject({ exitCode: undefined, timedOut: false, success: false }); + expect(result.error).toMatch(/ENOENT|not found/i); + }); + describe("working directory failures", () => { const enoent = (message: string) => Object.assign(new Error(message), { code: "ENOENT" }) as NodeJS.ErrnoException; @@ -139,58 +110,40 @@ describe("runCommand", () => { ); }); - it("flags a cwd that exists but is not a directory", () => { + it("flags a cwd that exists but is not a directory", async () => { const file = path.join(os.tmpdir(), `node-exec-file-${process.pid}-${Date.now()}.txt`); fs.writeFileSync(file, "x"); try { - const error = Object.assign(new Error("spawn ENOTDIR"), { - code: "ENOTDIR", - }) as NodeJS.ErrnoException; - expect(testing.clarifyNodeExecCwdSpawnError(error, file)).toBe( - `node exec working directory is not a directory on the node host: ${file} (os reported: spawn ENOTDIR)`, + const result = await testing.runCommand( + [process.execPath, "-e", "process.exit(0)"], + file, + undefined, + undefined, + ); + expect(result).toMatchObject({ success: false }); + expect(result.error).toContain( + `node exec working directory is not a directory on the node host: ${file}`, ); } finally { fs.rmSync(file, { force: true }); } }); - it("clarifies an asynchronous spawn error for a missing cwd", async () => { - const child = createMockChild(); - mockNextSpawn(child); + it("clarifies a missing cwd during execution", async () => { const cwd = path.join(os.tmpdir(), `node-exec-run-missing-${process.pid}-${Date.now()}`); - - const resultPromise = testing.runCommand(["/bin/sh"], cwd, undefined, undefined); - child.emit("error", enoent("spawn /bin/sh ENOENT")); - - await expect(resultPromise).resolves.toMatchObject({ - success: false, - error: expect.stringContaining( - `node exec working directory does not exist on the node host: ${cwd}`, - ), - }); + const result = await testing.runCommand( + [process.execPath, "-e", "process.exit(0)"], + cwd, + undefined, + undefined, + ); + expect(result).toMatchObject({ success: false }); + expect(result.error).toContain( + `node exec working directory does not exist on the node host: ${cwd}`, + ); }); - it("clarifies a synchronous spawn throw for a cwd that is a file", async () => { - const file = path.join(os.tmpdir(), `node-exec-run-file-${process.pid}-${Date.now()}.txt`); - fs.writeFileSync(file, "x"); - vi.mocked(spawn).mockImplementationOnce(() => { - throw Object.assign(new Error("spawn ENOTDIR"), { code: "ENOTDIR" }); - }); - try { - await expect( - testing.runCommand(["/bin/sh"], file, undefined, undefined), - ).resolves.toMatchObject({ - success: false, - error: expect.stringContaining( - `node exec working directory is not a directory on the node host: ${file}`, - ), - }); - } finally { - fs.rmSync(file, { force: true }); - } - }); - - it("preserves genuine executable and unrelated spawn errors", () => { + it("preserves executable and unrelated errors", () => { const missingExecutable = "spawn /usr/bin/does-not-exist ENOENT"; expect(testing.clarifyNodeExecCwdSpawnError(enoent(missingExecutable), os.tmpdir())).toBe( missingExecutable, @@ -206,14 +159,10 @@ describe("runCommand", () => { it("preserves the spawn error when the cwd cannot be inspected", () => { const message = "spawn /bin/sh ENOENT"; - const stat = vi.spyOn(fs, "statSync").mockImplementationOnce(() => { + vi.spyOn(fs, "statSync").mockImplementationOnce(() => { throw Object.assign(new Error("permission denied"), { code: "EACCES" }); }); - try { - expect(testing.clarifyNodeExecCwdSpawnError(enoent(message), "/unreadable")).toBe(message); - } finally { - stat.mockRestore(); - } + expect(testing.clarifyNodeExecCwdSpawnError(enoent(message), "/unreadable")).toBe(message); }); }); }); diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index 6944cdabb5a9..e3c80c28a39a 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -1,9 +1,7 @@ /** Node-host command dispatcher for system commands, approvals, env policy, and plugin commands. */ -import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; -import { expectDefined } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; @@ -43,11 +41,9 @@ import { NODE_FS_LIST_DIR_COMMAND, NODE_MCP_TOOLS_CALL_COMMAND, } from "../infra/node-commands.js"; -import { - decodeWindowsOutputBuffer, - resolveWindowsConsoleEncoding, -} from "../infra/windows-encoding.js"; +import { decodeWindowsOutputBuffer } from "../infra/windows-encoding.js"; import { logWarn } from "../logger.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { truncateUtf8Prefix } from "../utils/utf8-truncate.js"; import type { NodeHostClient } from "./client.js"; import { @@ -82,9 +78,6 @@ const MCP_PAYLOAD_TRUNCATION_MARKER = "[truncated: MCP result exceeded 20 MB]"; const MCP_ERROR_MESSAGE_MAX_CHARS = 1_024; const OUTPUT_EVENT_TAIL = 20_000; - -const STREAM_ERROR_KILL_GRACE_MS = 1_000; - const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const execHostEnforced = @@ -349,137 +342,39 @@ async function runCommand( env: Record | undefined, timeoutMs: number | undefined, ): Promise { - return await new Promise((resolve) => { - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let outputLen = 0; - let truncated = false; - let timedOut = false; - let settled = false; - const windowsEncoding = resolveWindowsConsoleEncoding(); - - // A cwd that exists but is not a directory makes `spawn` throw ENOTDIR - // synchronously instead of emitting `error`. Keep that failure inside the - // node result because runner.ts intentionally dispatches invokes with `void`. - let child: ReturnType; - try { - child = spawn(expectDefined(argv[0], "argv entry at 0"), argv.slice(1), { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - } catch (err) { - resolve({ - exitCode: undefined, - timedOut: false, - success: false, - stdout: "", - stderr: "", - error: clarifyNodeExecCwdSpawnError(err as NodeJS.ErrnoException, cwd), - truncated: false, - }); - return; - } - - const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => { - if (outputLen >= OUTPUT_CAP) { - truncated = true; - return; - } - const remaining = OUTPUT_CAP - outputLen; - const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; - outputLen += slice.length; - if (target === "stdout") { - stdoutChunks.push(slice); - } else { - stderrChunks.push(slice); - } - if (chunk.length > remaining) { - truncated = true; - } - }; - - child.stdout?.on("data", (chunk) => onChunk(chunk as Buffer, "stdout")); - child.stderr?.on("data", (chunk) => onChunk(chunk as Buffer, "stderr")); - - let timer: NodeJS.Timeout | undefined; - let streamError: Error | undefined; - let streamKillTimer: NodeJS.Timeout | undefined; - if (timeoutMs && timeoutMs > 0) { - timer = setTimeout(() => { - timedOut = true; - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - }, timeoutMs); - } - - const finalize = (exitCode?: number, error?: string | null) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - if (streamKillTimer) { - clearTimeout(streamKillTimer); - } - const stdout = decodeCapturedOutputBuffer({ - buffer: Buffer.concat(stdoutChunks), - windowsEncoding, - }); - const stderr = decodeCapturedOutputBuffer({ - buffer: Buffer.concat(stderrChunks), - windowsEncoding, - }); - resolve({ - exitCode, - timedOut, - success: exitCode === 0 && !timedOut && !error, - stdout, - stderr, - error: error ?? null, - truncated, - }); - }; - - const onStreamError = (err: Error) => { - if (settled || streamError) { - return; - } - streamError = err; - try { - child.kill("SIGTERM"); - } catch { - // ignore - } - // A reported system.run completion must not outlive its command. Escalate - // a pipe-failure shutdown, then let the child exit settle the result. - streamKillTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - }, STREAM_ERROR_KILL_GRACE_MS); - streamKillTimer.unref?.(); - }; - - child.stdout?.on("error", onStreamError); - child.stderr?.on("error", onStreamError); - child.on("error", (err) => { - if (!streamError) { - finalize(undefined, clarifyNodeExecCwdSpawnError(err, cwd)); - } + try { + const result = await runCommandWithTimeout(argv, { + baseEnv: env, + cwd, + killProcessTree: true, + maxCombinedOutputBytes: OUTPUT_CAP, + maxOutputBytes: OUTPUT_CAP, + outputCapture: "head", + input: Buffer.alloc(0), + timeoutMs: timeoutMs && timeoutMs > 0 ? timeoutMs : undefined, }); - child.on("exit", (code) => { - finalize(code === null ? undefined : code, streamError?.message ?? null); - }); - }); + const timedOut = result.termination === "timeout"; + const exitCode = result.code ?? undefined; + return { + exitCode, + timedOut, + success: exitCode === 0 && !timedOut, + stdout: result.stdout, + stderr: result.stderr, + error: null, + truncated: Boolean(result.stdoutTruncatedBytes || result.stderrTruncatedBytes), + }; + } catch (err) { + return { + exitCode: undefined, + timedOut: false, + success: false, + stdout: "", + stderr: "", + error: clarifyNodeExecCwdSpawnError(err as NodeJS.ErrnoException, cwd), + truncated: false, + }; + } } function resolveEnvPath(env?: Record): string[] { @@ -1204,7 +1099,6 @@ async function sendNodeEvent(client: NodeHostClient, event: string, payload: unk export const testing = { MCP_TEXT_CONTENT_MAX_BYTES, MCP_INVOKE_PAYLOAD_MAX_BYTES, - STREAM_ERROR_KILL_GRACE_MS, clarifyNodeExecCwdSpawnError, runCommand, } as const; diff --git a/src/plugin-sdk/process-runtime.ts b/src/plugin-sdk/process-runtime.ts index 0e794db86bdc..d205d74850b5 100644 --- a/src/plugin-sdk/process-runtime.ts +++ b/src/plugin-sdk/process-runtime.ts @@ -4,6 +4,7 @@ export { type CommandOptions, resolveCommandEnv, resolveProcessExitCode, + runCommandBuffered, runCommandWithTimeout, runExec, shouldSpawnWithShell, diff --git a/src/process/exec-output.ts b/src/process/exec-output.ts new file mode 100644 index 000000000000..ea3b8c337af3 --- /dev/null +++ b/src/process/exec-output.ts @@ -0,0 +1,208 @@ +import process from "node:process"; +import { StringDecoder } from "node:string_decoder"; +import { expectDefined } from "@openclaw/normalization-core"; +import { truncateUtf8Suffix } from "../utils/utf8-truncate.js"; + +export type CommandOutputCaptureMode = "head" | "tail" | "discard"; +export type CommandOutputStream = "stdout" | "stderr"; +export type CommandOutputCaptureOption = + | CommandOutputCaptureMode + | { stdout?: CommandOutputCaptureMode; stderr?: CommandOutputCaptureMode }; +export type CommandOutputLimitOption = + | boolean + | { stdout?: boolean; stderr?: boolean; combined?: boolean }; +export type PreserveOutputLine = (line: string, stream: CommandOutputStream) => boolean; + +export type CapturedOutputBuffers = { + chunks: Buffer[]; + bytes: number; + truncatedBytes: number; + preservedLines: string[]; + decoder: StringDecoder; + pendingLine: string; +}; + +const DEFAULT_COMMAND_OUTPUT_MAX_BYTES = 16 * 1024 * 1024; +export const MAX_PRESERVED_PENDING_LINE_BYTES = 8 * 1024; + +export function createCapturedOutputBuffers(): CapturedOutputBuffers { + return { + chunks: [], + bytes: 0, + truncatedBytes: 0, + preservedLines: [], + decoder: new StringDecoder("utf8"), + pendingLine: "", + }; +} + +function normalizeMaxOutputBytes(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return DEFAULT_COMMAND_OUTPUT_MAX_BYTES; + } + return Math.max(1, Math.floor(value)); +} + +export function resolveMaxOutputBytes( + value: number | { stdout?: number; stderr?: number } | undefined, + stream: CommandOutputStream, +): number { + return normalizeMaxOutputBytes(typeof value === "number" ? value : value?.[stream]); +} + +export function resolveOutputCapture( + value: CommandOutputCaptureOption | undefined, + stream: CommandOutputStream, +): CommandOutputCaptureMode { + return (typeof value === "string" ? value : value?.[stream]) ?? "tail"; +} + +export function shouldTerminateOnOutputLimit( + value: CommandOutputLimitOption | undefined, + limit: CommandOutputStream | "combined", +): boolean { + return typeof value === "boolean" ? value : value?.[limit] === true; +} + +export function appendCapturedOutput( + capture: CapturedOutputBuffers, + chunk: Buffer | string, + maxBytes: number, + mode: CommandOutputCaptureMode, +): void { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (mode === "discard") { + capture.truncatedBytes += buffer.byteLength; + return; + } + if (mode === "head") { + const remaining = Math.max(0, maxBytes - capture.bytes); + if (remaining > 0) { + const kept = buffer.subarray(0, remaining); + capture.chunks.push(kept); + capture.bytes += kept.byteLength; + } + capture.truncatedBytes += Math.max(0, buffer.byteLength - remaining); + return; + } + if (buffer.byteLength >= maxBytes) { + capture.chunks = [Buffer.from(buffer.subarray(buffer.byteLength - maxBytes))]; + capture.truncatedBytes += capture.bytes + buffer.byteLength - maxBytes; + capture.bytes = maxBytes; + return; + } + + capture.chunks.push(buffer); + capture.bytes += buffer.byteLength; + while (capture.bytes > maxBytes && capture.chunks.length > 0) { + const first = expectDefined(capture.chunks[0], "chunks entry at 0"); + const overflow = capture.bytes - maxBytes; + if (first.byteLength <= overflow) { + capture.chunks.shift(); + capture.bytes -= first.byteLength; + capture.truncatedBytes += first.byteLength; + } else { + capture.chunks[0] = Buffer.from(first.subarray(overflow)); + capture.bytes -= overflow; + capture.truncatedBytes += overflow; + } + } +} + +function trimTruncatedUtf8Boundary( + buffer: Buffer, + mode: CommandOutputCaptureMode, + truncatedBytes: number, +): Buffer { + if (truncatedBytes === 0 || buffer.length === 0 || process.platform === "win32") { + return buffer; + } + if (mode === "tail") { + let start = 0; + while (start < buffer.length && (expectDefined(buffer[start], "buffer byte") & 0xc0) === 0x80) { + start += 1; + } + return buffer.subarray(start); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (let removed = 0; removed <= 3 && removed <= buffer.length; removed += 1) { + const end = buffer.length - removed; + try { + decoder.decode(buffer.subarray(0, end)); + return buffer.subarray(0, end); + } catch { + // A UTF-8 code point is at most four bytes; try the preceding boundary. + } + } + return buffer; +} + +export function finalizeCapturedOutput( + capture: CapturedOutputBuffers, + mode: CommandOutputCaptureMode, +): Buffer { + const buffered = Buffer.concat(capture.chunks, capture.bytes); + const trimmed = trimTruncatedUtf8Boundary(buffered, mode, capture.truncatedBytes); + capture.truncatedBytes += buffered.byteLength - trimmed.byteLength; + return trimmed; +} + +function trimPreservedPendingLine(value: string, maxBytes: number): string { + return truncateUtf8Suffix(value, maxBytes); +} + +export function appendPreservedOutputLines(params: { + capture: CapturedOutputBuffers; + chunk: Buffer | string; + stream: CommandOutputStream; + preserveOutputLine?: PreserveOutputLine; + maxPreservedOutputLines: number; + maxPendingLineBytes: number; +}): void { + if (!params.preserveOutputLine || params.maxPreservedOutputLines <= 0) { + return; + } + const text = Buffer.isBuffer(params.chunk) + ? params.capture.decoder.write(params.chunk) + : params.chunk; + if (!text) { + return; + } + const lines = (params.capture.pendingLine + text).split(/\r?\n/); + params.capture.pendingLine = trimPreservedPendingLine( + lines.pop() ?? "", + params.maxPendingLineBytes, + ); + for (const line of lines) { + if ( + params.capture.preservedLines.length < params.maxPreservedOutputLines && + params.preserveOutputLine(line, params.stream) + ) { + params.capture.preservedLines.push(line); + } + } +} + +export function flushPreservedOutputLine(params: { + capture: CapturedOutputBuffers; + stream: CommandOutputStream; + preserveOutputLine?: PreserveOutputLine; + maxPreservedOutputLines: number; + maxPendingLineBytes: number; +}): void { + if (!params.preserveOutputLine || params.maxPreservedOutputLines <= 0) { + return; + } + const trailing = trimPreservedPendingLine( + params.capture.pendingLine + params.capture.decoder.end(), + params.maxPendingLineBytes, + ); + params.capture.pendingLine = ""; + if ( + trailing && + params.capture.preservedLines.length < params.maxPreservedOutputLines && + params.preserveOutputLine(trailing, params.stream) + ) { + params.capture.preservedLines.push(trailing); + } +} diff --git a/src/process/exec-result.ts b/src/process/exec-result.ts new file mode 100644 index 000000000000..62a55a06a890 --- /dev/null +++ b/src/process/exec-result.ts @@ -0,0 +1,117 @@ +export type SpawnResult = { + pid?: number; + stdout: string; + stderr: string; + stdoutTruncatedBytes?: number; + stderrTruncatedBytes?: number; + preservedStdoutLines?: string[]; + preservedStderrLines?: string[]; + code: number | null; + signal: NodeJS.Signals | null; + killed: boolean; + termination: "exit" | "timeout" | "no-output-timeout" | "signal"; + noOutputTimedOut?: boolean; + outputLimitExceeded?: boolean; + outputErrorStream?: "stdout" | "stderr"; +}; + +export const TIMEOUT_EXIT_CODE = 124; + +export function createSanitizedCommandError(result: { + code?: unknown; + exitCode?: unknown; + signal?: unknown; + timedOut?: boolean; + isCanceled?: boolean; + isMaxBuffer?: boolean; + isTerminated?: boolean; +}): Error { + const code = typeof result.code === "string" ? result.code : undefined; + const exitCode = typeof result.exitCode === "number" ? result.exitCode : undefined; + const signal = typeof result.signal === "string" ? result.signal : undefined; + const message = result.timedOut + ? "Command timed out" + : result.isMaxBuffer + ? "Command output exceeded its capture limit" + : result.isCanceled + ? "Command was canceled" + : result.isTerminated + ? `Command was terminated${signal ? ` by ${signal}` : ""}` + : exitCode !== undefined && exitCode !== 0 + ? `Command exited with code ${exitCode}` + : `Command failed during launch or output capture${code ? ` (${code})` : ""}`; + return Object.assign(new Error(message), { + ...(code ? { code } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + ...(signal ? { signal } : {}), + }); +} + +export function isPlainCommandExitFailure(result: { + failed: boolean; + exitCode?: unknown; + signal?: unknown; + cause?: unknown; + timedOut?: boolean; + isCanceled?: boolean; + isMaxBuffer?: boolean; + isTerminated?: boolean; +}): boolean { + return ( + result.failed && + typeof result.exitCode === "number" && + result.exitCode !== 0 && + result.signal === undefined && + result.cause === undefined && + !result.timedOut && + !result.isCanceled && + !result.isMaxBuffer && + !result.isTerminated + ); +} + +export function isPlainCommandSignalFailure(result: { + failed: boolean; + exitCode?: unknown; + signal?: unknown; + cause?: unknown; + timedOut?: boolean; + isCanceled?: boolean; + isMaxBuffer?: boolean; + isTerminated?: boolean; +}): boolean { + return ( + result.failed && + result.exitCode === undefined && + typeof result.signal === "string" && + result.cause === undefined && + !result.timedOut && + !result.isCanceled && + !result.isMaxBuffer && + result.isTerminated === true + ); +} + +export function resolveProcessExitCode(params: { + explicitCode: number | null | undefined; + childExitCode: number | null | undefined; + resolvedSignal: NodeJS.Signals | null; + usesWindowsExitCodeShim: boolean; + timedOut: boolean; + noOutputTimedOut: boolean; + killIssuedByTimeout: boolean; + killIssuedByAbort?: boolean; +}): number | null { + return ( + params.explicitCode ?? + params.childExitCode ?? + (params.usesWindowsExitCodeShim && + params.resolvedSignal == null && + !params.timedOut && + !params.noOutputTimedOut && + !params.killIssuedByTimeout && + !params.killIssuedByAbort + ? 0 + : null) + ); +} diff --git a/src/process/exec-runner.ts b/src/process/exec-runner.ts new file mode 100644 index 000000000000..a1c53d0a2808 --- /dev/null +++ b/src/process/exec-runner.ts @@ -0,0 +1,474 @@ +import process from "node:process"; +import { expectDefined } from "@openclaw/normalization-core"; +import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; +import { + decodeWindowsOutputBuffer, + resolveWindowsConsoleEncoding, +} from "../infra/windows-encoding.js"; +import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; +import { releaseChildProcessOutputAfterExit } from "./child-process.js"; +import { + appendCapturedOutput, + appendPreservedOutputLines, + createCapturedOutputBuffers, + finalizeCapturedOutput, + flushPreservedOutputLine, + MAX_PRESERVED_PENDING_LINE_BYTES, + resolveMaxOutputBytes, + resolveOutputCapture, + shouldTerminateOnOutputLimit, + type CapturedOutputBuffers, + type CommandOutputCaptureMode, + type CommandOutputCaptureOption, + type CommandOutputLimitOption, + type CommandOutputStream, + type PreserveOutputLine, +} from "./exec-output.js"; +import { + createSanitizedCommandError, + isPlainCommandExitFailure, + isPlainCommandSignalFailure, + resolveProcessExitCode, + TIMEOUT_EXIT_CODE, + type SpawnResult, +} from "./exec-result.js"; +import { COMMAND_PROCESS_TREE_KILL_GRACE_MS, spawnCommandWithInvocation } from "./exec-spawn.js"; +import { createCommandTerminationController } from "./exec-termination.js"; +import { resolveCommandStdio } from "./spawn-utils.js"; + +const WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS = 250; +const WINDOWS_CLOSE_STATE_POLL_MS = 10; + +type CommandTerminationReason = SpawnResult["termination"] | "output-limit"; + +export type CommandOptions = { + timeoutMs?: number; + cwd?: string; + input?: string | Uint8Array; + baseEnv?: NodeJS.ProcessEnv; + env?: NodeJS.ProcessEnv; + windowsVerbatimArguments?: boolean; + noOutputTimeoutMs?: number; + signal?: AbortSignal; + maxOutputBytes?: number | { stdout?: number; stderr?: number }; + maxCombinedOutputBytes?: number; + outputCapture?: CommandOutputCaptureOption; + /** Observe raw output without owning child lifecycle. Return false to stop the command. */ + onOutputChunk?: (chunk: Buffer, stream: CommandOutputStream) => boolean | void; + /** Accept a successful exit when only the selected diagnostic output stream failed. */ + tolerateOutputError?: { stdout?: boolean; stderr?: boolean }; + terminateOnOutputLimit?: CommandOutputLimitOption; + maxPreservedOutputLines?: number; + preserveOutputLine?: PreserveOutputLine; + killProcessTree?: boolean; + /** Signal used when terminating the direct child; tree termination owns its own grace policy. */ + killSignal?: NodeJS.Signals | number; +}; + +export async function runCommandWithTimeout( + argv: string[], + optionsOrTimeout: number | CommandOptions, +): Promise { + const options: CommandOptions = + typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout; + const { + timeoutMs, + cwd, + input, + baseEnv, + env, + noOutputTimeoutMs, + signal, + killProcessTree, + killSignal, + } = options; + const resolvedTimeoutMs = + typeof timeoutMs === "number" ? resolveTimerTimeoutMs(timeoutMs, 1) : undefined; + const hasInput = input !== undefined; + const stdio = resolveCommandStdio({ hasInput, preferInherit: true }); + + if (signal?.aborted) { + return { + stdout: "", + stderr: "", + code: null, + signal: null, + killed: false, + termination: "signal", + noOutputTimedOut: false, + }; + } + + const stdoutCapture = createCapturedOutputBuffers(); + const stderrCapture = createCapturedOutputBuffers(); + const maxStdoutBytes = resolveMaxOutputBytes(options.maxOutputBytes, "stdout"); + const maxStderrBytes = resolveMaxOutputBytes(options.maxOutputBytes, "stderr"); + const maxCombinedOutputBytes = + typeof options.maxCombinedOutputBytes === "number" && + Number.isFinite(options.maxCombinedOutputBytes) && + options.maxCombinedOutputBytes > 0 + ? Math.max(1, Math.floor(options.maxCombinedOutputBytes)) + : undefined; + const stdoutCaptureMode = resolveOutputCapture(options.outputCapture, "stdout"); + const stderrCaptureMode = resolveOutputCapture(options.outputCapture, "stderr"); + if (maxCombinedOutputBytes !== undefined && stdoutCaptureMode !== stderrCaptureMode) { + throw new Error("maxCombinedOutputBytes requires matching stdout and stderr capture modes"); + } + const usesCombinedTailCapture = + maxCombinedOutputBytes !== undefined && + stdoutCaptureMode === "tail" && + stderrCaptureMode === "tail"; + const maxPreservedPendingLineBytes = Math.min( + Math.max(maxStdoutBytes, maxStderrBytes), + MAX_PRESERVED_PENDING_LINE_BYTES, + ); + const maxPreservedOutputLines = Math.max(0, Math.floor(options.maxPreservedOutputLines ?? 16)); + const windowsEncoding = resolveWindowsConsoleEncoding(); + const cancelController = new AbortController(); + let termination: CommandTerminationReason | undefined; + let childExitState: { code: number | null; signal: NodeJS.Signals | null } | undefined; + let childExited = false; + let commandSettled = false; + let combinedOutputBytes = 0; + let combinedCapturedBytes = 0; + const outputBytesByStream = { stdout: 0, stderr: 0 }; + const combinedCapturedBytesByStream = { stdout: 0, stderr: 0 }; + const combinedTailChunks: Array<{ stream: CommandOutputStream; buffer: Buffer }> = []; + let noOutputTimer: NodeJS.Timeout | undefined; + let outputObserverError: unknown; + let outputErrorStream: CommandOutputStream | undefined; + + const { child, invocation } = spawnCommandWithInvocation(argv, { + buffer: false, + cancelSignal: cancelController.signal, + cwd, + detached: Boolean(killProcessTree && process.platform !== "win32"), + encoding: "buffer", + baseEnv, + env, + forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS, + killSignal, + ...(hasInput ? { input } : {}), + reject: false, + stdio, + stripFinalNewline: false, + windowsVerbatimArguments: options.windowsVerbatimArguments, + }); + const releaseOutput = releaseChildProcessOutputAfterExit(child); + child.once("exit", (code, signalValue) => { + childExited = true; + childExitState = { code, signal: signalValue }; + }); + const terminationController = createCommandTerminationController({ + child, + cancelController, + baseEnv, + env, + killProcessTree, + isChildExited: () => childExited, + isCommandSettled: () => commandSettled, + }); + + const clearNoOutputTimer = () => { + if (noOutputTimer) { + clearTimeout(noOutputTimer); + noOutputTimer = undefined; + } + }; + const ownsExitedProcessTree = Boolean(killProcessTree && process.platform !== "win32"); + const cancel = (reason: Exclude) => { + // Direct exit ends ordinary timer/abort ownership; releaseChildProcessOutputAfterExit + // still bounds inherited pipes. POSIX tree mode must reap descendants, while + // output caps remain meaningful for bytes drained after exit. + if ( + termination || + commandSettled || + (childExited && reason !== "output-limit" && !ownsExitedProcessTree) + ) { + return; + } + termination = reason; + const abortDeferred = terminationController.terminate(); + if (!abortDeferred) { + cancelController.abort(); + } + }; + const shouldTrackOutputTimeout = + typeof noOutputTimeoutMs === "number" && + Number.isFinite(noOutputTimeoutMs) && + noOutputTimeoutMs > 0; + const resolvedNoOutputTimeoutMs = shouldTrackOutputTimeout + ? resolveTimerTimeoutMs(noOutputTimeoutMs, 1) + : undefined; + const armNoOutputTimer = () => { + if ( + resolvedNoOutputTimeoutMs === undefined || + commandSettled || + termination || + (childExited && !ownsExitedProcessTree) + ) { + return; + } + clearNoOutputTimer(); + noOutputTimer = setTimeout(() => cancel("no-output-timeout"), resolvedNoOutputTimeoutMs); + }; + + const timeoutTimer = + resolvedTimeoutMs === undefined + ? undefined + : setTimeout(() => cancel("timeout"), resolvedTimeoutMs); + const onAbort = () => cancel("signal"); + signal?.addEventListener("abort", onAbort, { once: true }); + armNoOutputTimer(); + + const captureOutput = ( + capture: CapturedOutputBuffers, + chunk: Buffer | string, + maxBytes: number, + stream: CommandOutputStream, + captureMode: CommandOutputCaptureMode, + ) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + outputBytesByStream[stream] += buffer.byteLength; + const streamLimitExceeded = outputBytesByStream[stream] > maxBytes; + if (maxCombinedOutputBytes === undefined) { + appendCapturedOutput(capture, buffer, maxBytes, captureMode); + if ( + streamLimitExceeded && + shouldTerminateOnOutputLimit(options.terminateOnOutputLimit, stream) + ) { + cancel("output-limit"); + } + return; + } + + const combinedBytesBeforeChunk = combinedOutputBytes; + combinedOutputBytes += buffer.byteLength; + const combinedLimitExceeded = combinedOutputBytes > maxCombinedOutputBytes; + if (usesCombinedTailCapture) { + combinedTailChunks.push({ stream, buffer }); + combinedCapturedBytes += buffer.byteLength; + combinedCapturedBytesByStream[stream] += buffer.byteLength; + + const removeCapturedBytes = (index: number, requestedBytes: number) => { + const entry = expectDefined(combinedTailChunks[index], "combined tail chunk"); + const removedBytes = Math.min(requestedBytes, entry.buffer.byteLength); + if (removedBytes === entry.buffer.byteLength) { + combinedTailChunks.splice(index, 1); + } else { + entry.buffer = Buffer.from(entry.buffer.subarray(removedBytes)); + } + combinedCapturedBytes -= removedBytes; + combinedCapturedBytesByStream[entry.stream] -= removedBytes; + (entry.stream === "stdout" ? stdoutCapture : stderrCapture).truncatedBytes += removedBytes; + }; + + while (combinedCapturedBytesByStream[stream] > maxBytes) { + const index = combinedTailChunks.findIndex((entry) => entry.stream === stream); + if (index < 0) { + break; + } + removeCapturedBytes(index, combinedCapturedBytesByStream[stream] - maxBytes); + } + let combinedOverflow = combinedCapturedBytes - maxCombinedOutputBytes; + while (combinedOverflow > 0) { + removeCapturedBytes(0, combinedOverflow); + combinedOverflow = combinedCapturedBytes - maxCombinedOutputBytes; + } + } else { + const remaining = Math.max(0, maxCombinedOutputBytes - combinedBytesBeforeChunk); + if (remaining > 0) { + appendCapturedOutput(capture, buffer.subarray(0, remaining), maxBytes, captureMode); + } + capture.truncatedBytes += Math.max(0, buffer.byteLength - remaining); + } + if ( + (combinedLimitExceeded && + shouldTerminateOnOutputLimit(options.terminateOnOutputLimit, "combined")) || + (streamLimitExceeded && shouldTerminateOnOutputLimit(options.terminateOnOutputLimit, stream)) + ) { + cancel("output-limit"); + } + }; + + const observeOutputChunk = (chunk: Buffer | string, stream: CommandOutputStream): Buffer => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (termination || !options.onOutputChunk) { + return buffer; + } + try { + if (options.onOutputChunk(buffer, stream) === false) { + cancel("output-limit"); + } + } catch (error) { + outputObserverError = error; + cancel("output-limit"); + } + return buffer; + }; + + child.stdout?.once("error", () => { + outputErrorStream ??= "stdout"; + }); + child.stderr?.once("error", () => { + outputErrorStream ??= "stderr"; + }); + child.stdout?.on("data", (chunk) => { + const buffer = observeOutputChunk(chunk, "stdout"); + appendPreservedOutputLines({ + capture: stdoutCapture, + chunk: buffer, + stream: "stdout", + preserveOutputLine: options.preserveOutputLine, + maxPreservedOutputLines, + maxPendingLineBytes: maxPreservedPendingLineBytes, + }); + captureOutput(stdoutCapture, buffer, maxStdoutBytes, "stdout", stdoutCaptureMode); + armNoOutputTimer(); + }); + child.stderr?.on("data", (chunk) => { + const buffer = observeOutputChunk(chunk, "stderr"); + appendPreservedOutputLines({ + capture: stderrCapture, + chunk: buffer, + stream: "stderr", + preserveOutputLine: options.preserveOutputLine, + maxPreservedOutputLines, + maxPendingLineBytes: maxPreservedPendingLineBytes, + }); + captureOutput(stderrCapture, buffer, maxStderrBytes, "stderr", stderrCaptureMode); + armNoOutputTimer(); + }); + + const result = await child.finally(() => { + commandSettled = true; + if (timeoutTimer) { + clearTimeout(timeoutTimer); + } + clearNoOutputTimer(); + signal?.removeEventListener("abort", onAbort); + releaseOutput(); + }); + await terminationController.settle(); + if (outputObserverError !== undefined) { + throw toErrorObject(outputObserverError, "Command output observer failed"); + } + // Patched Node can report null/null after a cmd.exe shim exits. Execa turns + // that into a cause-less failure; preserve the shim fallback only post-spawn. + const isCauseLessWindowsShimResult = + !termination && + invocation.usesWindowsExitCodeShim && + typeof child.pid === "number" && + result.code === undefined && + result.cause === undefined && + !result.timedOut && + !result.isCanceled && + !result.isMaxBuffer && + !result.isTerminated; + if (isCauseLessWindowsShimResult) { + // A patched Windows runtime can populate exitCode shortly after close. + // Settle that state before the shim fallback can infer a clean exit. + for ( + let elapsedMs = 0; + elapsedMs < WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS; + elapsedMs += WINDOWS_CLOSE_STATE_POLL_MS + ) { + if ( + childExitState?.code != null || + childExitState?.signal != null || + child.exitCode != null || + child.signalCode != null + ) { + break; + } + await new Promise((resolve) => { + setTimeout(resolve, WINDOWS_CLOSE_STATE_POLL_MS); + }); + } + } + if ( + result.failed && + !termination && + !isPlainCommandExitFailure(result) && + !isPlainCommandSignalFailure(result) && + !isCauseLessWindowsShimResult && + !( + result.exitCode === 0 && + outputErrorStream !== undefined && + options.tolerateOutputError?.[outputErrorStream] === true + ) + ) { + const error = createSanitizedCommandError(result); + if (outputErrorStream) { + Object.assign(error, { outputErrorStream }); + } + throw error; + } + + const resolvedSignal = result.signal ?? childExitState?.signal ?? child.signalCode ?? null; + const resolvedCode = resolveProcessExitCode({ + explicitCode: result.exitCode ?? childExitState?.code, + childExitCode: child.exitCode, + resolvedSignal, + usesWindowsExitCodeShim: invocation.usesWindowsExitCodeShim, + timedOut: termination === "timeout", + noOutputTimedOut: termination === "no-output-timeout", + killIssuedByTimeout: termination === "timeout" || termination === "no-output-timeout", + killIssuedByAbort: termination === "signal" || termination === "output-limit", + }); + termination ??= resolvedSignal != null || result.isTerminated ? "signal" : "exit"; + const normalizedCode = + termination === "timeout" || termination === "no-output-timeout" + ? resolvedCode == null || resolvedCode === 0 + ? TIMEOUT_EXIT_CODE + : resolvedCode + : resolvedCode; + + flushPreservedOutputLine({ + capture: stdoutCapture, + stream: "stdout", + preserveOutputLine: options.preserveOutputLine, + maxPreservedOutputLines, + maxPendingLineBytes: maxPreservedPendingLineBytes, + }); + flushPreservedOutputLine({ + capture: stderrCapture, + stream: "stderr", + preserveOutputLine: options.preserveOutputLine, + maxPreservedOutputLines, + maxPendingLineBytes: maxPreservedPendingLineBytes, + }); + + if (usesCombinedTailCapture) { + for (const entry of combinedTailChunks) { + const capture = entry.stream === "stdout" ? stdoutCapture : stderrCapture; + capture.chunks.push(entry.buffer); + capture.bytes += entry.buffer.byteLength; + } + } + + return { + pid: child.pid, + stdout: decodeWindowsOutputBuffer({ + buffer: finalizeCapturedOutput(stdoutCapture, stdoutCaptureMode), + windowsEncoding, + }), + stderr: decodeWindowsOutputBuffer({ + buffer: finalizeCapturedOutput(stderrCapture, stderrCaptureMode), + windowsEncoding, + }), + stdoutTruncatedBytes: stdoutCapture.truncatedBytes || undefined, + stderrTruncatedBytes: stderrCapture.truncatedBytes || undefined, + preservedStdoutLines: + stdoutCapture.preservedLines.length > 0 ? stdoutCapture.preservedLines : undefined, + preservedStderrLines: + stderrCapture.preservedLines.length > 0 ? stderrCapture.preservedLines : undefined, + code: normalizedCode, + signal: resolvedSignal, + killed: child.killed, + termination: termination === "output-limit" ? "signal" : termination, + noOutputTimedOut: termination === "no-output-timeout", + outputLimitExceeded: termination === "output-limit" || undefined, + ...(outputErrorStream ? { outputErrorStream } : {}), + }; +} diff --git a/src/process/exec-spawn.ts b/src/process/exec-spawn.ts new file mode 100644 index 000000000000..6d8146409a73 --- /dev/null +++ b/src/process/exec-spawn.ts @@ -0,0 +1,133 @@ +import path from "node:path"; +import process from "node:process"; +import { execa, type Options as ExecaOptions, type ResultPromise } from "execa"; +import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js"; +import { resolveSafeChildProcessInvocation } from "./windows-command.js"; + +export const COMMAND_PROCESS_TREE_KILL_GRACE_MS = 300; + +function assignChildEnvValue(params: { + env: NodeJS.ProcessEnv; + key: string; + platform: NodeJS.Platform; + value: string | undefined; +}): void { + if (params.value === undefined) { + return; + } + if (params.platform === "win32") { + const normalizedKey = params.key.toLowerCase(); + for (const existingKey of Object.keys(params.env)) { + if (existingKey.toLowerCase() === normalizedKey && existingKey !== params.key) { + delete params.env[existingKey]; + } + } + } + params.env[params.key] = params.value; +} + +function mergeChildEnv(params: { + baseEnv: NodeJS.ProcessEnv; + env?: NodeJS.ProcessEnv; + platform: NodeJS.Platform; +}): NodeJS.ProcessEnv { + const resolvedEnv: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(params.baseEnv)) { + assignChildEnvValue({ env: resolvedEnv, key, platform: params.platform, value }); + } + for (const [key, value] of Object.entries(params.env ?? {})) { + assignChildEnvValue({ env: resolvedEnv, key, platform: params.platform, value }); + } + return resolvedEnv; +} + +export function shouldSpawnWithShell(params: { + resolvedCommand: string; + platform: NodeJS.Platform; +}): boolean { + // SECURITY: never enable `shell` for argv-based execution. + // `shell` routes through cmd.exe on Windows, which turns untrusted argv values + // (like chat prompts passed as CLI args) into command-injection primitives. + // If you need a shell, use an explicit shell-wrapper argv (e.g. `cmd.exe /c ...`) + // and validate/escape at the call site. + void params; + return false; +} + +export type SpawnCommandOptions = Omit< + ExecaOptions, + "env" | "extendEnv" | "shell" | "windowsHide" | "windowsVerbatimArguments" +> & { + baseEnv?: NodeJS.ProcessEnv; + env?: NodeJS.ProcessEnv; + windowsVerbatimArguments?: boolean; +}; + +export function spawnCommandWithInvocation< + OptionsType extends SpawnCommandOptions = SpawnCommandOptions, +>( + argv: string[], + options: OptionsType = {} as OptionsType, +): { + child: ResultPromise; + invocation: ReturnType; +} { + const { baseEnv, env, windowsVerbatimArguments, ...execaOptions } = options; + const commandEnv = resolveCommandEnv({ argv, baseEnv, env }); + const invocation = resolveSafeChildProcessInvocation({ + argv, + cwd: execaOptions.cwd, + env: commandEnv, + windowsVerbatimArguments, + }); + const child = execa(invocation.command, invocation.args, { + ...execaOptions, + env: commandEnv, + extendEnv: false, + shell: false, + windowsHide: invocation.windowsHide, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }) as unknown as ResultPromise; + return { child, invocation }; +} + +/** Spawn through the canonical argv, environment, and Windows safety boundary. */ +export function spawnCommand( + argv: string[], + options: OptionsType = {} as OptionsType, +): ResultPromise { + return spawnCommandWithInvocation(argv, options).child; +} + +export function resolveCommandEnv(params: { + argv: string[]; + env?: NodeJS.ProcessEnv; + baseEnv?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}): NodeJS.ProcessEnv { + const baseEnv = params.baseEnv ?? process.env; + const platform = params.platform ?? process.platform; + const argv = params.argv; + const shouldSuppressNpmFund = (() => { + const cmd = path.basename(argv[0] ?? ""); + if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") { + return true; + } + if (cmd === "node" || cmd === "node.exe") { + const script = argv[1] ?? ""; + return script.includes("npm-cli.js"); + } + return false; + })(); + + const resolvedEnv = mergeChildEnv({ baseEnv, env: params.env, platform }); + if (shouldSuppressNpmFund) { + if (resolvedEnv.NPM_CONFIG_FUND == null) { + resolvedEnv.NPM_CONFIG_FUND = "false"; + } + if (resolvedEnv.npm_config_fund == null) { + resolvedEnv.npm_config_fund = "false"; + } + } + return markOpenClawExecEnv(resolvedEnv); +} diff --git a/src/process/exec-termination.ts b/src/process/exec-termination.ts new file mode 100644 index 000000000000..dacffd8b2034 --- /dev/null +++ b/src/process/exec-termination.ts @@ -0,0 +1,144 @@ +import process from "node:process"; +import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; +import { COMMAND_PROCESS_TREE_KILL_GRACE_MS, spawnCommand } from "./exec-spawn.js"; +import { killProcessTree as terminateProcessTree } from "./kill-tree.js"; + +const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000; + +type TerminationChild = { + pid?: number; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill(signal?: NodeJS.Signals | number): boolean; +}; + +export function createCommandTerminationController(params: { + child: TerminationChild; + cancelController: AbortController; + baseEnv?: NodeJS.ProcessEnv; + env?: NodeJS.ProcessEnv; + killProcessTree?: boolean; + isChildExited: () => boolean; + isCommandSettled: () => boolean; +}): { terminate: () => boolean; settle: () => Promise } { + let processTreeSettleAt: number | undefined; + let windowsTerminationPromise: Promise | undefined; + + const isDirectChildAlive = () => + !params.isChildExited() && params.child.exitCode == null && params.child.signalCode == null; + const killDirectChild = () => { + if (isDirectChildAlive()) { + params.child.kill("SIGKILL"); + } + }; + const spawnTaskkillOrFallback = (args: string[], onSpawnError: () => void) => { + try { + const taskkillChild = spawnCommand([getWindowsSystem32ExePath("taskkill.exe"), ...args], { + baseEnv: params.baseEnv, + env: params.env, + forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS, + reject: false, + stdio: "ignore", + timeout: WINDOWS_TASKKILL_TIMEOUT_MS, + }); + return taskkillChild.then( + (result) => { + if (result.failed && result.exitCode === undefined) { + onSpawnError(); + } + return result; + }, + () => { + onSpawnError(); + return undefined; + }, + ); + } catch { + onSpawnError(); + return undefined; + } + }; + const startWindowsTermination = (childPid: number, graceful: boolean): void => { + const taskkills: Promise[] = []; + const startTaskkill = (args: string[]) => { + const taskkill = spawnTaskkillOrFallback(args, killDirectChild); + if (taskkill) { + taskkills.push(taskkill); + } + }; + windowsTerminationPromise = (async () => { + if (graceful) { + startTaskkill(["/PID", String(childPid), "/T"]); + await new Promise((resolve) => { + const timer = setTimeout(resolve, COMMAND_PROCESS_TREE_KILL_GRACE_MS); + timer.unref(); + }); + if (isDirectChildAlive()) { + startTaskkill(["/PID", String(childPid), "/T", "/F"]); + } + } else { + startTaskkill(["/PID", String(childPid), "/T", "/F"]); + } + // taskkill owns the live PID while it enumerates descendants. Abort Execa + // only after every started taskkill settles, avoiding a reused-PID race. + await Promise.allSettled(taskkills); + if (!params.isCommandSettled()) { + params.cancelController.abort(); + } + })(); + }; + + const terminate = (): boolean => { + const childPid = params.child.pid; + const directChildAlive = isDirectChildAlive(); + if (process.platform === "win32" && !directChildAlive) { + // taskkill /T requires a live root PID. Retrying a dead, reusable PID can + // target an unrelated tree; stronger ownership requires a spawn-time Job Object. + return false; + } + if (params.killProcessTree && typeof childPid === "number") { + processTreeSettleAt ??= Date.now() + COMMAND_PROCESS_TREE_KILL_GRACE_MS; + if (process.platform === "win32") { + startWindowsTermination(childPid, true); + return true; + } + terminateProcessTree(childPid, { graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS }); + return false; + } + if (!directChildAlive) { + return false; + } + if (process.platform === "win32" && typeof childPid === "number") { + startWindowsTermination(childPid, false); + return true; + } + return false; + }; + + const settle = async (): Promise => { + if (windowsTerminationPromise) { + await windowsTerminationPromise; + } + if ( + !params.killProcessTree || + processTreeSettleAt === undefined || + typeof params.child.pid !== "number" + ) { + return; + } + // A direct child can exit before its descendants finish the graceful + // signal. Keep the wrapper pending through that grace window, then ensure + // the detached group cannot outlive the completed command result. + const remainingMs = Math.max(0, processTreeSettleAt - Date.now()); + if (remainingMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, remainingMs); + }); + } + if (process.platform !== "win32") { + terminateProcessTree(params.child.pid, { force: true }); + } + }; + + return { terminate, settle }; +} diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 0c7b96aa86ee..36d97c971037 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -3,11 +3,13 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import process from "node:process"; import { describe, expect, it, vi } from "vitest"; +import { setVerbose } from "../global-state.js"; import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { attachChildProcessBridge } from "./child-process-bridge.js"; import { resolveCommandEnv, resolveProcessExitCode, + runCommandBuffered, runCommandWithTimeout, runExec, shouldSpawnWithShell, @@ -151,6 +153,37 @@ describe("runCommandWithTimeout", () => { }); }); + 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 () => { @@ -236,6 +269,314 @@ describe("runCommandWithTimeout", () => { expect(result.preservedStdoutLines).toEqual(["x".repeat(22)]); }); + + 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("keeps truncated UTF-8 output on code point boundaries", async () => { + const result = await runCommandWithTimeout( + [process.execPath, "-e", "process.stdout.write('a😀z')"], + { + maxOutputBytes: 3, + timeoutMs: 3_000, + }, + ); + + expect(result.stdout).toBe("z"); + expect(result.stdout).not.toContain("�"); + expect(result.stdoutTruncatedBytes).toBe(5); + }); + + it("discards an entirely partial UTF-8 head", async () => { + const result = await runCommandWithTimeout( + [process.execPath, "-e", "process.stdout.write('😀')"], + { + maxOutputBytes: 3, + 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("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")( + "force-kills inherited-pipe descendants after the direct child exits", + { timeout: 5_000 }, + async () => { + const descendantSource = + "process.on('SIGTERM', () => {}); setInterval(() => process.stdout.write('.'), 20)"; + const parentSource = [ + "const { spawn } = require('node:child_process')", + `const child = spawn(${JSON.stringify(process.execPath)}, ['-e', ${JSON.stringify(descendantSource)}], { stdio: ['ignore', 'inherit', 'inherit'] })`, + "child.unref()", + "process.stdout.write(`PID:${child.pid}\\n`)", + ].join(";"); + const result = await runCommandBuffered([process.execPath, "-e", parentSource], { + timeoutMs: 50, + }); + const pidMatch = result.stdout.toString().match(/PID:(\d+)/u); + if (!pidMatch) { + throw new Error(`missing descendant pid in ${result.stdout.toString()}`); + } + const descendantPid = Number(pidMatch[1]); + + try { + expect(result).toMatchObject({ code: null, termination: "timeout" }); + let descendantExited = false; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + process.kill(descendantPid, 0); + } catch { + descendantExited = true; + break; + } + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + expect(descendantExited).toBe(true); + } finally { + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // Already gone. + } + } + }, + ); + + 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", () => { @@ -251,6 +592,48 @@ describe("runExec", () => { 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("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", () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index 1f5667317be1..16811614ecd2 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -1,120 +1,41 @@ // Exec helpers run subprocesses with normalized output, timeout, and abort handling. -import path from "node:path"; -import process from "node:process"; -import { StringDecoder } from "node:string_decoder"; -import { expectDefined } from "@openclaw/normalization-core"; -import { execa, type Options as ExecaOptions, type ResultPromise } from "execa"; import { danger, shouldLogVerbose } from "../globals.js"; -import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js"; import { decodeWindowsOutputBuffer, resolveWindowsConsoleEncoding, } from "../infra/windows-encoding.js"; -import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; import { logDebug, logError } from "../logger.js"; import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; -import { truncateUtf8Suffix } from "../utils/utf8-truncate.js"; import { releaseChildProcessOutputAfterExit } from "./child-process.js"; -import { killProcessTree as terminateProcessTree } from "./kill-tree.js"; -import { resolveCommandStdio } from "./spawn-utils.js"; -import { resolveSafeChildProcessInvocation } from "./windows-command.js"; +import { resolveMaxOutputBytes, type CommandOutputStream } from "./exec-output.js"; +import { runCommandWithTimeout } from "./exec-runner.js"; +import { COMMAND_PROCESS_TREE_KILL_GRACE_MS, spawnCommand } from "./exec-spawn.js"; -function assignChildEnvValue(params: { - env: NodeJS.ProcessEnv; - key: string; - platform: NodeJS.Platform; - value: string | undefined; -}): void { - if (params.value === undefined) { - return; - } - if (params.platform === "win32") { - const normalizedKey = params.key.toLowerCase(); - for (const existingKey of Object.keys(params.env)) { - if (existingKey.toLowerCase() === normalizedKey && existingKey !== params.key) { - delete params.env[existingKey]; - } - } - } - params.env[params.key] = params.value; -} +export { runCommandWithTimeout } from "./exec-runner.js"; +export type { CommandOptions } from "./exec-runner.js"; +export { isPlainCommandExitFailure, resolveProcessExitCode } from "./exec-result.js"; +export type { SpawnResult } from "./exec-result.js"; +export { resolveCommandEnv, shouldSpawnWithShell, spawnCommand } from "./exec-spawn.js"; +export type { SpawnCommandOptions } from "./exec-spawn.js"; -function mergeChildEnv(params: { - baseEnv: NodeJS.ProcessEnv; - env?: NodeJS.ProcessEnv; - platform: NodeJS.Platform; -}): NodeJS.ProcessEnv { - const resolvedEnv: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(params.baseEnv)) { - assignChildEnvValue({ env: resolvedEnv, key, platform: params.platform, value }); - } - for (const [key, value] of Object.entries(params.env ?? {})) { - assignChildEnvValue({ env: resolvedEnv, key, platform: params.platform, value }); - } - return resolvedEnv; -} +const DEFAULT_EXEC_MAX_BUFFER_BYTES = 1024 * 1024; -export function shouldSpawnWithShell(params: { - resolvedCommand: string; - platform: NodeJS.Platform; -}): boolean { - // SECURITY: never enable `shell` for argv-based execution. - // `shell` routes through cmd.exe on Windows, which turns untrusted argv values - // (like chat prompts passed as CLI args) into command-injection primitives. - // If you need a shell, use an explicit shell-wrapper argv (e.g. `cmd.exe /c ...`) - // and validate/escape at the call site. - void params; - return false; -} - -export type SpawnCommandOptions = Omit< - ExecaOptions, - "env" | "extendEnv" | "shell" | "windowsHide" | "windowsVerbatimArguments" -> & { +export type RunExecOptions = { + timeoutMs?: number; + maxBuffer?: number; + logOutput?: boolean; + cwd?: string; baseEnv?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv; - windowsVerbatimArguments?: boolean; + input?: string | Uint8Array; + signal?: AbortSignal; }; -function spawnCommandWithInvocation( - argv: string[], - options: OptionsType = {} as OptionsType, -): { - child: ResultPromise; - invocation: ReturnType; -} { - const { baseEnv, env, windowsVerbatimArguments, ...execaOptions } = options; - const commandEnv = resolveCommandEnv({ argv, baseEnv, env }); - const invocation = resolveSafeChildProcessInvocation({ - argv, - cwd: execaOptions.cwd, - env: commandEnv, - windowsVerbatimArguments, - }); - const child = execa(invocation.command, invocation.args, { - ...execaOptions, - env: commandEnv, - extendEnv: false, - shell: false, - windowsHide: invocation.windowsHide, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }) as unknown as ResultPromise; - return { child, invocation }; -} - -/** Spawn through the canonical argv, environment, and Windows safety boundary. */ -export function spawnCommand( - argv: string[], - options: OptionsType = {} as OptionsType, -): ResultPromise { - return spawnCommandWithInvocation(argv, options).child; -} - // Simple promise-wrapped execFile with optional verbosity logging. export async function runExec( command: string, args: string[], - opts: number | { timeoutMs?: number; maxBuffer?: number; cwd?: string } = 10_000, + opts: number | RunExecOptions = 10_000, ): Promise<{ stdout: string; stderr: string }> { const timeout = typeof opts === "number" @@ -126,15 +47,19 @@ export async function runExec( typeof opts === "number" ? DEFAULT_EXEC_MAX_BUFFER_BYTES : (opts.maxBuffer ?? DEFAULT_EXEC_MAX_BUFFER_BYTES); - const cwd = typeof opts === "number" ? undefined : opts.cwd; + const resolvedOptions = typeof opts === "number" ? undefined : opts; try { const subprocess = spawnCommand([command, ...args], { - cwd, + baseEnv: resolvedOptions?.baseEnv, + cancelSignal: resolvedOptions?.signal, + cwd: resolvedOptions?.cwd, encoding: "buffer", + env: resolvedOptions?.env, forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS, + ...(resolvedOptions?.input !== undefined ? { input: resolvedOptions.input } : {}), maxBuffer, reject: true, - stdin: "ignore", + stdin: resolvedOptions?.input === undefined ? "ignore" : undefined, stripFinalNewline: false, timeout, }); @@ -149,7 +74,7 @@ export async function runExec( buffer: Buffer.from(stderr), windowsEncoding, }); - if (shouldLogVerbose()) { + if (resolvedOptions?.logOutput !== false && shouldLogVerbose()) { if (decodedStdout.trim()) { logDebug(decodedStdout.trim()); } @@ -183,512 +108,127 @@ export async function runExec( }); } } - if (shouldLogVerbose()) { - logError(danger(`Command failed: ${command} ${args.join(" ")}`)); + if (resolvedOptions?.logOutput !== false && shouldLogVerbose()) { + logError(danger(`Command failed: ${command}`)); } throw err; } } -export type SpawnResult = { - pid?: number; - stdout: string; - stderr: string; - stdoutTruncatedBytes?: number; - stderrTruncatedBytes?: number; - preservedStdoutLines?: string[]; - preservedStderrLines?: string[]; +type BufferedCommandOptions = { + timeoutMs?: number; + cwd?: string; + input?: string | Uint8Array; + baseEnv?: NodeJS.ProcessEnv; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + maxOutputBytes?: number | { stdout?: number; stderr?: number }; + discardOutput?: { stdout?: boolean; stderr?: boolean }; + tolerateOutputError?: { stdout?: boolean; stderr?: boolean }; +}; + +type BufferedCommandResult = { + stdout: Buffer; + stderr: Buffer; code: number | null; signal: NodeJS.Signals | null; killed: boolean; - termination: "exit" | "timeout" | "no-output-timeout" | "signal"; - noOutputTimedOut?: boolean; + termination: "exit" | "timeout" | "signal" | "output-limit" | "error"; + outputLimitStream?: CommandOutputStream; + errorStream?: CommandOutputStream; + error?: Error; }; -export type CommandOptions = { - timeoutMs: number; - cwd?: string; - input?: string; - baseEnv?: NodeJS.ProcessEnv; - env?: NodeJS.ProcessEnv; - windowsVerbatimArguments?: boolean; - noOutputTimeoutMs?: number; - signal?: AbortSignal; - maxOutputBytes?: number; - maxPreservedOutputLines?: number; - preserveOutputLine?: (line: string, stream: "stdout" | "stderr") => boolean; - killProcessTree?: boolean; -}; - -const COMMAND_PROCESS_TREE_KILL_GRACE_MS = 300; -const WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS = 250; -const WINDOWS_CLOSE_STATE_POLL_MS = 10; -const DEFAULT_EXEC_MAX_BUFFER_BYTES = 1024 * 1024; -const TIMEOUT_EXIT_CODE = 124; -const DEFAULT_COMMAND_OUTPUT_MAX_BYTES = 16 * 1024 * 1024; -const MAX_PRESERVED_PENDING_LINE_BYTES = 8 * 1024; - -type CapturedOutputBuffers = { - chunks: Buffer[]; - bytes: number; - truncatedBytes: number; - preservedLines: string[]; - decoder: StringDecoder; - pendingLine: string; -}; - -function normalizeMaxOutputBytes(value: number | undefined): number { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return DEFAULT_COMMAND_OUTPUT_MAX_BYTES; - } - return Math.max(1, Math.floor(value)); -} - -function appendCapturedOutput( - capture: CapturedOutputBuffers, - chunk: Buffer | string, - maxBytes: number, -): void { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - if (buffer.byteLength >= maxBytes) { - capture.chunks = [Buffer.from(buffer.subarray(buffer.byteLength - maxBytes))]; - capture.truncatedBytes += capture.bytes + buffer.byteLength - maxBytes; - capture.bytes = maxBytes; - return; - } - - capture.chunks.push(buffer); - capture.bytes += buffer.byteLength; - while (capture.bytes > maxBytes && capture.chunks.length > 0) { - const first = expectDefined(capture.chunks[0], "chunks entry at 0"); - const overflow = capture.bytes - maxBytes; - if (first.byteLength <= overflow) { - capture.chunks.shift(); - capture.bytes -= first.byteLength; - capture.truncatedBytes += first.byteLength; - } else { - capture.chunks[0] = Buffer.from(first.subarray(overflow)); - capture.bytes -= overflow; - capture.truncatedBytes += overflow; - } - } -} - -function trimPreservedPendingLine(value: string, maxBytes: number): string { - return truncateUtf8Suffix(value, maxBytes); -} - -function appendPreservedOutputLines(params: { - capture: CapturedOutputBuffers; - chunk: Buffer | string; - stream: "stdout" | "stderr"; - preserveOutputLine?: CommandOptions["preserveOutputLine"]; - maxPreservedOutputLines: number; - maxPendingLineBytes: number; -}): void { - if (!params.preserveOutputLine || params.maxPreservedOutputLines <= 0) { - return; - } - const text = Buffer.isBuffer(params.chunk) - ? params.capture.decoder.write(params.chunk) - : params.chunk; - if (!text) { - return; - } - const lines = (params.capture.pendingLine + text).split(/\r?\n/); - params.capture.pendingLine = trimPreservedPendingLine( - lines.pop() ?? "", - params.maxPendingLineBytes, - ); - for (const line of lines) { - if ( - params.capture.preservedLines.length < params.maxPreservedOutputLines && - params.preserveOutputLine(line, params.stream) - ) { - params.capture.preservedLines.push(line); - } - } -} - -function flushPreservedOutputLine(params: { - capture: CapturedOutputBuffers; - stream: "stdout" | "stderr"; - preserveOutputLine?: CommandOptions["preserveOutputLine"]; - maxPreservedOutputLines: number; - maxPendingLineBytes: number; -}): void { - if (!params.preserveOutputLine || params.maxPreservedOutputLines <= 0) { - return; - } - const trailing = trimPreservedPendingLine( - params.capture.pendingLine + params.capture.decoder.end(), - params.maxPendingLineBytes, - ); - params.capture.pendingLine = ""; - if ( - trailing && - params.capture.preservedLines.length < params.maxPreservedOutputLines && - params.preserveOutputLine(trailing, params.stream) - ) { - params.capture.preservedLines.push(trailing); - } -} -export function resolveProcessExitCode(params: { - explicitCode: number | null | undefined; - childExitCode: number | null | undefined; - resolvedSignal: NodeJS.Signals | null; - usesWindowsExitCodeShim: boolean; - timedOut: boolean; - noOutputTimedOut: boolean; - killIssuedByTimeout: boolean; - killIssuedByAbort?: boolean; -}): number | null { - return ( - params.explicitCode ?? - params.childExitCode ?? - (params.usesWindowsExitCodeShim && - params.resolvedSignal == null && - !params.timedOut && - !params.noOutputTimedOut && - !params.killIssuedByTimeout && - !params.killIssuedByAbort - ? 0 - : null) - ); -} - -export function resolveCommandEnv(params: { - argv: string[]; - env?: NodeJS.ProcessEnv; - baseEnv?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; -}): NodeJS.ProcessEnv { - const baseEnv = params.baseEnv ?? process.env; - const platform = params.platform ?? process.platform; - const argv = params.argv; - const shouldSuppressNpmFund = (() => { - const cmd = path.basename(argv[0] ?? ""); - if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") { - return true; - } - if (cmd === "node" || cmd === "node.exe") { - const script = argv[1] ?? ""; - return script.includes("npm-cli.js"); - } - return false; - })(); - - const resolvedEnv = mergeChildEnv({ baseEnv, env: params.env, platform }); - if (shouldSuppressNpmFund) { - if (resolvedEnv.NPM_CONFIG_FUND == null) { - resolvedEnv.NPM_CONFIG_FUND = "false"; - } - if (resolvedEnv.npm_config_fund == null) { - resolvedEnv.npm_config_fund = "false"; - } - } - return markOpenClawExecEnv(resolvedEnv); -} - -export async function runCommandWithTimeout( +/** Run a one-shot command with raw, independently capped stdout and stderr buffers. */ +export async function runCommandBuffered( argv: string[], - optionsOrTimeout: number | CommandOptions, -): Promise { - const options: CommandOptions = - typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout; - const { timeoutMs, cwd, input, baseEnv, env, noOutputTimeoutMs, signal, killProcessTree } = - options; - const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1); - const hasInput = input !== undefined; - const stdio = resolveCommandStdio({ hasInput, preferInherit: true }); - - if (signal?.aborted) { + options: BufferedCommandOptions = {}, +): Promise { + if (options.signal?.aborted) { return { - stdout: "", - stderr: "", + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), code: null, signal: null, killed: false, termination: "signal", - noOutputTimedOut: false, + ...(options.signal.reason instanceof Error ? { error: options.signal.reason } : {}), }; } - const stdoutCapture: CapturedOutputBuffers = { - chunks: [], - bytes: 0, - truncatedBytes: 0, - preservedLines: [], - decoder: new StringDecoder("utf8"), - pendingLine: "", - }; - const stderrCapture: CapturedOutputBuffers = { - chunks: [], - bytes: 0, - truncatedBytes: 0, - preservedLines: [], - decoder: new StringDecoder("utf8"), - pendingLine: "", - }; - const maxOutputBytes = normalizeMaxOutputBytes(options.maxOutputBytes); - const maxPreservedPendingLineBytes = Math.min(maxOutputBytes, MAX_PRESERVED_PENDING_LINE_BYTES); - const maxPreservedOutputLines = Math.max(0, Math.floor(options.maxPreservedOutputLines ?? 16)); - const windowsEncoding = resolveWindowsConsoleEncoding(); - const cancelController = new AbortController(); - let termination: SpawnResult["termination"] | undefined; - let childExitState: { code: number | null; signal: NodeJS.Signals | null } | undefined; - let childExited = false; - let noOutputTimer: NodeJS.Timeout | undefined; - let processTreeForceKillTimer: NodeJS.Timeout | undefined; - - const { child, invocation } = spawnCommandWithInvocation(argv, { - buffer: false, - cancelSignal: cancelController.signal, - cwd, - detached: Boolean(killProcessTree && process.platform !== "win32"), - encoding: "buffer", - baseEnv, - env, - forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS, - ...(hasInput ? { input } : {}), - reject: false, - stdio, - stripFinalNewline: false, - windowsVerbatimArguments: options.windowsVerbatimArguments, - }); - const releaseOutput = releaseChildProcessOutputAfterExit(child); - child.once("exit", (code, signalValue) => { - childExited = true; - childExitState = { code, signal: signalValue }; - }); - - const clearNoOutputTimer = () => { - if (noOutputTimer) { - clearTimeout(noOutputTimer); - noOutputTimer = undefined; - } - }; - const clearProcessTreeForceKillTimer = () => { - if (processTreeForceKillTimer) { - clearTimeout(processTreeForceKillTimer); - processTreeForceKillTimer = undefined; - } - }; - const killDirectChild = () => { - if (!childExited && child.exitCode == null && child.signalCode == null) { - child.kill("SIGKILL"); - } - }; - const spawnTaskkillOrFallback = (args: string[], onSpawnError: () => void): boolean => { - try { - const taskkillChild = spawnCommand([getWindowsSystem32ExePath("taskkill.exe"), ...args], { - baseEnv, - env, - reject: false, - stdio: "ignore", - }); - void taskkillChild.then((result) => { - if (result.failed && result.exitCode === undefined) { - onSpawnError(); - } - }); + const chunks: Record = { stdout: [], stderr: [] }; + const capturedBytes: Record = { stdout: 0, stderr: 0 }; + let outputLimitStream: CommandOutputStream | undefined; + const appendChunk = (chunk: Buffer, stream: CommandOutputStream): boolean => { + if (options.discardOutput?.[stream]) { return true; - } catch { - onSpawnError(); + } + const maxBytes = resolveMaxOutputBytes(options.maxOutputBytes, stream); + const remaining = Math.max(0, maxBytes - capturedBytes[stream]); + if (remaining > 0) { + const captured = Buffer.from(chunk.subarray(0, remaining)); + chunks[stream].push(captured); + capturedBytes[stream] += captured.byteLength; + } + if (chunk.byteLength > remaining) { + outputLimitStream ??= stream; return false; } + return true; }; - const terminateChild = () => { - if (childExited || child.exitCode != null || child.signalCode != null) { - return; - } - if (process.platform === "win32" && typeof child.pid === "number") { - if (killProcessTree) { - const taskkillStarted = spawnTaskkillOrFallback(["/PID", String(child.pid), "/T"], () => { - clearProcessTreeForceKillTimer(); - killDirectChild(); - }); - if (taskkillStarted) { - processTreeForceKillTimer = setTimeout(() => { - processTreeForceKillTimer = undefined; - if (childExited || child.exitCode != null || child.signalCode != null) { - return; - } - spawnTaskkillOrFallback(["/PID", String(child.pid), "/T", "/F"], killDirectChild); - }, COMMAND_PROCESS_TREE_KILL_GRACE_MS); - processTreeForceKillTimer.unref(); - } - } else { - spawnTaskkillOrFallback(["/PID", String(child.pid), "/T", "/F"], killDirectChild); - } - } else if (killProcessTree && typeof child.pid === "number") { - terminateProcessTree(child.pid, { graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS }); - } - }; - const cancel = (reason: Exclude) => { - if (termination || childExited) { - return; - } - termination = reason; - terminateChild(); - // Windows tree termination is owned by trusted taskkill. Aborting Execa here - // would terminate only the direct child before the tree gets its grace period. - if (process.platform !== "win32" || typeof child.pid !== "number") { - cancelController.abort(); - } - }; - const shouldTrackOutputTimeout = - typeof noOutputTimeoutMs === "number" && - Number.isFinite(noOutputTimeoutMs) && - noOutputTimeoutMs > 0; - const resolvedNoOutputTimeoutMs = shouldTrackOutputTimeout - ? resolveTimerTimeoutMs(noOutputTimeoutMs, 1) - : undefined; - const armNoOutputTimer = () => { - if (resolvedNoOutputTimeoutMs === undefined || childExited) { - return; - } - clearNoOutputTimer(); - noOutputTimer = setTimeout(() => cancel("no-output-timeout"), resolvedNoOutputTimeoutMs); - }; + const capturedOutput = (stream: CommandOutputStream) => + Buffer.concat(chunks[stream], capturedBytes[stream]); - const timeoutTimer = setTimeout(() => cancel("timeout"), resolvedTimeoutMs); - const onAbort = () => cancel("signal"); - signal?.addEventListener("abort", onAbort, { once: true }); - armNoOutputTimer(); - - child.stdout?.on("data", (chunk) => { - appendPreservedOutputLines({ - capture: stdoutCapture, - chunk, - stream: "stdout", - preserveOutputLine: options.preserveOutputLine, - maxPreservedOutputLines, - maxPendingLineBytes: maxPreservedPendingLineBytes, + try { + const result = await runCommandWithTimeout(argv, { + baseEnv: options.baseEnv, + cwd: options.cwd, + env: options.env, + input: options.input, + killProcessTree: true, + onOutputChunk: appendChunk, + outputCapture: "discard", + signal: options.signal, + timeoutMs: options.timeoutMs, + tolerateOutputError: { + stdout: options.discardOutput?.stdout || options.tolerateOutputError?.stdout, + stderr: options.discardOutput?.stderr || options.tolerateOutputError?.stderr, + }, }); - appendCapturedOutput(stdoutCapture, chunk, maxOutputBytes); - armNoOutputTimer(); - }); - child.stderr?.on("data", (chunk) => { - appendPreservedOutputLines({ - capture: stderrCapture, - chunk, - stream: "stderr", - preserveOutputLine: options.preserveOutputLine, - maxPreservedOutputLines, - maxPendingLineBytes: maxPreservedPendingLineBytes, - }); - appendCapturedOutput(stderrCapture, chunk, maxOutputBytes); - armNoOutputTimer(); - }); - - const result = await child.finally(() => { - clearTimeout(timeoutTimer); - clearNoOutputTimer(); - clearProcessTreeForceKillTimer(); - signal?.removeEventListener("abort", onAbort); - releaseOutput(); - }); - // Patched Node can report null/null after a cmd.exe shim exits. Execa turns - // that into a cause-less failure; preserve the shim fallback only post-spawn. - const isCauseLessWindowsShimResult = - !termination && - invocation.usesWindowsExitCodeShim && - typeof child.pid === "number" && - result.code === undefined && - result.cause === undefined && - !result.timedOut && - !result.isCanceled && - !result.isMaxBuffer && - !result.isTerminated; - if (isCauseLessWindowsShimResult) { - // A patched Windows runtime can populate exitCode shortly after close. - // Settle that state before the shim fallback can infer a clean exit. - for ( - let elapsedMs = 0; - elapsedMs < WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS; - elapsedMs += WINDOWS_CLOSE_STATE_POLL_MS - ) { - if ( - childExitState?.code != null || - childExitState?.signal != null || - child.exitCode != null || - child.signalCode != null - ) { - break; - } - await new Promise((resolve) => { - setTimeout(resolve, WINDOWS_CLOSE_STATE_POLL_MS); - }); - } + const termination: BufferedCommandResult["termination"] = result.outputLimitExceeded + ? "output-limit" + : result.termination === "no-output-timeout" + ? "timeout" + : result.termination; + return { + stdout: capturedOutput("stdout"), + stderr: capturedOutput("stderr"), + code: termination === "exit" ? result.code : null, + signal: result.signal, + killed: result.killed, + termination, + ...(outputLimitStream ? { outputLimitStream } : {}), + ...(result.outputErrorStream ? { errorStream: result.outputErrorStream } : {}), + }; + } catch (error) { + const commandError = error instanceof Error ? error : new Error("Command execution failed"); + const metadata = commandError as Error & { + exitCode?: unknown; + outputErrorStream?: unknown; + }; + const errorStream = + metadata.outputErrorStream === "stdout" || metadata.outputErrorStream === "stderr" + ? metadata.outputErrorStream + : undefined; + return { + stdout: capturedOutput("stdout"), + stderr: capturedOutput("stderr"), + code: typeof metadata.exitCode === "number" ? metadata.exitCode : null, + signal: null, + killed: false, + termination: "error", + ...(errorStream ? { errorStream } : {}), + error: commandError, + }; } - if ( - result.failed && - !termination && - result.exitCode === undefined && - result.signal === undefined && - !isCauseLessWindowsShimResult - ) { - if (result instanceof Error) { - throw result; - } - throw new Error(`Failed to launch command: ${argv[0] ?? ""}`, { cause: result }); - } - - const resolvedSignal = result.signal ?? childExitState?.signal ?? child.signalCode ?? null; - const resolvedCode = resolveProcessExitCode({ - explicitCode: result.exitCode ?? childExitState?.code, - childExitCode: child.exitCode, - resolvedSignal, - usesWindowsExitCodeShim: invocation.usesWindowsExitCodeShim, - timedOut: termination === "timeout", - noOutputTimedOut: termination === "no-output-timeout", - killIssuedByTimeout: termination === "timeout" || termination === "no-output-timeout", - killIssuedByAbort: termination === "signal", - }); - termination ??= resolvedSignal != null || result.isTerminated ? "signal" : "exit"; - const normalizedCode = - termination === "timeout" || termination === "no-output-timeout" - ? resolvedCode == null || resolvedCode === 0 - ? TIMEOUT_EXIT_CODE - : resolvedCode - : resolvedCode; - - flushPreservedOutputLine({ - capture: stdoutCapture, - stream: "stdout", - preserveOutputLine: options.preserveOutputLine, - maxPreservedOutputLines, - maxPendingLineBytes: maxPreservedPendingLineBytes, - }); - flushPreservedOutputLine({ - capture: stderrCapture, - stream: "stderr", - preserveOutputLine: options.preserveOutputLine, - maxPreservedOutputLines, - maxPendingLineBytes: maxPreservedPendingLineBytes, - }); - - return { - pid: child.pid, - stdout: decodeWindowsOutputBuffer({ - buffer: Buffer.concat(stdoutCapture.chunks, stdoutCapture.bytes), - windowsEncoding, - }), - stderr: decodeWindowsOutputBuffer({ - buffer: Buffer.concat(stderrCapture.chunks, stderrCapture.bytes), - windowsEncoding, - }), - stdoutTruncatedBytes: stdoutCapture.truncatedBytes || undefined, - stderrTruncatedBytes: stderrCapture.truncatedBytes || undefined, - preservedStdoutLines: - stdoutCapture.preservedLines.length > 0 ? stdoutCapture.preservedLines : undefined, - preservedStderrLines: - stderrCapture.preservedLines.length > 0 ? stderrCapture.preservedLines : undefined, - code: normalizedCode, - signal: resolvedSignal, - killed: child.killed, - termination, - noOutputTimedOut: termination === "no-output-timeout", - }; } diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 1a172cfaf206..197cd31e16de 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -346,7 +346,7 @@ describe("Windows command execution", () => { }); }); - it("still rejects a Windows shim launch error without an exit state", async () => { + it("sanitizes a Windows shim launch error without an exit state", async () => { const command = createMockSubprocess({ autoFinish: false }); execaMock.mockReturnValueOnce(command); @@ -361,7 +361,32 @@ describe("Windows command execution", () => { failed: true, }); - await expect(resultPromise).rejects.toThrow("Failed to launch command: pnpm"); + await expect(resultPromise).rejects.toMatchObject({ + code: "ENOENT", + message: "Command failed during launch or output capture (ENOENT)", + }); + }); + }); + + it("does not time out after the direct child exits while output settles", async () => { + vi.useFakeTimers(); + const command = createMockSubprocess({ autoFinish: false }); + execaMock.mockReturnValueOnce(command); + + await withMockedWindowsPlatform(async () => { + const resultPromise = runCommandWithTimeout(["node", "quick.js"], { timeoutMs: 80 }); + command.exitCode = 0; + command.emit("exit", 0, null); + + await vi.advanceTimersByTimeAsync(81); + expect(execaMock).toHaveBeenCalledTimes(1); + expect(command.stdout.destroyed).toBe(false); + await vi.advanceTimersByTimeAsync(19); + expect(command.stdout.destroyed).toBe(true); + expect(command.stderr.destroyed).toBe(true); + + command.finish(); + await expect(resultPromise).resolves.toMatchObject({ code: 0, termination: "exit" }); }); }); @@ -392,6 +417,85 @@ describe("Windows command execution", () => { }); }); + it("keeps forced Windows tree escalation after graceful taskkill returns nonzero", async () => { + vi.useFakeTimers(); + const command = createMockSubprocess({ autoFinish: false }); + execaMock + .mockImplementationOnce(() => command) + .mockImplementationOnce(() => createMockSubprocess({ exitCode: 1 })) + .mockImplementation(() => createMockSubprocess()); + + await withMockedWindowsPlatform(async () => { + const resultPromise = runCommandWithTimeout(["node", "idle.js"], { + killProcessTree: true, + timeoutMs: 80, + }); + await vi.advanceTimersByTimeAsync(81); + expect(requireExecaCall(1)[1]).toEqual(["/PID", "1234", "/T"]); + + await vi.advanceTimersByTimeAsync(300); + expect(requireExecaCall(2)[1]).toEqual(["/PID", "1234", "/T", "/F"]); + command.finish({ signal: "SIGKILL" }); + + await expect(resultPromise).resolves.toMatchObject({ code: 124, termination: "timeout" }); + }); + }); + + it("waits for forced taskkill before aborting the live Windows root", async () => { + vi.useFakeTimers(); + const command = createMockSubprocess({ autoFinish: false }); + const forcedTaskkill = createMockSubprocess({ autoFinish: false }); + execaMock + .mockImplementationOnce(() => command) + .mockImplementationOnce(() => createMockSubprocess()) + .mockImplementationOnce(() => forcedTaskkill); + + await withMockedWindowsPlatform(async () => { + const resultPromise = runCommandWithTimeout(["node", "idle.js"], { + killProcessTree: true, + timeoutMs: 80, + }); + const cancelSignal = requireExecaCall(0)[2].cancelSignal as AbortSignal; + + await vi.advanceTimersByTimeAsync(381); + expect(requireExecaCall(2)[1]).toEqual(["/PID", "1234", "/T", "/F"]); + expect(cancelSignal.aborted).toBe(false); + + forcedTaskkill.finish(); + await vi.advanceTimersByTimeAsync(0); + expect(cancelSignal.aborted).toBe(true); + + command.finish({ signal: "SIGKILL" }); + await expect(resultPromise).resolves.toMatchObject({ code: 124, termination: "timeout" }); + }); + }); + + it("waits for immediate forced taskkill before aborting the Windows root", async () => { + vi.useFakeTimers(); + const command = createMockSubprocess({ autoFinish: false }); + const forcedTaskkill = createMockSubprocess({ autoFinish: false }); + execaMock.mockImplementationOnce(() => command).mockImplementationOnce(() => forcedTaskkill); + + await withMockedWindowsPlatform(async () => { + const resultPromise = runCommandWithTimeout(["node", "idle.js"], { + killProcessTree: false, + timeoutMs: 80, + }); + const cancelSignal = requireExecaCall(0)[2].cancelSignal as AbortSignal; + + await vi.advanceTimersByTimeAsync(81); + expect(requireExecaCall(1)[1]).toEqual(["/PID", "1234", "/T", "/F"]); + expect(cancelSignal.aborted).toBe(false); + + forcedTaskkill.finish(); + await vi.advanceTimersByTimeAsync(0); + expect(cancelSignal.aborted).toBe(true); + + command.finish({ signal: "SIGKILL" }); + await expect(resultPromise).resolves.toMatchObject({ code: 124, termination: "timeout" }); + }); + }); + it("decodes GBK stdout and stderr from runExec", async () => { execaMock.mockImplementationOnce(() => createMockSubprocess({ diff --git a/src/proxy-capture/ca.ts b/src/proxy-capture/ca.ts index 7f2b6f4d619d..0f3aed9d8056 100644 --- a/src/proxy-capture/ca.ts +++ b/src/proxy-capture/ca.ts @@ -1,11 +1,8 @@ // Proxy capture CA helpers create and inspect local capture CA certificates. -import { execFile } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { promisify } from "node:util"; import { resolveSystemBin } from "../infra/resolve-system-bin.js"; - -const execFileAsync = promisify(execFile); +import { runExec } from "../process/exec.js"; // Ensure a short-lived root CA for local MITM debug proxy runs. Existing certs // are reused within the cert dir so repeated starts do not prompt regeneration. @@ -23,21 +20,25 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{ if (!openssl) { throw new Error("openssl is required to generate debug proxy certificates"); } - await execFileAsync(openssl, [ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-sha256", - "-days", - "7", - "-nodes", - "-keyout", - keyPath, - "-out", - certPath, - "-subj", - "/CN=OpenClaw Debug Proxy", - ]); + await runExec( + openssl, + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-sha256", + "-days", + "7", + "-nodes", + "-keyout", + keyPath, + "-out", + certPath, + "-subj", + "/CN=OpenClaw Debug Proxy", + ], + { logOutput: false }, + ); return { certPath, keyPath }; } diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 043cf88db36d..7ee646dc2a03 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -1,20 +1,8 @@ /** Tests SecretRef provider resolution for env, file, and exec sources. */ -import type { ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const spawnMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: (...args: Parameters) => - spawnMock(...args) ?? actual.spawn(...args), - }; -}); +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; import { @@ -749,70 +737,3 @@ describe("secret ref resolver", () => { }); }); }); - -describe("runExecResolver stream error handling", () => { - function createFakeChild(): ChildProcess { - const child = new EventEmitter() as EventEmitter & ChildProcess; - child.stdout = new EventEmitter() as EventEmitter & NonNullable; - child.stderr = new EventEmitter() as EventEmitter & NonNullable; - child.stdin = new EventEmitter() as EventEmitter & NonNullable; - child.stdin.write = vi.fn(() => true) as NonNullable["write"]; - child.stdin.end = vi.fn() as NonNullable["end"]; - Object.defineProperties(child, { - pid: { configurable: true, enumerable: true, get: () => 1234 }, - killed: { configurable: true, enumerable: true, get: () => false }, - }); - child.kill = vi.fn(() => true) as ChildProcess["kill"]; - return child; - } - - beforeEach(() => { - spawnMock.mockReset(); - }); - - it("swallows stdout and stderr stream errors without rejecting", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-stream-")); - const scriptPath = path.join(dir, "resolver.cjs"); - await fs.writeFile(scriptPath, "module.exports = {};", "utf8"); - await fs.chmod(scriptPath, 0o700); - - spawnMock.mockImplementation(() => { - const child = createFakeChild(); - const response = Buffer.from( - JSON.stringify({ protocolVersion: 1, values: { "openai/api-key": "ok" } }), - ); - queueMicrotask(() => { - child.stdout?.emit("error", new Error("stdout read failed")); - child.stdout?.emit("data", response); - child.stderr?.emit("error", new Error("stderr read failed")); - child.emit("close", 0, null); - }); - return child; - }); - - await expect( - resolveSecretRefString( - { source: "exec", provider: "execmain", id: "openai/api-key" }, - { - config: { - secrets: { - providers: { - execmain: { - source: "exec", - command: scriptPath, - args: [], - allowInsecurePath: true, - timeoutMs: 5_000, - noOutputTimeoutMs: 5_000, - maxOutputBytes: 16 * 1024, - }, - }, - }, - }, - }, - ), - ).resolves.toBe("ok"); - - await fs.rm(dir, { recursive: true, force: true }); - }); -}); diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index f81f10cfb319..258ed1fb2a76 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -1,5 +1,4 @@ /** Resolves SecretRef values from env, file, and exec secret providers. */ -import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; @@ -20,10 +19,7 @@ import { loadPluginManifestRegistry, type PluginManifestRegistry, } from "../plugins/manifest-registry.js"; -import { - forceKillChildProcessTree, - shouldDetachChildForProcessTree, -} from "../process/child-process-tree.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { inspectPathPermissions, safeStat } from "../security/audit-fs.js"; import { isPathInside } from "../security/scan-paths.js"; import { resolveUserPath } from "../utils.js"; @@ -474,139 +470,6 @@ async function resolveFileRefs(params: { return resolved; } -type ExecRunResult = { - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - termination: "exit" | "timeout" | "no-output-timeout"; -}; - -function isIgnorableStdinWriteError(error: unknown): boolean { - if (typeof error !== "object" || error === null || !("code" in error)) { - return false; - } - const code = String(error.code); - return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; -} - -async function runExecResolver(params: { - command: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - input: string; - timeoutMs: number; - noOutputTimeoutMs: number; - maxOutputBytes: number; -}): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(params.command, params.args, { - cwd: params.cwd, - env: params.env, - stdio: ["pipe", "pipe", "pipe"], - shell: false, - windowsHide: true, - detached: shouldDetachChildForProcessTree(), - }); - - let settled = false; - let stdout = ""; - let stderr = ""; - let timedOut = false; - let noOutputTimedOut = false; - let outputBytes = 0; - let noOutputTimer: NodeJS.Timeout | null = null; - const timeoutTimer = setTimeout(() => { - timedOut = true; - forceKillChildProcessTree(child); - }, params.timeoutMs); - - const clearTimers = () => { - clearTimeout(timeoutTimer); - if (noOutputTimer) { - clearTimeout(noOutputTimer); - noOutputTimer = null; - } - }; - - const armNoOutputTimer = () => { - if (noOutputTimer) { - clearTimeout(noOutputTimer); - } - noOutputTimer = setTimeout(() => { - noOutputTimedOut = true; - forceKillChildProcessTree(child); - }, params.noOutputTimeoutMs); - }; - - const append = (chunk: Buffer | string, target: "stdout" | "stderr") => { - const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); - outputBytes += Buffer.byteLength(text, "utf8"); - if (outputBytes > params.maxOutputBytes) { - forceKillChildProcessTree(child); - if (!settled) { - settled = true; - clearTimers(); - reject( - new Error(`Exec provider output exceeded maxOutputBytes (${params.maxOutputBytes}).`), - ); - } - return; - } - if (target === "stdout") { - stdout += text; - } else { - stderr += text; - } - armNoOutputTimer(); - }; - - armNoOutputTimer(); - child.on("error", (error) => { - if (settled) { - return; - } - settled = true; - clearTimers(); - reject(error); - }); - child.stdout?.on("error", () => {}); - child.stdout?.on("data", (chunk) => append(chunk, "stdout")); - child.stderr?.on("error", () => {}); - child.stderr?.on("data", (chunk) => append(chunk, "stderr")); - child.on("close", (code, signal) => { - if (settled) { - return; - } - settled = true; - clearTimers(); - resolve({ - stdout, - stderr, - code, - signal, - termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit", - }); - }); - - const handleStdinError = (error: unknown) => { - if (isIgnorableStdinWriteError(error) || settled) { - return; - } - settled = true; - clearTimers(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - child.stdin?.on("error", handleStdinError); - try { - child.stdin?.end(params.input); - } catch (error) { - handleStdinError(error); - } - }); -} - function parseExecValues(params: { providerName: string; ids: string[]; @@ -767,18 +630,24 @@ async function resolveExecRefs(params: { ); const jsonOnly = params.providerConfig.jsonOnly ?? true; - let result: ExecRunResult; + let result: Awaited>; try { - result = await runExecResolver({ - command: secureCommandPath, - args: params.providerConfig.args ?? [], - cwd: path.dirname(secureCommandPath), - env: childEnv, - input, - timeoutMs, - noOutputTimeoutMs, - maxOutputBytes, - }); + result = await runCommandWithTimeout( + [secureCommandPath, ...(params.providerConfig.args ?? [])], + { + baseEnv: {}, + cwd: path.dirname(secureCommandPath), + env: childEnv, + input, + killProcessTree: true, + maxCombinedOutputBytes: maxOutputBytes, + maxOutputBytes, + noOutputTimeoutMs, + outputCapture: "head", + terminateOnOutputLimit: true, + timeoutMs, + }, + ); } catch (err) { throwUnknownProviderResolutionError({ source: "exec", @@ -800,6 +669,13 @@ async function resolveExecRefs(params: { message: `Exec provider "${params.providerName}" produced no output for ${noOutputTimeoutMs}ms.`, }); } + if (result.outputLimitExceeded) { + throw providerResolutionError({ + source: "exec", + provider: params.providerName, + message: `Exec provider output exceeded maxOutputBytes (${maxOutputBytes}).`, + }); + } if (result.code !== 0) { throw providerResolutionError({ source: "exec", diff --git a/src/security/install-policy.test.ts b/src/security/install-policy.test.ts index 3a7c186a632a..5f4104ac482a 100644 --- a/src/security/install-policy.test.ts +++ b/src/security/install-policy.test.ts @@ -1,20 +1,8 @@ // Covers install-policy checks for packages and plugin installs. -import type { ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const spawnMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: (...args: Parameters) => - spawnMock(...args) ?? actual.spawn(...args), - }; -}); import type { OpenClawConfig } from "../config/types.openclaw.js"; import { killPidIfAlive, @@ -705,59 +693,3 @@ describe("runInstallPolicy", () => { }, ); }); - -describe("runPolicyCommand stream errors", () => { - function createFakeChild(): { - child: ChildProcess; - kill: ReturnType; - } { - const child = new EventEmitter() as EventEmitter & ChildProcess; - const kill = vi.fn(() => true); - child.stdout = new EventEmitter() as EventEmitter & NonNullable; - child.stderr = new EventEmitter() as EventEmitter & NonNullable; - child.stdin = new EventEmitter() as EventEmitter & NonNullable; - child.stdin.write = vi.fn(() => true) as NonNullable["write"]; - child.stdin.end = vi.fn() as NonNullable["end"]; - child.kill = kill as ChildProcess["kill"]; - return { child, kill }; - } - - beforeEach(() => { - spawnMock.mockReset(); - }); - - afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); - }); - - it.each(["stdout", "stderr", "stdin"] as const)( - "fails closed and kills the policy process after a %s stream error", - async (streamName) => { - const dir = await makeTempDir(); - const policyScriptPath = await writePolicyScript(dir); - const { child, kill } = createFakeChild(); - spawnMock.mockImplementation(() => { - queueMicrotask(() => { - child.stdout?.emit( - "data", - Buffer.from(JSON.stringify({ protocolVersion: 1, decision: "allow" })), - ); - child[streamName]?.emit("error", new Error(`${streamName} read failed`)); - child.emit("close", 0, null); - }); - return child; - }); - - const result = await runInstallPolicy({ - config: configWithPolicy(policyScriptPath, {}), - request: baseRequest(dir), - }); - - expect(result?.blocked?.code).toBe("security_scan_failed"); - expect(result?.blocked?.reason).toContain(`policy ${streamName} stream failed`); - expect(kill).toHaveBeenCalledWith("SIGKILL"); - }, - ); -}); diff --git a/src/security/install-policy.ts b/src/security/install-policy.ts index cee2ad98427d..5e845a0d4015 100644 --- a/src/security/install-policy.ts +++ b/src/security/install-policy.ts @@ -1,14 +1,10 @@ // Checks install policy constraints for package and plugin operations. -import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { OpenClawConfig, SecurityConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { - forceKillChildProcessTree, - shouldDetachChildForProcessTree, -} from "../process/child-process-tree.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { normalizePositiveInt, normalizePositiveTimerMs } from "../secrets/shared.js"; import { resolveUserPath } from "../utils.js"; import { resolveRuntimeServiceVersion } from "../version.js"; @@ -131,14 +127,6 @@ type InstallPolicyResult = findings?: InstallPolicyFinding[]; }; -type ExecRunResult = { - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - termination: "exit" | "timeout" | "no-output-timeout"; -}; - type InstallPolicyExecConfig = NonNullable["exec"]>; type InstallPolicyValidationIssue = { @@ -514,133 +502,6 @@ export async function validateInstallPolicyStatic( return { enabled: true, targets, issues }; } -function isIgnorableStdinWriteError(error: unknown): boolean { - if (typeof error !== "object" || error === null || !("code" in error)) { - return false; - } - const code = String(error.code); - return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; -} - -async function runPolicyCommand(params: { - command: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - input: string; - timeoutMs: number; - noOutputTimeoutMs: number; - maxOutputBytes: number; -}): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(params.command, params.args, { - cwd: params.cwd, - env: params.env, - stdio: ["pipe", "pipe", "pipe"], - shell: false, - windowsHide: true, - detached: shouldDetachChildForProcessTree(), - }); - - let settled = false; - let stdout = ""; - let stderr = ""; - let timedOut = false; - let noOutputTimedOut = false; - let outputBytes = 0; - let noOutputTimer: NodeJS.Timeout | null = null; - const timeoutTimer = setTimeout(() => { - timedOut = true; - forceKillChildProcessTree(child); - }, params.timeoutMs); - - const clearTimers = () => { - clearTimeout(timeoutTimer); - if (noOutputTimer) { - clearTimeout(noOutputTimer); - noOutputTimer = null; - } - }; - - const failCommand = (error: unknown, kill: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimers(); - if (kill) { - forceKillChildProcessTree(child); - } - reject(error instanceof Error ? error : new Error(String(error))); - }; - - const armNoOutputTimer = () => { - if (noOutputTimer) { - clearTimeout(noOutputTimer); - } - noOutputTimer = setTimeout(() => { - noOutputTimedOut = true; - forceKillChildProcessTree(child); - }, params.noOutputTimeoutMs); - }; - - const append = (chunk: Buffer | string, target: "stdout" | "stderr") => { - const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); - outputBytes += Buffer.byteLength(text, "utf8"); - if (outputBytes > params.maxOutputBytes) { - failCommand(new Error(`output exceeded maxOutputBytes (${params.maxOutputBytes})`), true); - return; - } - if (target === "stdout") { - stdout += text; - } else { - stderr += text; - } - armNoOutputTimer(); - }; - - armNoOutputTimer(); - child.on("error", (error) => { - failCommand(error, false); - }); - child.stdout?.on("error", (error) => { - failCommand(new Error(`policy stdout stream failed: ${formatErrorMessage(error)}`), true); - }); - child.stdout?.on("data", (chunk) => append(chunk, "stdout")); - child.stderr?.on("error", (error) => { - failCommand(new Error(`policy stderr stream failed: ${formatErrorMessage(error)}`), true); - }); - child.stderr?.on("data", (chunk) => append(chunk, "stderr")); - child.on("close", (code, signal) => { - if (settled) { - return; - } - settled = true; - clearTimers(); - resolve({ - stdout, - stderr, - code, - signal, - termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit", - }); - }); - - const handleStdinError = (error: unknown) => { - if (isIgnorableStdinWriteError(error) || settled) { - return; - } - failCommand(new Error(`policy stdin stream failed: ${formatErrorMessage(error)}`), true); - }; - child.stdin?.on("error", handleStdinError); - try { - child.stdin?.end(params.input); - } catch (error) { - handleStdinError(error); - } - }); -} - function normalizeFinding(value: unknown): InstallPolicyFinding | null { if (typeof value !== "object" || value === null) { return null; @@ -800,17 +661,20 @@ export async function runInstallPolicy(params: { const noOutputTimeoutMs = normalizePositiveTimerMs(policy.exec.noOutputTimeoutMs, timeoutMs); const maxOutputBytes = normalizePositiveInt(policy.exec.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES); const cwd = path.dirname(secureCommandPath); - let result: ExecRunResult; + let result: Awaited>; try { - result = await runPolicyCommand({ - command: secureCommandPath, - args: policy.exec.args ?? [], + result = await runCommandWithTimeout([secureCommandPath, ...(policy.exec.args ?? [])], { + baseEnv: {}, cwd, env: childEnv, input, - timeoutMs, - noOutputTimeoutMs, + killProcessTree: true, + maxCombinedOutputBytes: maxOutputBytes, maxOutputBytes, + noOutputTimeoutMs, + outputCapture: "head", + terminateOnOutputLimit: true, + timeoutMs, }); } catch (err) { return failClosed(formatErrorMessage(err)); @@ -821,6 +685,9 @@ export async function runInstallPolicy(params: { if (result.termination === "no-output-timeout") { return failClosed(`policy command produced no output for ${noOutputTimeoutMs}ms`); } + if (result.outputLimitExceeded) { + return failClosed(`output exceeded maxOutputBytes (${maxOutputBytes})`); + } if (result.code !== 0) { return failClosed(`policy command exited with code ${String(result.code)}`); }