refactor(process): route bounded commands through Execa (#106495)

* refactor(process): centralize bounded command execution

* refactor(process): migrate core one-shot commands

* refactor(plugins): migrate one-shot commands

* fix(process): await Windows tree termination

* chore(plugin-sdk): refresh process runtime surface

* refactor(process): migrate remaining bounded commands

* refactor(process): normalize command result handling

* refactor(process): split execution responsibilities

* chore(plugin-sdk): refresh API baseline

* chore(process): remove release-owned changelog entry

* fix(process): narrow binary command input checks

* fix(process): cap sandbox command output

* fix(qa-lab): preserve exact node probe env

* chore(ci): refresh dead export baseline

* fix(process): preserve force-kill command deadlines

* fix(process): avoid post-exit timeout reclassification

* test(process): update scp staging wrapper mock

* test(process): update remaining wrapper mocks

* refactor(qa-lab): preserve Execa tar execution
This commit is contained in:
Peter Steinberger
2026-07-13 11:07:35 -07:00
committed by GitHub
parent 171fabef24
commit db02a96c4c
98 changed files with 4083 additions and 5821 deletions
@@ -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
+3 -4
View File
@@ -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<AcpxProcessInfo[]> {
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);
+7 -9
View File
@@ -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<string, ChromeMcpSession>();
const pendingSessions = new Map<string, PendingChromeMcpSession>();
const retainedCleanupSessions = new Map<string, Set<ChromeMcpSession>>();
@@ -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,
});
}
@@ -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<Buffer> {
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. */
@@ -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;
@@ -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<typeof vi.fn>;
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<typeof vi.fn>) {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn: spawnMock };
});
return await import("./dir-fetch.js");
import { testing } from "./dir-fetch.js";
function commandResult(overrides: Record<string, unknown> = {}) {
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<typeof import("node:child_process")>();
return {
...actual,
spawn: vi.fn(
(
command: string,
args: readonly string[],
options: Parameters<typeof actual.spawn>[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();
});
});
@@ -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<boolean>
// 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<string[] | null> {
// 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<string[] | null>((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<TarArchiveResult>((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<string[] | "TOO_MANY"> {
@@ -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);
}
@@ -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<typeof vi.fn>;
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<string, unknown> = {}) {
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
...overrides,
};
}
async function importWithSpawn(spawnMock: ReturnType<typeof vi.fn>) {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn: spawnMock };
});
return await import("./node-invoke-policy.js");
function mockCommandResult(overrides: Record<string, unknown> = {}) {
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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
result.reason,
),
).toBe(false);
}
});
it("stops archive listing as soon as the entry cap is crossed", async () => {
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" });
});
});
@@ -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: {
@@ -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<Buffer> {
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<Buffer> {
});
}
const testUnlessWindows = process.platform === "win32" ? it.skip : it;
function mockTarSpawn(
script: (
child: EventEmitter & {
kill: ReturnType<typeof vi.fn>;
stderr: EventEmitter;
stdin: EventEmitter & { end: () => void };
stdout: EventEmitter;
},
) => void,
) {
return vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>;
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<string, unknown> = {}) {
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
...overrides,
};
}
function bufferedCommandResult(overrides: Record<string, unknown> = {}) {
return {
...commandResult(),
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
...overrides,
};
}
async function importWithCommandResults(...results: Array<Record<string, unknown>>) {
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<typeof import("node:child_process")>();
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<typeof import("node:child_process")>();
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<typeof import("node:child_process")>();
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<typeof import("node:child_process")>();
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<typeof import("node:child_process")>();
return {
...actual,
spawn: vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>;
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<typeof import("node:child_process")>();
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<typeof import("node:child_process")>();
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<typeof import("node:child_process")>();
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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
result.reason,
),
).toBe(false);
}
} 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).toContain("f".repeat(199));
expect(result.reason).not.toContain("🤖");
expect(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
result.reason,
),
).toBe(false);
}
});
});
@@ -1,16 +1,14 @@
// File Transfer plugin module implements dir fetch tool behavior.
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import {
appendBoundedTextTail,
projectBoundedTextTail,
} from "../shared/append-bounded-text-tail.js";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import { projectBoundedTextTail } from "../shared/append-bounded-text-tail.js";
import { appendFileTransferAudit } from "../shared/audit.js";
import { consumeChildOutput } from "../shared/child-output.js";
import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js";
import { humanSize, readBoolean, readClampedInt } from "../shared/params.js";
import {
@@ -53,113 +51,69 @@ async function listTarOutputLines<T>(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<ReturnType<typeof runCommandWithTimeout>>;
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<typeof setTimeout> = 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<string> {
@@ -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<typeof setTimeout> = 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<ReturnType<typeof runCommandWithTimeout>>;
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<void> {
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<typeof setTimeout> = 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);
});
}
}
/**
+15 -73
View File
@@ -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<typeof import("node:child_process")>()),
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<string, unknown> = { 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<Record<string, unknown>>) {
const request = vi.fn().mockResolvedValue({ chats });
const stop = vi.fn().mockResolvedValue(undefined);
@@ -69,7 +30,7 @@ function mockRpcChatList(chats: Array<Record<string, unknown>>) {
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 () => {
+6 -137
View File
@@ -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<Record<string, unknown>> {
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<typeof setTimeout> | 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<string, unknown>): 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<string, unknown> | null = null;
if (last) {
try {
const value = JSON.parse(last);
if (value && typeof value === "object" && !Array.isArray(value)) {
parsed = value as Record<string, unknown>;
}
} 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,
});
}
+99 -17
View File
@@ -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<ReturnType<typeof runCommandWithTimeout>>> = {},
): Awaited<ReturnType<typeof runCommandWithTimeout>> {
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",
);
});
});
+64 -47
View File
@@ -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<ChildProcessWithoutNullStreams, "stdout" | "stderr" | "kill">;
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<string, unknown> | 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<string, unknown>)
: 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<Record<string, unknown>> {
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;
}
+6 -47
View File
@@ -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<typeof import("node:child_process")>()),
spawn: spawnMock,
}));
function createClient(result: Record<string, unknown>): 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);
});
});
+6 -129
View File
@@ -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, unknown>): string | null {
if (result.success !== false) {
return null;
@@ -583,124 +573,11 @@ async function runIMessageCliJson(
args: readonly string[],
timeoutMs?: number,
): Promise<Record<string, unknown>> {
return await new Promise((resolve, reject) => {
const child = spawn(cliPath, buildIMessageCliJsonArgs(args, dbPath), {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killEscalation: ReturnType<typeof setTimeout> | 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<string, unknown>): 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<string, unknown> | null = null;
if (last) {
try {
const json = JSON.parse(last) as unknown;
if (json && typeof json === "object" && !Array.isArray(json)) {
parsed = json as Record<string, unknown>;
}
} 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,
});
}
+21 -24
View File
@@ -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<Logbook
// the capture never becomes world-readable even if the dir mode drifts.
await writeFile(filePath, "", { mode: 0o600 });
// -x: no capture sound; -C: include cursor; -D is 1-based display index.
await execFileAsync("screencapture", [
"-x",
"-C",
"-D",
String(screenIndex + 1),
"-t",
"jpg",
filePath,
]);
await execFileAsync("sips", [
"--resampleHeightWidthMax",
String(maxWidth),
"-s",
"format",
"jpeg",
"-s",
"formatOptions",
String(qualityPct),
filePath,
]);
await runExec(
"screencapture",
["-x", "-C", "-D", String(screenIndex + 1), "-t", "jpg", filePath],
{ logOutput: false },
);
await runExec(
"sips",
[
"--resampleHeightWidthMax",
String(maxWidth),
"-s",
"format",
"jpeg",
"-s",
"formatOptions",
String(qualityPct),
filePath,
],
{ logOutput: false },
);
const buffer = await readFile(filePath);
return { format: "jpeg", base64: buffer.toString("base64") };
} catch (err) {
+11 -139
View File
@@ -1,9 +1,8 @@
// Matrix tests cover deps plugin behavior.
import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, type MockInstance, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
ensureMatrixCryptoRuntime,
ensureMatrixSdkInstalled,
@@ -13,72 +12,6 @@ import {
const logStub = vi.fn();
type ChildKill = (signal?: NodeJS.Signals | number) => boolean;
async function importDepsWithSpawnMock(
spawnMock: ReturnType<typeof vi.fn>,
): Promise<typeof import("./deps.js")> {
vi.resetModules();
vi.doMock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("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<void>((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<void>((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<typeof import("node:child_process")>("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<ChildKill> | 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",
});
});
});
+23 -133
View File
@@ -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<void> | 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<CommandResult> {
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 {
+9 -7
View File
@@ -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<string | null>;
};
@@ -80,13 +81,14 @@ async function runObsidianCli(params: {
deps?: ObsidianCliDeps;
}): Promise<ObsidianCliResult> {
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,
+13 -18
View File
@@ -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<string> {
return await new Promise<string>((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 {
+25 -85
View File
@@ -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<SandboxBackendCommandResult> {
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;
+126 -100
View File
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined;
let processConfig: Record<string, unknown> | 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-"));
@@ -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<symbol, unknown>)[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<typeof vi.fn> & {
[key: symbol]: ReturnType<typeof vi.fn>;
};
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();
@@ -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<string, string
export async function isOllamaEnabledWithRestartAlways(): Promise<boolean> {
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";
@@ -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<OpProcessRunner>(async () => {
+14 -29
View File
@@ -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<OpProcessResult> {
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 (
+2 -11
View File
@@ -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(
@@ -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<string[]> {
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);
@@ -126,7 +126,6 @@ describe("qa runner model catalog", () => {
const runPromise = loadQaRunnerModelOptions({
repoRoot,
signal: controller.signal,
abortKillGraceMs: 100,
});
await waitForFile(pidPath, 2_000);
+35 -163
View File
@@ -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<void>((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 });
}
+15 -17
View File
@@ -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<typeof import("node:child_process")>("node:child_process");
vi.mock("openclaw/plugin-sdk/process-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/process-runtime")>();
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({
+23 -23
View File
@@ -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<ExecResult>((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<ExecResult> {
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) {
+29 -1
View File
@@ -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({
+3 -3
View File
@@ -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;
@@ -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<void>>());
const { runCommandBufferedMock } = vi.hoisted(() => ({ runCommandBufferedMock: vi.fn() }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
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<typeof vi.fn<(signal?: NodeJS.Signals) => 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<string, unknown> = {}) {
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<unknown>) {
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);
});
});
+54 -136
View File
@@ -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: {
+54 -67
View File
@@ -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<string, unknown> = {}) {
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");
});
});
+50 -127
View File
@@ -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<ChildProcessWithoutNullStreams, "stdout" | "stderr">,
@@ -217,51 +219,22 @@ export async function startNgrokTunnel(config: {
* Run an ngrok command and wait for completion.
*/
async function runNgrokCommand(args: string[]): Promise<string> {
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<void> {
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(() => {});
}
/**
@@ -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<typeof import("node:child_process")>("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<typeof vi.fn>;
function commandResult(overrides: Record<string, unknown> = {}) {
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<typeof vi.fn>;
};
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<typeof vi.fn>;
};
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"]);
});
});
+16 -64
View File
@@ -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<TailscaleSelfInfo | null> {
+3 -2
View File
@@ -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",
+2 -2
View File
@@ -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(
+4
View File
@@ -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",
@@ -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<typeof import("node:child_process")>("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<typeof import("../../process/exec.js")>()),
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<typeof import("../../process/exec.js")>()),
spawnCommand: spawnDockerProcess,
}));
({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch } = await import("./docker.js"));
}
+74 -60
View File
@@ -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<typeof import("node:child_process")>("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<typeof import("../../process/exec.js")>()),
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<typeof import("../../process/exec.js")>()),
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",
);
});
});
+55 -146
View File
@@ -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<ExecDockerRawResult> {
return new Promise<ExecDockerRawResult>((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";
-70
View File
@@ -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,
);
});
});
+85 -8
View File
@@ -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<typeof import("../../process/exec.js")>()),
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<string, string>;
maxBuffer?: number;
} {
const options = spawnCommandMock.mock.calls[0]?.[1] as
| { baseEnv?: Record<string, string>; 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);
+4 -28
View File
@@ -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;
+28 -60
View File
@@ -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<SandboxBackendCommandResult>((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 = [
+10 -17
View File
@@ -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<string | null> {
return new Promise((resolvePromise) => {
execFile(
async function resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {
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;
}
}
/**
@@ -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<typeof import("node:child_process")>("node:child_process");
vi.mock("../process/exec.js", async () => {
const actual = await vi.importActual<typeof import("../process/exec.js")>("../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({
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text);
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
const { runCommandWithTimeoutMock } = vi.hoisted(() => ({
runCommandWithTimeoutMock: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("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);
});
});
+21 -49
View File
@@ -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(
+36 -72
View File
@@ -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<typeof import("node:child_process")>("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);
});
});
+18 -87
View File
@@ -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<ExecFileTailResult> {
// Keep only the newest stdout bytes; log commands should not buffer unbounded output.
return await new Promise<ExecFileTailResult>((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,
};
}
}
+11 -15
View File
@@ -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(
{
@@ -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<string | undefined> {
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<number | undefined> {
+5 -5
View File
@@ -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<string>([
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<string | null> {
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 {
+3 -6
View File
@@ -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<string | undefined> {
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;
+2 -7
View File
@@ -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,
@@ -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<typeof vi.fn>;
};
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<typeof import("node:child_process")>("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,
});
});
});
+1 -1
View File
@@ -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({
+28 -83
View File
@@ -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<LocalCommandProbe> {
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. */
+24 -23
View File
@@ -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<ExecFileOptionsWithStringEncoding, "encoding"> = {},
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeout?: number;
killSignal?: NodeJS.Signals | number;
windowsHide?: boolean;
} = {},
): Promise<ExecResult> {
return await new Promise<ExecResult>((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 };
}
}
+3 -3
View File
@@ -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;
+5 -6
View File
@@ -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<T>(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) {
+20 -54
View File
@@ -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<NodeIdentityProbeResult>((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),
};
}
}
+12 -31
View File
@@ -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();
+4 -12
View File
@@ -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<void> {
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<void> {
await runExec(command.command, command.args, { logOutput: false });
}
function formatConfigOpenError(error: unknown): string {
@@ -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"]
: [
+12 -3
View File
@@ -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;
+5 -8
View File
@@ -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<string> | 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;
+77 -156
View File
@@ -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<MockSpawnChild["stdout"]>;
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<ReturnType<typeof runCommandWithTimeout>>> = {},
): Awaited<ReturnType<typeof runCommandWithTimeout>> {
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<typeof import("node:child_process")>("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();
});
});
+14 -58
View File
@@ -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<SshResolvedConfig | null>((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;
}
}
+22 -21
View File
@@ -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?.(
+15 -16
View File
@@ -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) };
}
}
+20 -60
View File
@@ -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<typeof import("node:child_process")>()),
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);
});
});
+14 -38
View File
@@ -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<string> {
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<string>((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<string> {
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. */
+104 -155
View File
@@ -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<typeof import("node:child_process")>();
return {
...actual,
spawn: vi.fn(),
};
});
const { spawn } = await import("node:child_process");
type MockChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
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<typeof spawn>);
}
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);
});
});
});
+34 -140
View File
@@ -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<string, string> | undefined,
timeoutMs: number | undefined,
): Promise<RunResult> {
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<typeof spawn>;
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, string>): 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;
+1
View File
@@ -4,6 +4,7 @@ export {
type CommandOptions,
resolveCommandEnv,
resolveProcessExitCode,
runCommandBuffered,
runCommandWithTimeout,
runExec,
shouldSpawnWithShell,
+208
View File
@@ -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);
}
}
+117
View File
@@ -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)
);
}
+474
View File
@@ -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<SpawnResult> {
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<CommandTerminationReason, "exit">) => {
// 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<void>((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 } : {}),
};
}
+133
View File
@@ -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<OptionsType>;
invocation: ReturnType<typeof resolveSafeChildProcessInvocation>;
} {
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<OptionsType>;
return { child, invocation };
}
/** Spawn through the canonical argv, environment, and Windows safety boundary. */
export function spawnCommand<OptionsType extends SpawnCommandOptions = SpawnCommandOptions>(
argv: string[],
options: OptionsType = {} as OptionsType,
): ResultPromise<OptionsType> {
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);
}
+144
View File
@@ -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<void> } {
let processTreeSettleAt: number | undefined;
let windowsTerminationPromise: Promise<void> | 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<unknown>[] = [];
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<void>((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<void> => {
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<void>((resolve) => {
setTimeout(resolve, remainingMs);
});
}
if (process.platform !== "win32") {
terminateProcessTree(params.child.pid, { force: true });
}
};
return { terminate, settle };
}
+383
View File
@@ -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<void>((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", () => {
+123 -583
View File
@@ -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<OptionsType extends SpawnCommandOptions = SpawnCommandOptions>(
argv: string[],
options: OptionsType = {} as OptionsType,
): {
child: ResultPromise<OptionsType>;
invocation: ReturnType<typeof resolveSafeChildProcessInvocation>;
} {
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<OptionsType>;
return { child, invocation };
}
/** Spawn through the canonical argv, environment, and Windows safety boundary. */
export function spawnCommand<OptionsType extends SpawnCommandOptions = SpawnCommandOptions>(
argv: string[],
options: OptionsType = {} as OptionsType,
): ResultPromise<OptionsType> {
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<SpawnResult> {
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<BufferedCommandResult> {
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<CommandOutputStream, Buffer[]> = { stdout: [], stderr: [] };
const capturedBytes: Record<CommandOutputStream, number> = { 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<SpawnResult["termination"], "exit">) => {
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<void>((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] ?? "<empty>"}`, { 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",
};
}
+106 -2
View File
@@ -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({
+21 -20
View File
@@ -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 };
}
+1 -80
View File
@@ -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<typeof import("node:child_process")>();
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) =>
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<ChildProcess["stdout"]>;
child.stderr = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stderr"]>;
child.stdin = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdin"]>;
child.stdin.write = vi.fn(() => true) as NonNullable<ChildProcess["stdin"]>["write"];
child.stdin.end = vi.fn() as NonNullable<ChildProcess["stdin"]>["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 });
});
});
+25 -149
View File
@@ -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<ExecRunResult> {
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<ReturnType<typeof runCommandWithTimeout>>;
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",
-68
View File
@@ -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<typeof import("node:child_process")>();
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) =>
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<typeof vi.fn>;
} {
const child = new EventEmitter() as EventEmitter & ChildProcess;
const kill = vi.fn(() => true);
child.stdout = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdout"]>;
child.stderr = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stderr"]>;
child.stdin = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdin"]>;
child.stdin.write = vi.fn(() => true) as NonNullable<ChildProcess["stdin"]>["write"];
child.stdin.end = vi.fn() as NonNullable<ChildProcess["stdin"]>["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");
},
);
});
+13 -146
View File
@@ -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<NonNullable<SecurityConfig["installPolicy"]>["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<ExecRunResult> {
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<ReturnType<typeof runCommandWithTimeout>>;
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)}`);
}