Files
fusion/plugins/fusion-plugin-acp-runtime/src/probe.ts
gsxdsm 028c90ab88 feat(acp): ACP transport, handshake, subprocess lifecycle (U2)
Adds the connection layer: spawnAgent + self-cleaning process registry,
env allow-list (no inherited process.env, KTD6b), redacted stderr capture
(S8), and connect() establishing a ClientSideConnection over ndJsonStream
and completing the initialize handshake with explicit integer protocol-
version negotiation (KTD2) under a timeout. fs capabilities advertised only
when toggled (KTD6); teardown is registry-SIGKILL-authoritative (KTD4a).
probe.ts adds an async readiness probe with a failure taxonomy. Includes a
minimal runnable echo-agent fixture and 25 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:43:42 -07:00

105 lines
3.1 KiB
TypeScript

// Async readiness probe + failure taxonomy for the ACP runtime.
//
// Mirrors the droid-runtime async-probe convention (KTD4): never block the
// event loop, never throw into it — always resolve a typed status. The probe
// spawns the agent, completes (or fails) the `initialize` handshake under a
// timeout, and maps the outcome onto a small failure taxonomy so the UI can
// distinguish "binary missing" from "handshake stalled" from "needs auth".
import {
connect,
HandshakeTimeoutError,
IncompatibleProtocolError,
DEFAULT_INITIALIZE_TIMEOUT_MS,
} from "./provider.js";
export type AcpProbeReason =
| "ok"
| "missing_binary"
| "spawn_error"
| "handshake_timeout"
| "incompatible_protocol"
| "unauthenticated";
export interface AcpProbeStatus {
ok: boolean;
reason: AcpProbeReason;
detail?: string;
protocolVersion?: number;
authRequired?: boolean;
}
export interface ProbeOptions {
binaryPath: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
}
function isMissingBinary(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOENT";
}
/**
* Probe whether the configured ACP agent is present and completes the handshake.
*
* Never rejects — resolves an `AcpProbeStatus`. Always tears the spawned process
* down afterward (success or failure). Mapping:
* - ENOENT spawn error → `missing_binary`
* - other spawn error → `spawn_error`
* - handshake timeout → `handshake_timeout`
* - mismatched protocol version → `incompatible_protocol`
* - non-empty authMethods → `ok` with `authRequired: true`
*/
export async function probeAcpReadiness(opts: ProbeOptions): Promise<AcpProbeStatus> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
let connection: Awaited<ReturnType<typeof connect>> | undefined;
try {
connection = await connect({
binaryPath: opts.binaryPath,
args: opts.args,
cwd: opts.cwd,
env: opts.env,
// Probe with fs capabilities OFF — readiness must not advertise anything
// the real session might not (KTD6).
advertiseFs: { read: false, write: false },
initializeTimeoutMs: timeoutMs,
});
const authRequired = connection.authMethods.length > 0;
return {
ok: true,
reason: "ok",
authRequired,
};
} catch (err) {
if (err instanceof HandshakeTimeoutError) {
return { ok: false, reason: "handshake_timeout", detail: err.message };
}
if (err instanceof IncompatibleProtocolError) {
return {
ok: false,
reason: "incompatible_protocol",
detail: err.message,
protocolVersion: err.agentProtocolVersion,
};
}
if (isMissingBinary(err)) {
return {
ok: false,
reason: "missing_binary",
detail: `ACP agent binary not found: ${opts.binaryPath}`,
};
}
return {
ok: false,
reason: "spawn_error",
detail: err instanceof Error ? err.message : String(err),
};
} finally {
connection?.dispose();
}
}