import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { decodeWindowsOutputBuffer } from "../infra/windows-encoding.js"; import { releaseChildProcessOutputAfterExit } from "./child-process.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"; export { runCommandWithTimeout, runUtf8CommandWithTimeout } 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"; const DEFAULT_EXEC_MAX_BUFFER_BYTES = 1024 * 1024; export type RunExecOptions = { timeoutMs?: number; maxBuffer?: number; logOutput?: boolean; cwd?: string; baseEnv?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv; input?: string | Uint8Array; stdinFileDescriptor?: number; signal?: AbortSignal; /** Observe received bytes without changing buffering, completion or cancellation. */ onOutputChunk?: (chunk: Buffer, stream: CommandOutputStream) => void; }; function decodeExecOutput(buffer: Uint8Array): string { return decodeWindowsOutputBuffer({ buffer: Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength), }); } export async function runExec( command: string, args: string[], opts: number | RunExecOptions = 10_000, ): Promise<{ stdout: string; stderr: string }> { const timeout = typeof opts === "number" ? resolveTimerTimeoutMs(opts, 1) : typeof opts.timeoutMs === "number" ? resolveTimerTimeoutMs(opts.timeoutMs, 1) : undefined; const maxBuffer = typeof opts === "number" ? DEFAULT_EXEC_MAX_BUFFER_BYTES : (opts.maxBuffer ?? DEFAULT_EXEC_MAX_BUFFER_BYTES); const resolvedOptions = typeof opts === "number" ? undefined : opts; if (resolvedOptions?.input !== undefined && resolvedOptions.stdinFileDescriptor !== undefined) { throw new Error("runExec accepts either input or stdinFileDescriptor, not both"); } try { const subprocess = spawnCommand([command, ...args], { 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, ...(resolvedOptions?.stdinFileDescriptor === undefined ? { stdin: resolvedOptions?.input === undefined ? "ignore" : undefined } : { // Execa forwards arbitrary numeric stdin descriptors to Node, but its type narrows them to fd 0. stdin: resolvedOptions.stdinFileDescriptor as 0, }), stripFinalNewline: false, timeout, }); const releaseOutput = releaseChildProcessOutputAfterExit(subprocess.nodeChildProcess); let observer = resolvedOptions?.onOutputChunk; const observe = (chunk: Buffer, stream: CommandOutputStream) => { try { observer?.(chunk, stream); } catch { // Diagnostic observers cannot replace the command's outcome. observer = undefined; } }; const onStdout = (chunk: Buffer) => observe(chunk, "stdout"); const onStderr = (chunk: Buffer) => observe(chunk, "stderr"); if (observer) { subprocess.nodeChildProcess.stdout?.on("data", onStdout); subprocess.nodeChildProcess.stderr?.on("data", onStderr); } const { stdout, stderr } = await subprocess.finally(() => { releaseOutput(); subprocess.nodeChildProcess.stdout?.off("data", onStdout); subprocess.nodeChildProcess.stderr?.off("data", onStderr); }); const decodedStdout = decodeExecOutput(stdout); const decodedStderr = decodeExecOutput(stderr); if (resolvedOptions?.logOutput !== false) { const [{ shouldLogVerbose }, { logDebug, logError }] = await Promise.all([ import("../globals.js"), import("../logger.js"), ]); if (shouldLogVerbose()) { if (decodedStdout.trim()) { logDebug(decodedStdout.trim()); } if (decodedStderr.trim()) { logError(decodedStderr.trim()); } } } return { stdout: decodedStdout, stderr: decodedStderr }; } catch (err) { if (err && typeof err === "object") { const errorWithOutput = err as { code?: string | number; exitCode?: unknown; stdout?: unknown; stderr?: unknown; }; if (errorWithOutput.code === undefined && typeof errorWithOutput.exitCode === "number") { errorWithOutput.code = errorWithOutput.exitCode; } if (errorWithOutput.stdout instanceof Uint8Array) { errorWithOutput.stdout = decodeExecOutput(errorWithOutput.stdout); } if (errorWithOutput.stderr instanceof Uint8Array) { errorWithOutput.stderr = decodeExecOutput(errorWithOutput.stderr); } } if (resolvedOptions?.logOutput !== false) { // Logging imports must not replace the original command failure. const logging = await Promise.all([import("../globals.js"), import("../logger.js")]).catch( () => undefined, ); if (logging) { const [{ danger, shouldLogVerbose }, { logError }] = logging; if (shouldLogVerbose()) { logError(danger(`Command failed: ${command}`)); } } } throw err; } } export type BufferedCommandOptions = { timeoutMs?: number; cwd?: string; input?: string | Uint8Array; baseEnv?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv; signal?: AbortSignal; maxOutputBytes?: number | { stdout?: number; stderr?: number }; maxCombinedOutputBytes?: number; discardOutput?: { stdout?: boolean; stderr?: boolean }; tolerateOutputError?: { stdout?: boolean; stderr?: boolean }; terminateOnOutputError?: boolean | { stdout?: boolean; stderr?: boolean }; killProcessTree?: boolean; killGraceMs?: number; }; export type BufferedCommandResult = { stdout: Buffer; stderr: Buffer; code: number | null; signal: NodeJS.Signals | null; killed: boolean; termination: "exit" | "timeout" | "signal" | "output-limit" | "error"; outputLimitStream?: CommandOutputStream; errorStream?: CommandOutputStream; error?: Error; }; /** Run a one-shot command with raw, independently capped stdout and stderr buffers. */ export async function runCommandBuffered( argv: string[], options: BufferedCommandOptions = {}, ): Promise { if (options.signal?.aborted) { return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: null, signal: null, killed: false, termination: "signal", ...(options.signal.reason instanceof Error ? { error: options.signal.reason } : {}), }; } const chunks: Record = { stdout: [], stderr: [] }; const capturedBytes: Record = { stdout: 0, stderr: 0 }; const maxCombinedOutputBytes = typeof options.maxCombinedOutputBytes === "number" && Number.isFinite(options.maxCombinedOutputBytes) && options.maxCombinedOutputBytes > 0 ? Math.max(1, Math.floor(options.maxCombinedOutputBytes)) : undefined; let outputLimitStream: CommandOutputStream | undefined; const appendChunk = (chunk: Buffer, stream: CommandOutputStream): boolean => { if (options.discardOutput?.[stream]) { return true; } const maxBytes = resolveMaxOutputBytes(options.maxOutputBytes, stream); const combinedBytes = capturedBytes.stdout + capturedBytes.stderr; const combinedRemaining = maxCombinedOutputBytes === undefined ? Number.POSITIVE_INFINITY : Math.max(0, maxCombinedOutputBytes - combinedBytes); const remaining = Math.max(0, Math.min(maxBytes - capturedBytes[stream], combinedRemaining)); 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 capturedOutput = (stream: CommandOutputStream) => Buffer.concat(chunks[stream], capturedBytes[stream]); try { const result = await runCommandWithTimeout(argv, { baseEnv: options.baseEnv, cwd: options.cwd, env: options.env, input: options.input, killProcessTree: options.killProcessTree ?? true, killGraceMs: options.killGraceMs, 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, }, terminateOnOutputError: options.terminateOnOutputError, }); 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, }; } }