Files
fusion/plugins/fusion-plugin-acp-runtime/src/process-manager.ts
gsxdsm a9815fb1ff FN-6457: add ACP ask runner and bundled Claude bridge setup
Route ACP-backed planning and validation through a read-only ask-once runner with a pinned Claude bridge foundation.

- Add askAcpOnce for single-turn ACP sessions with timeout handling, JSON recovery, clean stop validation, and disposal.
- Refactor validation seams to use ACP runtime prompts and require structured pass verdicts.
- Resolve the Claude ACP bridge from the plugin bundle and add setup checks for identity, environment, probing, and auth readiness.
- Document the ACP Route B plan and update tests for validator, session, runtime, and plugin setup behavior.

Files changed:
 CONCEPTS.md                                        |   6 +
 docs/acp-contract.md                               |  36 ++
 .../2026-06-14-001-feat-claude-acp-runtime-plan.md | 465 +++++++++++++++++++++
 .../engine/src/__tests__/cli-agent-ask.test.ts     | 104 +++++
 .../src/__tests__/cli-agent-validator.test.ts      | 137 +++---
 .../src/__tests__/interactive-ai-session.test.ts   |  96 +++--
 packages/engine/src/agent-runtime.ts               |   6 +-
 packages/engine/src/cli-agent-ask.ts               | 120 ++++++
 packages/engine/src/cli-agent-validator.ts         |  65 ++-
 .../cli-agent/__tests__/one-shot-session.test.ts   |  16 +-
 packages/engine/src/cli-agent/one-shot-session.ts  |  17 +-
 packages/engine/src/index.ts                       |   8 +-
 packages/engine/src/interactive-ai-session.ts      |  33 +-
 plugins/fusion-plugin-acp-runtime/AGENTS.md        |  14 +
 plugins/fusion-plugin-acp-runtime/CHANGELOG.md     |   6 +
 plugins/fusion-plugin-acp-runtime/README.md        |  13 +-
 plugins/fusion-plugin-acp-runtime/package.json     |   3 +-
 .../src/__tests__/index.test.ts                    |  51 ++-
 .../src/__tests__/process-manager.test.ts          |  32 +-
 .../src/__tests__/runtime-adapter.test.ts          |   4 +-
 .../src/__tests__/setup.test.ts                    |  71 ++++
 plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts |  95 ++++-
 plugins/fusion-plugin-acp-runtime/src/index.ts     |  16 +-
 .../src/process-manager.ts                         |  26 +-
 .../src/runtime-adapter.ts                         |  11 +-
 plugins/fusion-plugin-acp-runtime/src/setup.ts     | 104 +++++
 plugins/fusion-plugin-acp-runtime/src/types.ts     |   6 +-
 pnpm-lock.yaml                                     | 139 ++++--
 28 files changed, 1502 insertions(+), 198 deletions(-)

Fusion-Task-Id: FN-6457
Fusion-Task-Lineage: a3364ed7-cb28-4a2b-b898-6ccd0d95fb92
2026-06-15 02:30:41 -07:00

163 lines
5.8 KiB
TypeScript

// port-4040-allowlist: this file documents the reserved dashboard port in kill-guard comments only; 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();
}
export class MissingAcpEnvError extends Error {
readonly code = "ACP_MISSING_ENV";
constructor(readonly missingKeys: string[]) {
super(`Missing required ACP environment variable(s): ${missingKeys.join(", ")}`);
this.name = "MissingAcpEnvError";
}
}
export interface BuildSpawnEnvOptions {
required?: string[];
sourceEnv?: NodeJS.ProcessEnv;
}
/**
* 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[], options: BuildSpawnEnvOptions = {}): NodeJS.ProcessEnv {
/*
FNXC:ACP-RouteB 2026-06-14-19:52:
Claude bridge subprocesses may receive HOME so the real `claude` can read ~/.claude auth and PATH so the bridge can locate sub-executables. Do not forward ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or inherited process.env because the bridge is an untrusted external process.
*/
const sourceEnv = options.sourceEnv ?? process.env;
const env: NodeJS.ProcessEnv = {};
for (const key of allowList) {
const value = sourceEnv[key];
if (typeof value === "string") env[key] = value;
}
const missing = (options.required ?? []).filter((key) => typeof env[key] !== "string");
if (missing.length > 0) {
throw new MissingAcpEnvError(missing);
}
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);
}