// port-4040-allowlist: doc comments below reference the "never kill port 4040" rule; no kill targets it. // Subprocess lifecycle for the ACP runtime. // // Mirrors the hardening conventions in // `plugins/fusion-plugin-droid-runtime/src/process-manager.ts`: a self-cleaning // process registry, SIGKILL teardown scoped to agent subprocesses only (never // the dashboard/port-4040 — KTD4), bounded stderr capture with secret redaction // (Risk S8), and a high inactivity ceiling (the engine's StuckTaskDetector is // the authoritative aborter — KTD4). // // The ACP agent is UNTRUSTED. The spawn env is built from an explicit allow-list // (KTD6b), never inherited `process.env`, so secret-bearing vars are not handed // to the agent. import { spawn, type ChildProcess } from "node:child_process"; import { redactSecrets } from "@fusion/core"; function debugLog(message: string): void { if (process.env.PI_ACP_DEBUG !== "1") return; console.error(`[acp-runtime] ${message}`); } /** Registry of active agent subprocesses for teardown. Self-cleans on exit. */ const activeProcesses = new Set(); /** * Register a subprocess in the agent process registry. * Auto-removed from the registry when it exits. */ export function registerProcess(child: ChildProcess): void { activeProcesses.add(child); child.on("exit", () => activeProcesses.delete(child)); } /** Remove a subprocess from the registry (idempotent). */ export function unregisterProcess(child: ChildProcess): void { activeProcesses.delete(child); } /** Number of registered (presumed-live) agent subprocesses — for diagnostics/tests. */ export function activeProcessCount(): number { return activeProcesses.size; } /** * Force-kill a subprocess via SIGKILL. No-op if already dead (killed or exited). * Cross-platform safe: Node treats SIGKILL as forceful termination on Windows. */ export function forceKill(child: ChildProcess): void { if (child.killed || child.exitCode !== null) return; try { child.kill("SIGKILL"); } catch { // already gone } } /** * Force-kill every registered agent subprocess and clear the registry. * * Scoped to agent subprocesses tracked here only — never the dashboard / port * 4040 / any other process (KTD4 / kill-guard conventions). Safe to call * repeatedly; no-ops on already-dead processes. */ export function killAllProcesses(): void { for (const child of activeProcesses) { forceKill(child); } activeProcesses.clear(); } /** * Build the subprocess environment from an explicit allow-list (KTD6b). * * Returns ONLY allow-listed vars copied from `process.env`. The full env is * never inherited — the agent is untrusted and must not receive secret-bearing * vars. Returns an empty env by default (empty allow-list). */ export function buildSpawnEnv(allowList: string[]): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const key of allowList) { const value = process.env[key]; if (typeof value === "string") env[key] = value; } return env; } export interface SpawnAgentOptions { binaryPath: string; args: string[]; cwd: string; env: NodeJS.ProcessEnv; } /** * Spawn the ACP agent subprocess with piped stdio. * * Registers the child on spawn and unregisters it on exit. The caller wraps * stdin/stdout into a web stream for `ndJsonStream`. */ export function spawnAgent(options: SpawnAgentOptions): ChildProcess { const child = spawn(options.binaryPath, options.args, { stdio: ["pipe", "pipe", "pipe"], cwd: options.cwd, env: options.env, }); registerProcess(child); debugLog(`spawnAgent: pid=${child.pid} binary=${options.binaryPath}`); return child; } // --- stderr capture + secret redaction (Risk S8) -------------------------- /** Maximum stderr bytes retained; older output is dropped to bound memory. */ const STDERR_BUFFER_CEILING = 64 * 1024; // Secret redaction (Risk S8) lives in @fusion/core so PTY/process owners share // one implementation; re-exported here to preserve this module's public surface. export { redactSecrets }; /** * Accumulate stderr into a bounded, secret-redacted buffer. * Returns a getter for the current (redacted) buffer contents. */ export function captureStderr(child: ChildProcess): () => string { // FIX 5: redacting each chunk in isolation leaks a secret that straddles a // chunk boundary (the token is split across two `data` events so neither half // matches a pattern). Accumulate the RAW bytes into a bounded buffer first, // then redact across the whole (bounded) buffer after each append so a // boundary-spanning secret is caught. The buffer stays bounded by the existing // ceiling; the returned getter always reports the redacted view. let raw = ""; child.stderr?.on("data", (data: Buffer) => { raw += data.toString(); if (raw.length > STDERR_BUFFER_CEILING) { raw = raw.slice(raw.length - STDERR_BUFFER_CEILING); } }); return () => redactSecrets(raw); }