141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
// 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<ChildProcess>();
|
|
|
|
/**
|
|
* 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);
|
|
}
|