mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
171fabef24
commit
db02a96c4c
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"]
|
||||
: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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?.(
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type CommandOptions,
|
||||
resolveCommandEnv,
|
||||
resolveProcessExitCode,
|
||||
runCommandBuffered,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
shouldSpawnWithShell,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user