import { spawn } from "node:child_process"; import { CancelledError, TimeoutError } from "./errors.js"; import type { ShellActionExecution, ShellActionResult, ShellUpdateLine } from "./types.js"; /** Default cap on captured stdout/stderr, each. */ const DEFAULT_MAX_OUTPUT_CHARS = 1_000_000; const TRUNCATION_MARKER = "\n…[output truncated]"; const SHELL_RESULT = Symbol.for("pi-workflows.shell-result"); /** * Retrieve the shell result attached to an error thrown by * {@link runShellAction}, so callers can persist the action receipt even when * the command failed. */ export function shellResultFromError(error: unknown): ShellActionResult | null { if (error instanceof Error) { const attached = (error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT]; return attached ?? null; } return null; } export function renderShellCommand(command: string, args: string[]): string { const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" "); return renderedArgs.length > 0 ? `${command} ${renderedArgs}` : command; } export function shellOutputTruncation(result: ShellActionResult): { stdout: boolean; stderr: boolean; } { return { stdout: result.stdout.endsWith(TRUNCATION_MARKER), stderr: result.stderr.endsWith(TRUNCATION_MARKER), }; } function shellFailure( spec: ShellActionExecution, args: string[], result: ShellActionResult, killedBy: "timeout" | "abort" | null, ): Error | undefined { if (killedBy === "timeout") { return new TimeoutError(spec.timeoutMs ?? 0); } if (killedBy === "abort") { return new CancelledError(); } if (((result.exitCode ?? 0) !== 0 || result.signal != null) && spec.allowNonZeroExit !== true) { const status = result.signal ? `signal ${result.signal}` : `exit ${String(result.exitCode)}`; const details = result.stderr.length > 0 ? `\n${result.stderr.trim()}` : ""; return new Error( `Shell action failed (${renderShellCommand(spec.command, args)}): ${status}${details}`, ); } return undefined; } export async function runShellAction( spec: ShellActionExecution, signal?: AbortSignal, onLine?: (line: ShellUpdateLine) => Promise, ): Promise { // The node may have been cancelled while an async `exec` callback resolved; // never start side effects for an already-abandoned attempt. if (signal?.aborted) { throw new CancelledError(); } const cwd = spec.cwd ?? process.cwd(); const args = spec.args ?? []; const startMs = Date.now(); // POSIX: run in its own process group so timeout/abort can kill the whole // tree, including descendants that inherited the stdio pipes. const useProcessGroup = process.platform !== "win32"; const child = spawn(spec.command, args, { cwd, env: { ...process.env, ...spec.env }, shell: spec.shell, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: useProcessGroup, }); let stdout = ""; let stderr = ""; let killedBy: "timeout" | "abort" | null = null; let lineError: Error | undefined; let timeout: NodeJS.Timeout | undefined; // Cap retained output so a verbose or unending command cannot exhaust // memory before its timeout fires. const maxOutputChars = spec.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; const appendCapped = (current: string, chunk: string): string => { if (current.endsWith(TRUNCATION_MARKER)) { return current; } const room = maxOutputChars - current.length; if (chunk.length <= room) { return current + chunk; } return current + chunk.slice(0, Math.max(0, room)) + TRUNCATION_MARKER; }; const signalTree = (killSignal: NodeJS.Signals) => { if (useProcessGroup && child.pid !== undefined) { try { process.kill(-child.pid, killSignal); return; } catch { // Group already gone; fall back to the direct child. } } child.kill(killSignal); }; let killEscalation: NodeJS.Timeout | undefined; const kill = (reason: "timeout" | "abort") => { killedBy ??= reason; signalTree("SIGTERM"); killEscalation ??= setTimeout(() => { signalTree("SIGKILL"); }, 1_000); killEscalation.unref(); }; const onAbort = () => kill("abort"); const lineBuffers: Record<"stdout" | "stderr", string> = { stdout: "", stderr: "" }; const decoders = { stdout: new TextDecoder("utf-8", { fatal: onLine !== undefined }), stderr: new TextDecoder("utf-8", { fatal: onLine !== undefined }), }; let lineWork = Promise.resolve(); const processChunk = ( stream: "stdout" | "stderr", chunk: string, source: NodeJS.ReadableStream & { pause(): unknown; resume(): unknown }, ) => { if (stream === "stdout") stdout = appendCapped(stdout, chunk); else stderr = appendCapped(stderr, chunk); if (onLine === undefined || lineError !== undefined) return; source.pause(); lineWork = lineWork .then(async () => { const parts = `${lineBuffers[stream]}${chunk}`.split("\n"); lineBuffers[stream] = parts.pop() ?? ""; if (Buffer.byteLength(lineBuffers[stream], "utf8") > 64 * 1024) { throw new Error(`shell ${stream} update line exceeded 65536 bytes`); } for (const raw of parts) { const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw; if (Buffer.byteLength(text, "utf8") > 64 * 1024) { throw new Error(`shell ${stream} update line exceeded 65536 bytes`); } await onLine({ stream, text }); } }) .catch((error: unknown) => { lineError = error instanceof Error ? error : new Error(String(error)); kill("abort"); }) .finally(() => source.resume()); }; const flushLines = async () => { await lineWork; if (onLine === undefined || lineError !== undefined) return; for (const stream of ["stdout", "stderr"] as const) { const text = lineBuffers[stream]; if (Buffer.byteLength(text, "utf8") > 64 * 1024) { throw new Error(`shell ${stream} update line exceeded 65536 bytes`); } if (text.length > 0) await onLine({ stream, text: text.endsWith("\r") ? text.slice(0, -1) : text }); } }; const decodeChunk = ( stream: "stdout" | "stderr", chunk: Buffer, source: NodeJS.ReadableStream & { pause(): unknown; resume(): unknown }, ) => { try { processChunk(stream, decoders[stream].decode(chunk, { stream: true }), source); } catch (error) { lineError = error instanceof Error ? error : new Error(String(error)); kill("abort"); } }; const finish = new Promise((resolve, reject) => { child.stdout.on("data", (chunk: Buffer) => decodeChunk("stdout", chunk, child.stdout)); child.stderr.on("data", (chunk: Buffer) => decodeChunk("stderr", chunk, child.stderr)); child.once("error", (error) => { // Spawn failures (missing executable, EACCES) still get a receipt so // failed actions remain auditable. const result: ShellActionResult = { command: spec.command, args, cwd, stdout, stderr, exitCode: null, signal: null, durationMs: Date.now() - startMs, }; (error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result; reject(error); }); // `close` (unlike `exit`) fires only after stdio has fully closed, so // captured output is never truncated. child.once("close", (exitCode, signalName) => { try { processChunk("stdout", decoders.stdout.decode(), child.stdout); processChunk("stderr", decoders.stderr.decode(), child.stderr); } catch (error) { lineError = error instanceof Error ? error : new Error(String(error)); } void flushLines() .then(() => { const result: ShellActionResult = { command: spec.command, args, cwd, stdout, stderr, exitCode, signal: signalName, durationMs: Date.now() - startMs, }; const error = lineError ?? shellFailure(spec, args, result, killedBy); if (error) { // Attach the result so callers can persist the action receipt. (error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result; reject(error); return; } resolve(result); }) .catch((error: unknown) => { const failure = error instanceof Error ? error : new Error(String(error)); lineError = failure; const result: ShellActionResult = { command: spec.command, args, cwd, stdout, stderr, exitCode, signal: signalName, durationMs: Date.now() - startMs, }; (failure as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result; reject(failure); }); }); }); // A child that exits without consuming stdin emits EPIPE; without a // listener that would crash the whole process instead of just this action. child.stdin.on("error", () => {}); if (spec.stdin != null) { child.stdin.write(spec.stdin); } child.stdin.end(); if (spec.timeoutMs != null && spec.timeoutMs > 0) { timeout = setTimeout(() => kill("timeout"), spec.timeoutMs); } if (signal) { if (signal.aborted) { onAbort(); } else { signal.addEventListener("abort", onAbort, { once: true }); } } try { return await finish; } finally { if (timeout) { clearTimeout(timeout); } if (killEscalation) { // The group pid may be reused after the child exits; never let the // delayed SIGKILL fire once the command has fully closed. clearTimeout(killEscalation); } signal?.removeEventListener("abort", onAbort); } }