UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,339 lines (1,338 loc) • 45.2 kB
import { Socket, createConnection, createServer } from "node:net"; import { spawn, spawnSync } from "node:child_process"; import fs, { opendirSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; import { stripVTControlCharacters } from "node:util"; import { execa } from "execa"; import { once } from "node:events"; import { mkdtemp, rm } from "node:fs/promises"; import { Transform } from "node:stream"; import { deserialize, serialize } from "node:v8"; //#region packages/normalization-core/src/error-coercion.ts function readProperty(value, key) { try { return value[key]; } catch { return; } } /** * Normalizes an unknown thrown value into an Error. Non-Error objects become * the `cause` and have their enumerable fields copied so structured details * (codes, statuses) survive the coercion. */ function toErrorObject(value, fallbackMessage) { if (value instanceof Error) return value; if (typeof value === "string") return new Error(value); const error = new Error(fallbackMessage, { cause: value }); if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value); return error; } function extractErrorCode(err) { if (!err || typeof err !== "object") return; const code = readProperty(err, "code"); if (typeof code === "string") return code; if (typeof code === "number") return String(code); } //#endregion //#region packages/agent-core/src/harness/env/kill-tree.ts const DEFAULT_GRACE_MS = 3e3; const MAX_GRACE_MS = 6e4; const TASKKILL_COMPLETION_TIMEOUT_MS = 3e3; const UNIX_PROCESS_TREE_TIMEOUT_MS = 500; const MAX_UNIX_PROCESS_TREE_PIDS = 4096; const MAX_UNIX_PROCESS_TREE_DEPTH = 128; /** * Best-effort process-tree termination with graceful shutdown. * - Windows: use taskkill /T to include descendants. Sends SIGTERM-equivalent * first (without /F), then force-kills if taskkill refuses or the process * survives the grace period. * - Unix: send SIGTERM to the process group when ownership is known, wait the * grace period, then SIGKILL. Linux attached children are enumerated and * signaled descendants-first because their process group belongs to the gateway. * * Group kill (`process.kill(-pid, ...)`) is only used when the PID is verified * as its own process group leader, unless `detached: true` is explicitly passed. * This prevents accidentally signaling the gateway's process group when the * child shares its parent's group. * * - `detached: false`: skip group kill unconditionally. * - `detached: true`: use group kill unconditionally (trust caller). * - `detached` omitted: use group kill only when PID is the group leader. */ function killProcessTree(pid, opts) { if (!Number.isFinite(pid) || pid <= 0) return; if (process.platform === "win32") { if (opts?.force === true) { signalProcessTreeWindowsAndWait(pid, "SIGKILL"); return; } killProcessTreeWindows(pid, normalizeGraceMs(opts?.graceMs)); return; } const useGroupKill = opts?.detached === true || opts?.detached !== false && isProcessGroupLeader(pid); const attachedLinuxTree = opts?.detached === false && !useGroupKill && process.platform === "linux"; const processTree = attachedLinuxTree ? collectUnixProcessTree(pid) : void 0; if (attachedLinuxTree && !processTree) { signalProcessTreeUnix(pid, opts?.force === true ? "SIGKILL" : "SIGTERM", false); return; } let forceRequested = false; const force = () => { if (forceRequested) return; forceRequested = true; const liveProcessTree = processTree?.filter(verifiedProcessInstanceAlive) ?? []; if (!(useGroupKill ? isProcessAlive(-pid) : attachedLinuxTree ? liveProcessTree.length > 0 : isProcessAlive(pid))) return; signalProcessTreeUnix(pid, "SIGKILL", useGroupKill, processTree ? liveProcessTree : void 0); }; if (opts?.force === true) { if (processTree) force(); else signalProcessTreeUnix(pid, "SIGKILL", useGroupKill); return; } signalProcessTreeUnix(pid, "SIGTERM", useGroupKill, processTree); const graceMs = normalizeGraceMs(opts?.graceMs); const forceTimer = setTimeout(force, graceMs); if (!attachedLinuxTree) forceTimer.unref(); return { force: () => { clearTimeout(forceTimer); force(); } }; } function normalizeGraceMs(value) { if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GRACE_MS; return Math.max(0, Math.min(MAX_GRACE_MS, Math.floor(value))); } function isProcessAlive(pid) { try { process.kill(pid, 0); } catch (error) { if ((typeof error === "object" && error !== null && "code" in error ? error.code : void 0) !== "EPERM") return false; } if (pid < 0 || process.platform !== "linux") return true; try { const status = readFileSync(`/proc/${pid}/status`, "utf8"); return !(status.match(/^State:\s+(\S)/m)?.[1] === "Z" && /^Threads:[ \t]+1[ \t]*$/m.test(status)); } catch { return true; } } function parseProcessGroupId(value) { if (typeof value !== "string" || !/^\d+$/.test(value.trim())) return; const pgid = Number(value.trim()); return Number.isSafeInteger(pgid) && pgid > 0 ? pgid : void 0; } function readProcessGroupIdFromPs(pid) { try { const res = spawnSync("ps", [ "-p", String(pid), "-o", "pgid=" ], { encoding: "utf8", timeout: 500 }); if (res.error || res.status !== 0) return; return parseProcessGroupId(res.stdout); } catch { return; } } function readProcessGroupIdFromProc(pid) { try { const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); const commEnd = stat.lastIndexOf(")"); if (commEnd < 0) return; return parseProcessGroupId(stat.slice(commEnd + 1).trim().split(/\s+/)[2]); } catch { return; } } /** Fail closed to direct-PID signaling when group ownership cannot be proved. */ function isProcessGroupLeader(pid) { return ((process.platform === "linux" ? readProcessGroupIdFromProc(pid) : void 0) ?? readProcessGroupIdFromPs(pid)) === pid; } function parsePositivePids(value) { if (typeof value !== "string") return []; return value.trim().split(/\s+/u).map((entry) => Number(entry)).filter((entry) => Number.isSafeInteger(entry) && entry > 0); } /** * Read a stable process-instance identity for `pid`. Linux `starttime` from * `/proc/<pid>/stat` distinguishes recycled PIDs. Other Unix platforms do not * expose a sufficiently precise portable identity, so attached snapshots stay * disabled there rather than authorizing a stale signal. */ function readUnixProcessIdentity(pid, deadlineMs) { return readUnixProcessInstance(pid, deadlineMs)?.identity; } /** * Read a process-instance identity and its current parent PID from * `/proc/<pid>/stat`. The `ppid` lets the caller revalidate that a numeric PID * reported by `/proc/<parent>/children` still belongs to the verified parent at * capture time, so a PID reused between the children read and this read cannot * be admitted as an authorized descendant. */ function readUnixProcessInstance(pid, deadlineMs) { if (deadlineMs !== void 0 && Date.now() >= deadlineMs) return; if (process.platform !== "linux") return; try { const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); const commEnd = stat.lastIndexOf(")"); if (commEnd < 0) return; const fields = stat.slice(commEnd + 1).trim().split(/\s+/); const ppid = Number(fields[1]); const starttime = fields[19]; if (!starttime || !/^\d+$/u.test(starttime) || !Number.isSafeInteger(ppid)) return; return { identity: `${pid}:${starttime}`, ppid }; } catch { return; } } /** * True when the captured process instance is still alive at the same identity. * Missing identities are never eligible for an attached-tree signal. */ function verifiedProcessInstanceAlive(entry) { if (!entry.identity || !isProcessAlive(entry.pid)) return false; return readUnixProcessIdentity(entry.pid) === entry.identity; } function* readUnixProcessChildren(pid, canReadTask) { let tasks; try { tasks = opendirSync(`/proc/${pid}/task`); while (canReadTask()) { const task = tasks.readSync(); if (!task) return; const tid = parseProcessGroupId(task.name); if (!tid) continue; try { yield* parsePositivePids(readFileSync(`/proc/${pid}/task/${tid}/children`, "utf8")); } catch {} } } catch {} finally { try { tasks?.closeSync(); } catch {} } } /** * Capture an attached process tree before signaling its root. * Descendants are returned first so they retain their parent relationship. * * Each entry carries a stable process-instance identity captured at discovery * time so delayed grace-period signals can be verified against the original * instance instead of a numeric PID that may have been recycled. A descendant * whose identity cannot be captured is omitted (fail-closed for that subtree) * rather than retained as an identity-less PID that a delayed signal could * target after reuse. The supervisor-owned root PID is always trusted as the * caller's direct child, so the snapshot always contains it once the root's own * identity verifies, even when every descendant probe fails. */ function collectUnixProcessTree(rootPid) { if (process.platform !== "linux") return; const descendants = []; const seen = /* @__PURE__ */ new Set([rootPid]); let taskReads = 0; const deadline = Date.now() + UNIX_PROCESS_TREE_TIMEOUT_MS; const rootIdentity = readUnixProcessIdentity(rootPid, deadline); if (!rootIdentity || Date.now() >= deadline) return; const withinBounds = (depth) => Date.now() < deadline && depth <= MAX_UNIX_PROCESS_TREE_DEPTH; const hasCapacity = () => seen.size < MAX_UNIX_PROCESS_TREE_PIDS; const visit = (parentPid, parentIdentity, depth) => { if (!withinBounds(depth) || !hasCapacity()) return; if (readUnixProcessIdentity(parentPid, deadline) !== parentIdentity) return; const children = readUnixProcessChildren(parentPid, () => withinBounds(depth) && hasCapacity() && taskReads++ < MAX_UNIX_PROCESS_TREE_PIDS && readUnixProcessIdentity(parentPid, deadline) === parentIdentity); for (const childPid of children) { if (!withinBounds(depth + 1) || !hasCapacity()) return; if (seen.has(childPid) || childPid === process.pid) continue; seen.add(childPid); const instance = readUnixProcessInstance(childPid, deadline); if (!instance || instance.ppid !== parentPid || !withinBounds(depth + 1)) continue; visit(childPid, instance.identity, depth + 1); descendants.push({ pid: childPid, identity: instance.identity }); } }; visit(rootPid, rootIdentity, 0); return [...descendants, { pid: rootPid, identity: rootIdentity }]; } function signalProcessTreeUnix(pid, signal, useGroupKill, processTree) { if (useGroupKill) { signalUnixTarget(-pid, signal); return; } const targets = processTree ?? [{ pid }]; for (const entry of targets) { if (processTree && !verifiedProcessInstanceAlive(entry)) continue; signalUnixTarget(entry.pid, signal); } } function signalUnixTarget(pid, signal) { try { process.kill(pid, signal); } catch {} } function runTaskkill(args, onExit) { return new Promise((resolve) => { let settled = false; const finish = (code) => { if (settled) return; settled = true; clearTimeout(completionTimer); onExit?.(code); resolve(); }; const completionTimer = setTimeout(() => finish(null), TASKKILL_COMPLETION_TIMEOUT_MS); completionTimer.unref?.(); try { const child = spawn("taskkill", args, { stdio: "ignore", detached: true, windowsHide: true }); child.once("error", () => finish(null)); child.once("close", (code) => finish(code)); } catch { finish(null); } }); } function killProcessTreeWindows(pid, graceMs) { let forced = false; let graceTimer; const forceKill = () => { if (forced) return; forced = true; if (graceTimer !== void 0) { clearTimeout(graceTimer); graceTimer = void 0; } if (!isProcessAlive(pid)) return; signalProcessTreeWindowsAndWait(pid, "SIGKILL"); }; signalProcessTreeWindowsAndWait(pid, "SIGTERM", (code) => { if (code !== null && code !== 0) forceKill(); }); graceTimer = setTimeout(forceKill, graceMs); graceTimer.unref(); } function signalProcessTreeWindowsAndWait(pid, signal, onExit) { return runTaskkill(signal === "SIGKILL" ? [ "/F", "/T", "/PID", String(pid) ] : [ "/T", "/PID", String(pid) ], onExit); } //#endregion //#region src/process/linux-oom-score.ts const OOM_SCORE_WRAP_SHELL = "/bin/sh"; const OOM_SCORE_WRAP_SCRIPT = "echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec \"$0\" \"$@\""; [ "echo 1000 > /proc/self/oom_score_adj 2>/dev/null; if [ \"${OC_INTERNAL_OOM_EXEC_BASH_ENV+x}\" = x ]; then BASH_ENV=\"$OC_INTERNAL_OOM_EXEC_BASH_ENV\"; export BASH_ENV; fi; unset OC_INTERNAL_OOM_EXEC_BASH_ENV", "if [ \"${OC_INTERNAL_OOM_EXEC_ENV+x}\" = x ]; then ENV=\"$OC_INTERNAL_OOM_EXEC_ENV\"; export ENV; fi; unset OC_INTERNAL_OOM_EXEC_ENV", "if [ \"${OC_INTERNAL_OOM_EXEC_CDPATH+x}\" = x ]; then CDPATH=\"$OC_INTERNAL_OOM_EXEC_CDPATH\"; export CDPATH; fi; unset OC_INTERNAL_OOM_EXEC_CDPATH", "if [ \"${OC_INTERNAL_OOM_EXEC_PS4+x}\" = x ]; then PS4=\"$OC_INTERNAL_OOM_EXEC_PS4\"; export PS4; fi; unset OC_INTERNAL_OOM_EXEC_PS4; exec \"$0\" \"$@\"" ].join("; "); function isWrapped(command, args) { return command === OOM_SCORE_WRAP_SHELL && args[0] === "-c" && args[1] === OOM_SCORE_WRAP_SCRIPT; } /** Only small spawn owners may temporarily raise their score; never call from the Gateway. */ function spawnWithInheritedOomScore(command, args, options) { if (process.platform !== "linux" || !isWrapped(command, args) || !args[2] || options.shell || options.argv0 !== void 0) return spawn(command, args, options); let scoreFd; let original; try { scoreFd = fs.openSync("/proc/self/oom_score_adj", "r+"); original = fs.readFileSync(scoreFd, "utf8").trim(); fs.writeSync(scoreFd, "1000", 0, "utf8"); } catch { if (scoreFd !== void 0) fs.closeSync(scoreFd); return spawn(command, args, options); } try { return spawn(args[2], args.slice(3), options); } finally { try { fs.writeSync(scoreFd, original, 0, "utf8"); } catch { process.emitWarning("Could not restore spawn owner's Linux OOM score"); } finally { fs.closeSync(scoreFd); } } } //#endregion //#region src/process/supervisor/cancellation-policy.ts const GRACEFUL_CANCEL_TIMEOUT_MS = 5e3; //#endregion //#region src/shared/pid-alive.ts function isValidPid(pid) { return Number.isInteger(pid) && pid > 0; } /** * Check if every thread has exited by reading Linux /proc/<pid>/status. * Returns false on non-Linux platforms or if the proc file can't be read. */ function isZombieProcess(pid) { if (process.platform !== "linux") return false; try { const status = fs.readFileSync(`/proc/${pid}/status`, "utf8"); return status.match(/^State:\s+(\S)/m)?.[1] === "Z" && /^Threads:[ \t]+1[ \t]*$/m.test(status); } catch { return false; } } /** Returns true only when the PID is invalid, missing, or known to be a Linux zombie. */ function isPidDefinitelyDead(pid) { if (!isValidPid(pid)) return true; try { process.kill(pid, 0); } catch (err) { return err.code === "ESRCH"; } return isZombieProcess(pid); } //#endregion //#region src/process/supervisor/service-child-group-ownership.ts /** The caller supplies native command inspection; the standalone group worker stays dependency-free. */ function* readProcessGroupMembers(timeoutMs, commandInspection) { const includeCommand = commandInspection !== void 0; if (process.platform === "linux") { const deadline = Date.now() + timeoutMs; for (const name of readdirSync("/proc")) { if (Date.now() >= deadline) throw new Error("Process group census exceeded its deadline"); if (!/^\d+$/.test(name)) continue; const pid = Number(name); let stat; let argv; try { stat = readFileSync(`/proc/${name}/stat`, "utf8"); if (includeCommand) argv = readFileSync(`/proc/${name}/cmdline`, "utf8").split("\0").filter(Boolean); } catch (error) { if (pid !== process.pid && ["ENOENT", "ESRCH"].includes(extractErrorCode(error) ?? "")) continue; throw new Error(`Could not classify PID ${pid}: process command inspection failed (${extractErrorCode(error) ?? "unavailable"}).`, { cause: error }); } const match = /^(\d+) \([\s\S]*\) (\S) (\d+) (\d+)(?:\s|$)/.exec(stat); if (!match || Number(match[1]) !== pid || Date.now() >= deadline) throw new Error("Process group census is unavailable"); if (argv?.length === 0) { const flags = Number(stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[6]); if (!(Number.isInteger(flags) && (flags & 2097152) !== 0) && !isPidDefinitelyDead(pid)) throw new Error(`Could not classify PID ${pid}: live userspace process has no readable arguments.`); } yield { pid, pgid: Number(match[4]), state: match[2], ...argv ? { command: { ppid: Number(match[3]), argv } } : {} }; } if (Date.now() >= deadline) throw new Error("Process group census exceeded its deadline"); return; } if (includeCommand && process.platform !== "darwin") throw new Error(`Exact process command census is unavailable on ${process.platform}.`); const deadline = Date.now() + timeoutMs; const census = spawnSync("/bin/ps", [ "-A", "-o", includeCommand ? "pid=,pgid=,stat=,ppid=,uid=" : "pid=,pgid=,stat=" ], { encoding: "utf8", timeout: timeoutMs, maxBuffer: 4194304 }); if (census.error || census.status !== 0) throw new Error("Process group census is unavailable"); for (const line of census.stdout.split("\n")) { if (!line.trim()) continue; const match = includeCommand ? /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(-?\d+)\s*$/.exec(line) : /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line); if (!match) throw new Error("Process group census is unavailable"); const pid = Number(match[1]); if (pid !== census.pid) { if (includeCommand && Date.now() >= deadline) throw new Error("Process group census exceeded its deadline"); const command = includeCommand ? match[3].startsWith("Z") || pid === 0 ? { argv: [] } : commandInspection?.readDarwinCommand(pid, Number(match[5]) >>> 0) : void 0; if (includeCommand && !command) continue; yield { pid, pgid: Number(match[2]), state: match[3], ...command ? { command: { ppid: Number(match[4]), ...command } } : {} }; } } if (includeCommand && Date.now() >= deadline) throw new Error("Process group census exceeded its deadline"); } /** Advisory retirement timing only; the host owns kernel group-disappearance proof. */ function hasLiveOwnedProcessGroupMembers(timeoutMs = 1e3) { let observedOwner = false; try { for (const { pid, pgid, state } of readProcessGroupMembers(Math.max(1, Math.min(1e3, timeoutMs)))) if (pid === process.pid) { if (pgid !== process.pid) return; observedOwner = true; } else if (pgid === process.pid && (!state.startsWith("Z") || process.platform === "linux" && !isPidDefinitelyDead(pid))) return true; } catch { return; } return observedOwner ? false : void 0; } //#endregion //#region src/process/spawn-broker/execa-message.ts const decoder = new TextDecoder(); const commonEscapes = /* @__PURE__ */ new Map([ [" ", " "], ["\n", "\n"], ["\b", "\\b"], ["\f", "\\f"], ["\r", "\\r"], [" ", "\\t"] ]); function renderOutput(output) { let text = typeof output === "string" ? output : decoder.decode(output); if (text.endsWith("\n")) text = text.slice(0, text.endsWith("\r\n") ? -2 : -1); return stripVTControlCharacters(text).replace(/[\p{Separator}\p{Other}]/gu, (character) => { const common = commonEscapes.get(character); if (common !== void 0) return common; const codepoint = character.codePointAt(0); const hex = codepoint.toString(16); return codepoint <= 65535 ? `\\u${hex.padStart(4, "0")}` : `\\U${hex}`; }); } /** Keep the authoritative prefix and replace only exactly matching output suffixes. */ function encodeExecaMessage(message, output) { const outputs = []; let end = message.length; for (const stream of ["stdout", "stderr"]) { const rendered = renderOutput(output[stream]); if (!rendered) continue; const start = end - rendered.length; if (start < 2 || !message.endsWith(rendered, end) || message.slice(start - 2, start) !== "\n\n") return { message }; outputs.unshift(stream); end = start - 2; } return outputs.length > 0 ? { message: message.slice(0, end), messageOutputs: outputs } : { message }; } //#endregion //#region src/process/spawn-broker/execa-protocol.ts function serializeExecaError(error, output = {}) { return { name: error.name, ...encodeExecaMessage(error.message, output), ..."code" in error && typeof error.code === "string" ? { code: error.code } : {}, ...error.cause instanceof Error ? { cause: serializeExecaError(error.cause) } : {} }; } //#endregion //#region src/process/process-deadline.ts /** Let pending child exit and output notifications run before accepting a deadline. */ function setProcessTimeout(callback, delayMs) { let decision; const timer = setTimeout(() => { decision = setTimeout(callback, 0); }, delayMs); return { clear() { clearTimeout(timer); clearTimeout(decision); }, refresh() { clearTimeout(decision); decision = void 0; timer.refresh(); } }; } //#endregion //#region src/process/spawn-broker/execa-output.ts /** Execa retains its reader; the Gateway receives a separate native output socket. */ async function createExecaOutput() { const directory = await mkdtemp(path.join(tmpdir(), "oc-spawn-")); const server = createServer({ pauseOnConnect: true }); let writer; let receiver; try { const socketPath = path.join(directory, "out"); server.listen(socketPath); await once(server, "listening"); const accepted = new Promise((resolve) => { server.once("connection", resolve); }); receiver = createConnection(socketPath); [writer] = await Promise.all([accepted, once(receiver, "connect")]); const destination = writer; const transform = new Transform({ transform(chunk, _encoding, callback) { destination.write(chunk, (error) => callback(error, chunk)); }, flush(callback) { destination.end(callback); }, destroy(error, callback) { destination.destroy(); callback(error); } }); destination.on("error", (error) => transform.destroy(error)); destination.once("close", () => { if (!destination.writableFinished) transform.destroy(); }); return { receiver, transform }; } catch (error) { writer?.destroy(); receiver?.destroy(); throw error; } finally { server.close(); await rm(directory, { recursive: true, force: true }); } } //#endregion //#region src/process/spawn-broker/protocol.ts var SpawnBrokerError = class extends Error { constructor(message, options) { super(message, options); this.code = "ERR_SPAWN_BROKER_UNAVAILABLE"; this.name = "SpawnBrokerError"; } }; function serializeBrokerError(error) { return { message: error.message, code: error.code, errno: error.errno, syscall: error.syscall, path: error.path }; } //#endregion //#region src/process/spawn-broker/pipe.ts const readers = /* @__PURE__ */ new WeakMap(); const holdReadable = () => {}; /** Defer consumption and EOF until the transferred socket is ready for its caller. */ function holdPipe(socket) { const held = { read: socket.read.bind(socket), resumed: false, onResume: () => { held.resumed = true; } }; readers.set(socket, held); socket.on("resume", held.onResume); socket.on("readable", holdReadable); socket.read = () => null; } /** Stop the sender's libuv reader before Node detaches the socket for IPC. */ function holdPipeForTransfer(socket) { stopPipeReads(socket); holdPipe(socket); } function stopPipeReads(socket) { const handle = Reflect.get(socket, "_handle"); if (!handle || typeof handle !== "object" || !("readStop" in handle) || typeof handle.readStop !== "function" || !("reading" in handle) || typeof handle.reading !== "boolean") throw new SpawnBrokerError("Spawn broker requires a transferable Node pipe handle"); if (handle.readStop() !== 0) throw new SpawnBrokerError("Spawn broker could not stop pipe reads"); handle.reading = false; } /** Called after send's callback, when keepOpen:false has detached the native handle. */ function takePipePrefix(socket) { const held = readers.get(socket); if (!held) throw new Error("Spawn broker pipe was not held for handoff"); const buffered = held.read.call(socket, socket.readableLength); restoreReader(socket); if (buffered === null) return Buffer.alloc(0); if (!Buffer.isBuffer(buffered)) throw new Error("Spawn broker pipe must contain bytes"); return buffered; } function restoreReader(socket) { const held = readers.get(socket); if (!held) return; socket.read = held.read; readers.delete(socket); socket.removeListener("resume", held.onResume); socket.removeListener("readable", holdReadable); return held; } //#endregion //#region src/process/spawn-broker/execa-worker.ts function outputOption(options, fd) { const explicit = fd === 1 ? options.stdout : options.stderr; if (explicit !== void 0) return explicit; return typeof options.stdio === "string" ? options.stdio : options.stdio?.[fd] ?? "pipe"; } /** Spawn only after preparing bounded output paths, before the command can produce bytes. */ async function startBrokerExeca(argv, options, assertCurrent) { const controller = new AbortController(); const outputs = /* @__PURE__ */ new Map(); try { for (const fd of [1, 2]) { const name = fd === 1 ? "stdout" : "stderr"; const buffered = typeof options.buffer === "boolean" ? options.buffer : options.buffer?.[name]; if (outputOption(options, fd) === "pipe" && buffered !== false) outputs.set(fd, await createExecaOutput()); } assertCurrent(); const { encoding, executionDeadlineMs, ...processOptions } = options; const spawnOptions = { ...processOptions, ...outputs.has(1) ? { stdout: { transform: outputs.get(1).transform, objectMode: false } } : {}, ...outputs.has(2) ? { stderr: { transform: outputs.get(2).transform, objectMode: false } } : {}, cancelSignal: controller.signal }; const subprocess = encoding === void 0 || encoding === "utf8" || encoding === "utf16le" ? execa(argv[0], argv.slice(1), { ...spawnOptions, encoding }) : execa(argv[0], argv.slice(1), { ...spawnOptions, encoding }); const child = subprocess.nodeChildProcess; const stdio = [ 0, 1, 2 ].map((fd) => { if (fd === 0 && options.input !== void 0) return null; if (fd > 0 && outputOption(options, fd === 1 ? 1 : 2) !== "pipe") return null; const stream = outputs.get(fd)?.receiver ?? child.stdio[fd]; if (!(stream instanceof Socket)) return null; if (fd > 0) holdPipeForTransfer(stream); return stream; }); let executionTimedOut = false; const deadline = executionDeadlineMs === void 0 ? void 0 : setProcessTimeout(() => { if (child.exitCode === null && child.signalCode === null) { executionTimedOut = true; subprocess.kill(); } }, executionDeadlineMs); const clearDeadline = () => deadline?.clear(); child.once("exit", clearDeadline); const result = subprocess.then((value) => serializeResult(value, executionTimedOut), (error) => { if (error instanceof Error && "failed" in error && error.failed === true) return serializeResult(error, executionTimedOut); throw error; }).finally(() => { clearDeadline(); child.removeListener("exit", clearDeadline); }); result.catch(() => {}); return { child, stdio, result, cancel() { clearDeadline(); controller.abort(); }, kill(signal) { clearDeadline(); return subprocess.kill(signal); }, outputDrained(fd, error) { const output = outputs.get(fd); if (output) { output.receiver.destroy(); if (error) output.transform.destroy(error); return; } if (fd === 0 && !error) { child.stdin?.end(); return; } child.stdio[fd]?.destroy(error); } }; } catch (error) { for (const output of outputs.values()) { output.transform.destroy(); output.receiver.destroy(); } throw error; } } function serializeResult(result, executionTimedOut) { const output = { stdout: byteOrTextOutput(result.stdout), stderr: byteOrTextOutput(result.stderr) }; const error = result instanceof Error ? result : executionTimedOut ? /* @__PURE__ */ new Error("Command timed out") : void 0; return { ...output, exitCode: result.exitCode, signal: result.signal, failed: result.failed || executionTimedOut, timedOut: result.timedOut || executionTimedOut, isCanceled: result.isCanceled, isGracefullyCanceled: result.isGracefullyCanceled, isMaxBuffer: result.isMaxBuffer, isTerminated: result.isTerminated, isForcefullyTerminated: result.isForcefullyTerminated, shortMessage: result.shortMessage, originalMessage: result.originalMessage, code: result.code, command: result.command, escapedCommand: result.escapedCommand, cwd: result.cwd, durationMs: result.durationMs, signalDescription: result.signalDescription, ...error ? { error: serializeExecaError(error, output) } : {} }; } function byteOrTextOutput(output) { if (output === void 0 || typeof output === "string" || output instanceof Uint8Array) return output; throw new TypeError("Spawn broker execa output must contain bytes or text"); } //#endregion //#region src/process/spawn-broker/ipc.ts const FRAME_BYTES = 1048576; const MAX_PENDING_BYTES = 268435456; const MAX_PENDING_MESSAGES = 1024; const FRAME_KIND = "openclaw-spawn-broker-frame"; /** Frame large inputs/results while bounding both native writes and retained payloads. */ function createBrokerSender(send) { let bytes = 0; let messages = 0; let sequence = 0; let previous = Promise.resolve(); const write = (message, handle) => new Promise((resolve, reject) => { try { send(message, handle, (error) => error ? reject(error) : resolve()); } catch (error) { reject(toErrorObject(error, "Spawn broker IPC write failed")); } }); const enqueue = (size, operation) => { if (bytes + size > MAX_PENDING_BYTES || messages >= MAX_PENDING_MESSAGES) return Promise.reject(new SpawnBrokerError("Spawn broker IPC capacity exceeded")); bytes += size; messages += 1; const completion = previous.then(operation); previous = completion.then(() => {}, () => {}); return completion.finally(() => { bytes -= size; messages -= 1; }); }; const sender = (message, handle) => { const payload = serialize(message); const size = payload.byteLength; if (size > FRAME_BYTES && handle) return Promise.reject(new SpawnBrokerError("Spawn broker handle metadata exceeds one IPC frame")); const id = ++sequence; const operation = async () => { if (size <= FRAME_BYTES) { await write(message, handle); return; } for (let offset = 0; offset < size; offset += FRAME_BYTES) { const frame = { kind: FRAME_KIND, id, total: size, offset, bytes: payload.subarray(offset, offset + FRAME_BYTES) }; await write(frame); } }; return enqueue(size, operation); }; return Object.assign(sender, { reserve(run) { return enqueue(FRAME_BYTES, async () => { let active = true; let publication; const publish = (message) => { if (!active || publication) return Promise.reject(new SpawnBrokerError("Spawn broker publication reservation is closed")); publication = (async () => { if (serialize(message).byteLength > FRAME_BYTES) throw new SpawnBrokerError("Spawn broker reserved publication exceeds one IPC frame"); await write(message); })(); publication.catch(() => {}); return publication; }; try { return await run(publish); } finally { active = false; await publication; } }); } }); } /** Only the version-matched private peer can supply frames on this channel. */ function createBrokerReceiver() { const pending = /* @__PURE__ */ new Map(); let reserved = 0; return { receive(message) { if (!message || typeof message !== "object" || !("kind" in message) || message.kind !== FRAME_KIND) return message; const frame = message; if (!Number.isSafeInteger(frame.id) || !Number.isSafeInteger(frame.total) || frame.total <= FRAME_BYTES || frame.total > MAX_PENDING_BYTES || !Number.isSafeInteger(frame.offset) || !Buffer.isBuffer(frame.bytes) || frame.bytes.length === 0 || frame.bytes.length > FRAME_BYTES) throw new SpawnBrokerError("Invalid spawn broker IPC frame"); let assembly = pending.get(frame.id); if (!assembly) { if (frame.offset !== 0 || pending.size >= MAX_PENDING_MESSAGES || reserved + frame.total > MAX_PENDING_BYTES) throw new SpawnBrokerError("Spawn broker receive capacity exceeded"); assembly = { total: frame.total, received: 0, chunks: [] }; pending.set(frame.id, assembly); reserved += frame.total; } if (assembly.total !== frame.total || assembly.received !== frame.offset || assembly.received + frame.bytes.length > assembly.total) throw new SpawnBrokerError("Out-of-order spawn broker IPC frame"); assembly.chunks.push(frame.bytes); assembly.received += frame.bytes.length; if (assembly.received < assembly.total) return; pending.delete(frame.id); reserved -= assembly.total; return deserialize(Buffer.concat(assembly.chunks, assembly.total)); }, clear() { pending.clear(); reserved = 0; } }; } //#endregion //#region src/process/spawn-broker/worker-sender.ts /** A receipt keeps Node's internal handle queue empty before inspecting the next socket. */ function createWorkerSender(send) { let pending; let closed; const finish = (pipe, error) => { if (pending !== pipe) return; pending = void 0; pipe.callback(error); }; const sender = createBrokerSender((message, handle, callback) => { if (closed) { callback(closed); return; } if (!(handle instanceof Socket) || !message || typeof message !== "object" || !("type" in message) || message.type !== "pipe" || !("id" in message) || typeof message.id !== "number" || !("fd" in message) || typeof message.fd !== "number") { send(message, handle, callback); return; } const pipe = { id: message.id, fd: message.fd, written: false, received: false, callback }; pending = pipe; const stdinClosed = message.fd === 0 && handle.destroyed; try { send(stdinClosed ? { ...message, closed: true } : message, stdinClosed ? void 0 : handle, (error) => { if (error) { finish(pipe, error); return; } pipe.written = true; if (pipe.received) finish(pipe, null); }); } catch (error) { pending = void 0; throw error; } }); return { send: sender, reserve(run) { return sender.reserve(async (publish) => { if (closed) throw closed; return await run(publish); }); }, acknowledge(id, fd) { if (!pending || pending.id !== id || pending.fd !== fd) return; pending.received = true; if (pending.written) finish(pending, null); }, close(error) { closed = error; if (pending) finish(pending, error); } }; } //#endregion //#region src/process/spawn-broker/worker.ts const owned = /* @__PURE__ */ new Map(); const receiver = createBrokerReceiver(); let stopping = false; const starting = /* @__PURE__ */ new Map(); const sender = createWorkerSender((message, handle, callback) => { if (!process.send || !process.connected) { callback(/* @__PURE__ */ new Error("Spawn broker parent disconnected")); return; } process.send(message, handle, { keepOpen: false }, callback); }); function report(message, handle) { return sender.send(message, handle).catch((error) => { shutdown(); throw error; }); } function forget(id, entry) { if (entry.announced && entry.exited && entry.resultSettled && entry.openPipes.size === 0) owned.delete(id); } function shutdown() { if (stopping) return; stopping = true; sender.close(/* @__PURE__ */ new Error("Spawn broker parent disconnected")); receiver.clear(); const terminations = []; for (const entry of owned.values()) { if (entry.child.connected) entry.child.disconnect(); if (entry.child.pid) terminations.push(killProcessTree(entry.child.pid, { detached: entry.detached, graceMs: GRACEFUL_CANCEL_TIMEOUT_MS })); } const signalGroup = (signal) => { try { process.kill(-process.pid, signal); } catch {} }; const finish = () => { for (const termination of terminations) termination?.force(); signalGroup("SIGKILL"); process.exit(0); }; signalGroup("SIGTERM"); if (owned.size === 0 && starting.size === 0 && hasLiveOwnedProcessGroupMembers() === false) { finish(); return; } setTimeout(finish, 5250); } function disposeFailedChild(child) { if (!child) return; child.once("error", () => {}); if (child.pid) child.kill("SIGKILL"); if (child.connected) child.disconnect(); for (const stream of child.stdio ?? []) stream?.destroy(); } async function launch(message) { if (stopping || owned.size + starting.size >= 256) { const error = new SpawnBrokerError("Spawn broker request capacity exceeded"); await report({ type: "execa-result", id: message.id, result: { failed: true, code: error.code, timedOut: false, isCanceled: false, isGracefullyCanceled: false, isMaxBuffer: false, isTerminated: false, isForcefullyTerminated: false, command: "", escapedCommand: "", cwd: process.cwd(), durationMs: 0, error: serializeExecaError(error) } }); await report({ type: "error", id: message.id, error: serializeBrokerError(error), resultUnavailable: true }); return; } const pending = {}; starting.set(message.id, pending); let spawnedChild; const assertActive = () => { if (stopping || !process.connected) throw new Error("Spawn broker is stopping"); }; try { const admission = await sender.reserve(async (publish) => { assertActive(); const execa = message.type === "spawn-execa" ? await startBrokerExeca(message.argv, message.options, assertActive) : void 0; const child = execa?.child ?? (message.type === "spawn" ? spawnWithInheritedOomScore(message.argv[0], message.argv.slice(1), message.options) : void 0); spawnedChild = child; if (!child) throw new Error("Spawn broker command did not start"); if (!execa) { if (child.stdio === void 0) { const closed = new Promise((resolve) => { child.once("close", () => resolve()); }); const error = await new Promise((resolve) => { child.once("error", resolve); }); await closed; throw error; } for (const [fd, stream] of child.stdio.entries()) if (fd > 0 && stream instanceof Socket) holdPipeForTransfer(stream); } if (execa && !child.pid) { for (const [fd] of execa.stdio.entries()) execa.outputDrained(fd); disposeFailedChild(child); return { type: "failed", execa }; } const current = { child, detached: message.options.detached === true || message.type === "spawn-execa" && message.options.killDescendants === true, execa, announced: false, events: [], exited: false, resultSettled: !execa, openPipes: new Set((execa?.stdio ?? child.stdio).flatMap((stream, fd) => stream instanceof Socket ? [fd] : [])) }; owned.set(message.id, current); if (pending.canceled) execa?.cancel(); if (pending.signal !== void 0) (execa?.kill ?? child.kill.bind(child))(pending.signal); const event = (value) => { if (current.announced) report(value).catch(() => {}); else if (current.events.length < 32) current.events.push(value); else { child.kill("SIGKILL"); shutdown(); } }; child.on("error", (error) => event({ type: "error", id: message.id, error: serializeBrokerError(error) })); child.on("message", (payload) => event({ type: "ipc", id: message.id, message: payload })); child.once("disconnect", () => event({ type: "disconnect", id: message.id })); child.once("exit", (code, signal) => event({ type: "exit", id: message.id, code, signal })); child.once("close", () => { event({ type: "closed", id: message.id }); current.exited = true; forget(message.id, current); }); if (execa) execa.result.then(async (result) => { event({ type: "execa-result", id: message.id, result }); current.resultSettled = true; forget(message.id, current); }).catch((error) => { event({ type: "error", id: message.id, error: serializeBrokerError(toErrorObject(error, "Spawn broker execa result failed")), resultUnavailable: true }); current.resultSettled = true; forget(message.id, current); }); if (!execa) await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); }); if (!child.pid) throw new Error("Spawn broker command has no process identifier"); await publish({ type: "owned", id: message.id, pid: child.pid }); return { type: "started", entry: current }; }); if (admission.type === "failed") { const result = await admission.execa.result; await report({ type: "execa-result", id: message.id, result }); await report({ type: "error", id: message.id, error: { message: result.error?.message ?? result.shortMessage ?? "Command could not be spawned", code: result.code } }); return; } const current = admission.entry; const { child, execa } = current; if (stopping) { if (child.connected) child.disconnect(); if (child.pid) killProcessTree(child.pid, { detached: current.detached, graceMs: GRACEFUL_CANCEL_TIMEOUT_MS }); return; } const streams = execa?.stdio ?? child.stdio; for (const [fd, stream] of streams.entries()) { if (!(stream instanceof Socket)) continue; stream.pause(); await report({ type: "pipe", id: message.id, fd }, stream); if (fd > 0) await report({ type: "pipe-prefix", id: message.id, fd, bytes: takePipePrefix(stream) }); if (!execa) stream.destroy(); } if (!child.pid) throw new Error("Spawn broker command has no process identifier"); await report({ type: "spawned", id: message.id, pid: child.pid, spawnfile: child.spawnfile, spawnargs: child.spawnargs, connected: child.connected, stdioLength: streams.length }); for (;;) { const value = current.events.shift(); if (value === void 0) break; await report(value); } current.announced = true; forget(message.id, current); } catch (error) { disposeFailedChild(spawnedChild); owned.delete(message.id); if (!stopping) await report({ type: "error", id: message.id, error: serializeBrokerError(error instanceof Error ? error : new Error(String(error))), resultUnavailable: true }); } finally { starting.delete(message.id); } } process.once("disconnect", shutdown); const onSupervisorSignal = () => { if (!process.connected) shutdown(); }; process.on("SIGTERM", onSupervisorSignal); process.on("SIGINT", onSupervisorSignal); process.on("message", (raw, handle) => { let decoded; try { decoded = receiver.receive(raw); } catch { shutdown(); return; } if (decoded === void 0) return; const message = decoded; if (message.type === "shutdown") { shutdown(); return; } if (message.type === "pipe-received") { sender.acknowledge(message.id, message.fd); return; } if (message.type === "spawn" || message.type === "spawn-execa") { launch(message).catch(shutdown); return; } const entry = owned.get(message.id); if (!entry) { const pending = starting.get(message.id); if (pending && message.type === "cancel") pending.canceled = true; if (pending && message.type === "kill") pending.signal = message.signal; if (message.type === "ipc") report({ type: "ipc-sent", id: message.id, sequence: message.sequence, error: { message: "Child process IPC channel is closed" } }).catch(() => {}); return; } if (message.type === "kill") (entry.execa?.kill ?? entry.child.kill.bind(entry.child))(message.signal); else if (message.type === "cancel") entry.execa?.cancel(); else if (message.type === "output-drained") { entry.execa?.outputDrained(message.fd, message.error ? Object.assign(new Error(message.error.message), message.error) : void 0); entry.openPipes.delete(message.fd); forget(message.id, entry); } else if (message.type === "disconnect" && entry.child.connected) entry.child.disconnect(); else if (message.type === "ipc") { const acknowledge = (error) => { report({ type: "ipc-sent", id: message.id, sequence: message.sequence, ...error ? { error: serializeBrokerError(error) } : {} }).catch(() => {}); }; if (!entry.child.connected) acknowledge(/* @__PURE__ */ new Error("Child process IPC channel is closed")); else try { entry.child.send(message.message, handle, { keepOpen: true }, acknowledge); } catch (error) { acknowledge(error instanceof Error ? error : new Error(String(error))); } } }); report({ type: "ready", pid: process.pid }).catch(shutdown); //#endregion export {};