import { spawn, type ChildProcess } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { getShell, isWindows, killProcessTree, type PluginModule, type Runtime, type RuntimeCreateConfig, type RuntimeHandle, type RuntimeMetrics, type AttachInfo, } from "@aoagents/ao-core"; import { getPipePath, ptyHostSendMessage, ptyHostGetOutput, ptyHostIsAlive, ptyHostKill, } from "./pty-client.js"; export const manifest = { name: "process", slot: "runtime" as const, description: "Runtime plugin: child processes", version: "0.1.0", }; /** Only allow safe characters in session IDs */ const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/; function assertValidSessionId(id: string): void { if (!SAFE_SESSION_ID.test(id)) { throw new Error(`Invalid session ID "${id}": must match ${SAFE_SESSION_ID}`); } } interface ProcessEntry { process: ChildProcess | null; outputBuffer: string[]; createdAt: number; } const MAX_OUTPUT_LINES = 1000; export function create(): Runtime { // Per-instance process map — each create() call gets its own isolated state const processes = new Map(); return { name: "process", async create(config: RuntimeCreateConfig): Promise { assertValidSessionId(config.sessionId); const handleId = config.sessionId; // Prevent duplicate session IDs — check and reserve atomically (no await // between check and set) so concurrent create() calls can't both pass. if (processes.has(handleId)) { throw new Error(`Session "${handleId}" already exists — destroy it before re-creating`); } // Reserve the slot synchronously for both platforms so duplicate-create, // getMetrics, and getAttachInfo see the entry on Windows too. Unix used // to create this further down; we hoist it so the Windows branch shares // the same bookkeeping. const entry: ProcessEntry = { process: null, outputBuffer: [], createdAt: Date.now(), }; processes.set(handleId, entry); // --- Windows: spawn via PTY host (ConPTY) --- if (isWindows()) { const pipePath = getPipePath(handleId); const shellInfo = getShell(); const ptyHostScript = resolve( dirname(fileURLToPath(import.meta.url)), "pty-host.js", ); const ptyEnv = { ...process.env, ...config.environment }; try { const ptyChild = spawn( process.execPath, [ ptyHostScript, handleId, pipePath, config.workspacePath, shellInfo.cmd, ...shellInfo.args(config.launchCommand), ], { cwd: config.workspacePath, env: ptyEnv, stdio: ["ignore", "pipe", "pipe"], detached: true, // Must survive parent exit (like tmux daemon) // Suppress the console window node-pty's conpty helper would // otherwise flash on screen when a ConPTY child fails. Without // this, errors from node-pty's internal conpty_console_list_agent // surface as visible Windows error dialogs (0x800700e8). windowsHide: true, }, ); // Wait for PTY host to signal readiness (writes "READY:\n" to stdout) const ptyPid = await new Promise((resolveReady, reject) => { const timeout = setTimeout(() => { ptyChild.kill(); reject(new Error("PTY host startup timeout (10s)")); }, 10_000); let data = ""; ptyChild.stdout?.on("data", (chunk: Buffer) => { data += chunk.toString(); const match = data.match(/READY:(\d+)/); if (match) { clearTimeout(timeout); resolveReady(parseInt(match[1], 10)); } }); ptyChild.stderr?.on("data", (chunk: Buffer) => { data += chunk.toString(); }); ptyChild.on("error", (err) => { clearTimeout(timeout); reject(new Error(`PTY host spawn error: ${err.message}`)); }); ptyChild.on("exit", (code) => { clearTimeout(timeout); reject(new Error(`PTY host exited during startup with code ${code}: ${data}`)); }); }); // Unref so this process can exit while pty-host stays alive ptyChild.unref(); ptyChild.stdout?.destroy(); ptyChild.stderr?.destroy(); return { id: handleId, runtimeName: "process", data: { pid: ptyPid, ptyHostPid: ptyChild.pid, pipePath, createdAt: entry.createdAt, }, }; } catch (err) { processes.delete(handleId); throw err; } } // --- Unix: existing piped stdio path (unchanged below) --- // Use explicit shell args instead of spawn's shell: option. // When shell is a string, Node.js internally passes -c which is ambiguous // on PowerShell 5.1 (-c matches both -Command and -ConfigurationName). // getShell().args() returns the correct flag (-Command for pwsh/powershell.exe, /c for cmd). // launchCommand comes from trusted YAML config and may contain pipes and redirects. const shellInfo = getShell(); let child: ChildProcess; try { child = spawn(shellInfo.cmd, shellInfo.args(config.launchCommand), { cwd: config.workspacePath, env: { ...process.env, ...config.environment }, stdio: ["pipe", "pipe", "pipe"], detached: !isWindows(), // Own process group so destroy() can kill child commands (Unix only) }); } catch (err: unknown) { processes.delete(handleId); const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed to spawn process for session ${handleId}: ${msg}`, { cause: err }); } entry.process = child; // Attach exit handler immediately — before any await — so fast-exiting // processes can't slip through the gap. child.once("exit", () => { entry.outputBuffer.push(`[process exited with code ${child.exitCode}]`); processes.delete(handleId); }); // Handle late errors (process crashes after spawn) child.on("error", () => { // Already captured via exit handler — prevent unhandled error crash }); // Wait for spawn success or error await new Promise((resolve, reject) => { const onError = (err: Error) => { child.removeListener("spawn", onSpawn); processes.delete(handleId); reject(new Error(`Failed to spawn process for session ${handleId}: ${err.message}`)); }; const onSpawn = () => { child.removeListener("error", onError); resolve(); }; child.once("error", onError); child.once("spawn", onSpawn); }); // Capture stdout and stderr into rolling buffer. // Each stream gets its own partial-line buffer so interleaved chunks // from different streams don't corrupt each other. function makeAppendOutput(): (data: Buffer) => void { let partial = ""; return (data: Buffer) => { const text = partial + data.toString("utf-8"); const lines = text.split("\n"); // Last element is either "" (if text ended with \n) or a partial line partial = lines.pop()!; for (const line of lines) { entry.outputBuffer.push(line); } // Trim buffer to max size if (entry.outputBuffer.length > MAX_OUTPUT_LINES) { entry.outputBuffer.splice(0, entry.outputBuffer.length - MAX_OUTPUT_LINES); } }; } const appendStdout = makeAppendOutput(); const appendStderr = makeAppendOutput(); child.stdout?.on("data", appendStdout); child.stderr?.on("data", appendStderr); // Flush any trailing partial lines when the process exits child.once("exit", () => { // Trigger flush by sending a final newline through each handler appendStdout(Buffer.from("\n")); appendStderr(Buffer.from("\n")); }); return { id: handleId, runtimeName: "process", data: { pid: child.pid, createdAt: entry.createdAt, }, }; }, async destroy(handle: RuntimeHandle): Promise { // PTY host path (Windows) — kill via named pipe + process tree const pipePath = (handle.data as Record)?.pipePath as string | undefined; if (pipePath) { // Ask the pty-host to dispose its ConPTY handle and exit gracefully. await ptyHostKill(pipePath); const ptyHostPid = (handle.data as Record)?.ptyHostPid; if (typeof ptyHostPid === "number" && ptyHostPid > 0) { // Give the host ~500ms to shut down cleanly so node-pty can release // the ConPTY. SIGKILLing immediately orphans the // conpty_console_list_agent helper and triggers Windows Error // Reporting dialogs (0x800700e8). const deadline = Date.now() + 500; while (Date.now() < deadline) { try { process.kill(ptyHostPid, 0); // probe } catch { processes.delete(handle.id); return; // already gone — clean exit } await new Promise((r) => setTimeout(r, 25)); } await killProcessTree(ptyHostPid, "SIGKILL"); } processes.delete(handle.id); return; } const entry = processes.get(handle.id); if (!entry) { // Process was spawned by a different Node.js process (e.g. ao spawn). // The in-memory Map doesn't have it, but handle.data.pid has the OS PID. const pid = (handle.data as Record)?.pid; if (typeof pid === "number" && pid > 0) { await killProcessTree(pid, "SIGKILL"); } return; } const child = entry.process; if (!child) { // Process hasn't spawned yet — just remove the reservation processes.delete(handle.id); return; } if (child.exitCode === null && child.signalCode === null) { const pid = child.pid; // Register the exit listener BEFORE sending the kill signal to avoid // the race where the process exits during the async killProcessTree // call and the "exit" event fires before the listener is attached, // causing the full 5-second timeout to always elapse on Windows. const waitForExit = new Promise((resolve) => { const timeout = setTimeout(() => { Promise.resolve( child.exitCode === null && child.signalCode === null ? pid ? killProcessTree(pid, "SIGKILL") : void child.kill("SIGKILL") : undefined, ) .catch(() => {}) .finally(resolve); }, 5000); child.once("exit", () => { clearTimeout(timeout); resolve(); }); }); // Send SIGTERM after the listener is registered so we cannot miss // the exit event regardless of how quickly the process terminates. if (pid) { await killProcessTree(pid, "SIGTERM"); } else { child.kill("SIGTERM"); } await waitForExit; } processes.delete(handle.id); }, async sendMessage(handle: RuntimeHandle, message: string): Promise { // PTY host path (Windows) const pipePath = (handle.data as Record)?.pipePath as string | undefined; if (pipePath) { await ptyHostSendMessage(pipePath, message); return; } const entry = processes.get(handle.id); if (!entry) { throw new Error(`No process found for session ${handle.id}`); } const child = entry.process; if (!child) { throw new Error(`Process for session ${handle.id} is still spawning`); } const stdin = child.stdin; if (!stdin || !stdin.writable) { throw new Error(`stdin not writable for session ${handle.id}`); } // Wrap write in a promise with done-flag to prevent double resolve/reject await new Promise((resolve, reject) => { let done = false; const finish = (err?: Error | null) => { if (done) return; done = true; cleanup(); if (err) reject(err); else resolve(); }; const onError = (err: Error) => finish(err); const onDrain = () => { // Drain means backpressure cleared — still wait for write callback }; const cleanup = () => { stdin.removeListener("error", onError); stdin.removeListener("drain", onDrain); }; stdin.on("error", onError); stdin.on("drain", onDrain); stdin.write(message + "\n", (err) => finish(err ?? null)); }); }, async getOutput(handle: RuntimeHandle, lines = 50): Promise { const pipePath = (handle.data as Record)?.pipePath as string | undefined; if (pipePath) { return ptyHostGetOutput(pipePath, lines); } const entry = processes.get(handle.id); if (!entry) return ""; const buffer = entry.outputBuffer; const start = Math.max(0, buffer.length - lines); return buffer.slice(start).join("\n"); }, async isAlive(handle: RuntimeHandle): Promise { const pipePath = (handle.data as Record)?.pipePath as string | undefined; if (pipePath) { return ptyHostIsAlive(pipePath); } const entry = processes.get(handle.id); if (!entry || !entry.process) { // Not in our in-memory Map — check via PID signal-0 const pid = (handle.data as Record)?.pid; if (typeof pid === "number" && pid > 0) { try { process.kill(pid, 0); return true; } catch (err: unknown) { // EPERM means process exists but we don't have permission — still alive if ((err as NodeJS.ErrnoException).code === "EPERM") return true; return false; } } return false; } return entry.process.exitCode === null && entry.process.signalCode === null; }, async getMetrics(handle: RuntimeHandle): Promise { const entry = processes.get(handle.id); const createdAt = entry?.createdAt ?? Date.now(); return { uptimeMs: Date.now() - createdAt, }; }, async getAttachInfo(handle: RuntimeHandle): Promise { const pipePath = (handle.data as Record)?.pipePath as string | undefined; if (pipePath) { const alive = await ptyHostIsAlive(pipePath); if (!alive) { return { type: "process", target: "", command: `# process for session ${handle.id} is no longer running` }; } return { type: "process", target: String((handle.data as Record)?.pid ?? ""), command: pipePath }; } const entry = processes.get(handle.id); if ( !entry || !entry.process || entry.process.exitCode !== null || entry.process.signalCode !== null ) { return { type: "process", target: "", command: `# process for session ${handle.id} is no longer running`, }; } return { type: "process", target: String(entry.process.pid), }; }, }; } export default { manifest, create } satisfies PluginModule;