Two-reviewer pass (security + architecture) on KTD10 + the full Route A increment: no code defects, no P0, merge-ready as a dormant increment. Applying the P1 follow-ups: - Add the feature changeset (@runfusion/fusion minor) — the one convention gap. - KTD10 tests: fail-closed (bridge not resolved -> env stays unset -> -p) and idempotency (second onLoad keeps the first published path). - Document the two intentional, parallel MCP-forwarding paths (U10 engine-adapter vs U11 provider-driver) so nobody double-forwards, and the known ACP-path-token-usage=0 residual so U12 doesn't treat it as a bug. Reviewers confirmed: dormancy invariant holds end-to-end (nothing sets FUSION_CLAUDE_ACP=1; both flag+path required; -p is the default); OAuth pi path untouched. 206/206 plugin tests, 333/333 pi-claude-cli tests, typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 KiB
ACP (Agent Client Protocol) Runtime Contract
Date: 2026-06-03
Launch/readiness contract and failure taxonomy for fusion-plugin-acp-runtime,
which drives any external Agent Client Protocol
agent over JSON-RPC/stdio. Mirrors the shape of docs/cursor-cli-contract.md.
Transport
- Newline-delimited JSON-RPC 2.0 over stdio (no Content-Length framing).
Provided by
@agentclientprotocol/sdk(ndJsonStream+ClientSideConnection). - The client (Fusion) launches the agent as a subprocess with piped stdio. The agent's stdin is the JSON-RPC output stream; its stdout is the input stream.
stderris captured (redacted) for diagnostics, never parsed as protocol.
Invocation and binary detection
- Unlike a single-vendor CLI, ACP is a protocol — the agent binary + ACP-mode
flag are user-configured:
acpBinaryPath— e.g.gemini,npx, or an absolute path.acpArgs— the flag(s) that put the agent in ACP/stdio mode, e.g.["--acp"].
- The subprocess environment is built from the
acpEnvAllowListallow-list only (inheritedprocess.envis not forwarded — the agent is untrusted).
Claude bridge ask profile (Route B)
Route-B planning and validator asks use the acp runtime with the bundled
claude-code-cli-acp bridge instead of claude -p:
claude-code-cli-acp@0.1.1is pinned under the ACP runtime plugin and the sentinel binary name resolves to this plugin's ownnode_modules/.binshim, not to a PATH-selected substitute.- The read-only ask posture uses
tools: "readonly",acpArgs: [], and leavesacpFsRead/acpFsWriteoff. Route A's tool-bearing provider path remains deferred and is not implied by this profile. - The Claude bridge env allow-list is intentionally narrow:
HOMEis forwarded so the underlyingclaudecan read~/.claudeauth/session state, andPATHis forwarded for sub-executable resolution.ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN, and inheritedprocess.envare not forwarded. checkSetuptreats the bridge as installed only when the resolved binary is plugin-owned, the ACP handshake succeeds, and no Claude auth hint is returned. Auth-needed statuses tell the operator to runclaudeonce to authenticate.
askAcpOnce prose → JSON recovery contract
The engine-side askAcpOnce runner creates one readonly ACP session, accumulates
all onText deltas into text, runs one promptWithFallback turn, optionally
recovers the trailing JSON object via extractJsonObjects, and disposes the
session in finally. Its shape is deliberately close to the old one-shot result:
- Success:
{ ok: true, text, parsed?, stopReason? }. - Failure:
{ ok: false, reason, message, text?, stopReason? }for session creation errors, turn errors, timeouts, and abnormal stops. promptWithFallbacksurfaces ACPstopReasonto the runner. Planning tolerates an absent stop reason, but validation treats abnormal/truncated stops such asmax_tokensandcancelledaserrorregardless of any recovered JSON.- Validator prose fallback is constrained: prose can infer
failorblocked, but neverpass. A pass requires clean structured JSON (verdict:"pass"orpassed:true) from a clean turn.
Readiness = the initialize handshake
There is no --version probe. Readiness is the protocol handshake itself:
- Spawn the agent subprocess.
- Send
initialize { protocolVersion: 1, clientCapabilities: { fs } }under a timeout (default 30s — research flagged Gemini-on-macOS OAuth and Claude-adaptersession/newstalls). - The agent responds with its integer
protocolVersion,agentCapabilities, andauthMethods. - The client compares the integer protocol version; an unsupported version is a hard failure (do not assume the agent errors first).
fs capabilities are advertised only when acpFsRead/acpFsWrite are
enabled (writes default OFF).
Failure taxonomy (probe.ts AcpProbeReason)
| Reason | Trigger |
|---|---|
ok |
Handshake completed (with authRequired: true when authMethods is non-empty) |
missing_binary |
Spawn ENOENT (binary not found, code 127) |
spawn_error |
Other spawn failure |
handshake_timeout |
initialize did not complete within the bound (code 124) |
incompatible_protocol |
Agent negotiated an unsupported integer protocol version |
unauthenticated |
Agent requires an auth method the client cannot satisfy |
Lifecycle / teardown
- The engine has no
AbortSignalin the runtime contract; teardown enters via an unawaited synchronousdispose()plus the process-registry kill. The registry SIGKILL is the authoritative no-orphan / no-deadlock guarantee; a best-effortsession/cancel+ pending-permission drain runs first when timing allows but is opportunistic.
Sources
- https://agentclientprotocol.com (introduction, schema, transports, initialization, tool-calls)
@agentclientprotocol/sdkv0.24.0 — https://www.npmjs.com/package/@agentclientprotocol/sdk- Validation: the SDK example echo agent (CI) + an in-repo controllable fixture
(
src/__tests__/fixtures/echo-agent.mjs); Gemini CLI / Claude-adapter for manual e2e.
Open Questions
OQ1 — Route A MCP-over-ACP forwarding and permission-gate traversal
Status: UNRESOLVED / BLOCKED as of FN-6476 (2026-06-15). Gate traversal: UNRESOLVED — no forwarded tool invocation reached the point where it could be classified as GATED or BYPASSED. Combined Route A verdict: NOT GO until this OQ records both required U9 answers as GO.
Recovery status: NOT-RECOVERED. fn_task_show FN-6459 retained only archived task metadata plus an archive log entry, .fusion/tasks/FN-6459/ is absent in the FN-6465 worktree, and fn_task_document_read(key="research") returned not found from FN-6465's execution context. No surviving authoritative FN-6459 U9 verdict was available to transcribe.
U9 answers required before Route A implementation:
- Whether
claude-code-cli-acpcan forward the real Fusion MCP server(s) supplied through ACPsession/new.mcpServersto the underlying interactiveclaude, using the actualpackages/pi-claude-cli/src/mcp-config.tsstdio shape ({ command: "node", args: [serverPath, schemaFilePath] }), not a stub. - Whether a forwarded Fusion tool invocation surfaces back to Fusion as ACP
session/request_permissionand therefore traverses the existing permission gate, or whether the bridge letsclaudeinvoke the MCP tool autonomously inside the bridge, bypassing the gate.
FN-6465 result: these answers remain unproven. Local binaries were present during recovery (claude 2.1.177 and pinned claude-code-cli-acp 0.1.1), but FN-6465 did not complete an authenticated, instrumented spike against the real Fusion MCP config with ACP permission telemetry. Do not infer a GO from binary presence.
FN-6466 result (real bridge run, still blocked): The follow-up spike opened ACP session/new directly with a non-empty Route-A MCP payload so it did not reuse the plugin helper that still hardcodes mcpServers: []. The payload matched the real mcp-config.ts stdio shape: one server named custom-tools, command: "node", args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, <temp schema file>], env: [], and a temp schema file containing 62 captured Fusion custom tools sourced from packages/cli/src/extension.ts. The bridge accepted initialize and session/new with that payload, so the transport did not reject the forwarded MCP declaration outright. The first prompt turn explicitly instructed Claude to call fn_task_list, but the turn ended with assistant text Not logged in · Please run /login, zero tool-call updates, and zero ACP session/request_permission callbacks.
FN-6467 result (second real bridge run, still blocked): The rerun verified claude 2.1.177 on PATH and the pinned claude-code-cli-acp 0.1.1 shim under plugins/fusion-plugin-acp-runtime/node_modules/.bin; the lockfile records integrity sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==. The harness again opened ACP directly with one non-empty stdio MCP server named custom-tools, command: "node", args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, <temp schema file>], env: [], containing 62 Fusion custom-tool names confirmed from packages/cli/src/extension.ts and matching FN-6466's payload source. initialize returned agentInfo.name="claude-code-cli-acp", version="0.1.1", and authMethods=["claude-code-login"]; session/new accepted the non-empty mcpServers payload and returned a session. The prompt explicitly instructed Claude to call fn_task_list, but the turn ended with assistant text Not logged in · Please run /login, stopReason end_turn, zero tool-call updates, and zero ACP session/request_permission callbacks.
Recorded OQ1 state after FN-6467:
- Can Claude invoke a real forwarded Fusion tool through the bridge? UNPROVEN / BLOCKED. The bridge accepts the non-empty
mcpServersdeclaration, but the underlyingclaudesession is still unauthenticated from the bridge's perspective and no forwarded MCP tool was invoked. - Do forwarded tool calls traverse ACP
session/request_permission? UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed). No forwarded tool call occurred, so the rerun observed no permission callback and cannot classify the security-critical gate path.
FN-6473 result (escalation rerun, still blocked): The escalation re-verified the local prerequisites and an actual bridge turn: claude 2.1.177 resolved at /Users/eclipxe/.local/bin/claude; the plugin-local pinned bridge shim resolved at plugins/fusion-plugin-acp-runtime/node_modules/.bin/claude-code-cli-acp and reported 0.1.1; the lockfile still records integrity sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==. The instrumented harness opened ACP directly with one non-empty stdio MCP server named custom-tools, command: "node", args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, <temp schema file>], env: [], containing 62 Fusion custom-tool names confirmed from packages/cli/src/extension.ts and matching mcp-config.ts's writeMcpConfig shape. initialize returned agentInfo.name="claude-code-cli-acp", version="0.1.1", and authMethods=["claude-code-login"]; session/new accepted the non-empty mcpServers payload and returned a session. The prompt explicitly instructed Claude to invoke fn_task_list, but the turn ended with assistant text Not logged in · Please run /login, stopReason end_turn, zero tool-call updates, and zero ACP session/request_permission callbacks.
Recorded OQ1 state after FN-6473:
- Can Claude invoke a real forwarded Fusion tool through the bridge? UNPROVEN / BLOCKED. The bridge still accepts the non-empty
mcpServersdeclaration, but the underlyingclaudesession remains unauthenticated from the bridge's perspective and no forwarded MCP tool was invoked. - Do forwarded tool calls traverse ACP
session/request_permission? UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed). The explicit request-permission instrumentation recorded zero callbacks because no forwarded tool call occurred.
FN-6476 result (genuinely-authenticated rerun attempt, still blocked): This rerun first re-verified the local prerequisites: claude 2.1.177 resolved at /Users/eclipxe/.local/bin/claude; the plugin-local bridge shim resolved at plugins/fusion-plugin-acp-runtime/node_modules/.bin/claude-code-cli-acp and reported 0.1.1; the lockfile still records integrity sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==. The payload source was the committed FN-6473/FN-6475 OQ1 record plus a rebuild from the real mcp-config.ts shape and packages/cli/src/extension.ts: one stdio MCP server named custom-tools, command: "node", args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, <temp schema file>], env: [], carrying 62 Fusion custom-tool names. The authenticated-readiness proof opened ACP directly against the pinned bridge and drove a no-MCP prompt turn before attempting any forwarded-tool verdict; initialize returned agentInfo.name="claude-code-cli-acp", version="0.1.1", authMethods=["claude-code-login"], and the turn returned assistant text Not logged in · Please run /login with stopReason end_turn, zero tool-like updates, and zero ACP session/request_permission callbacks. Because the readiness proof failed, the harness did not proceed to the MCP-forwarding prompt; no forwarded Fusion tool was invoked and gate traversal could not be classified as GATED or BYPASSED.
Recorded OQ1 state after FN-6476:
- Can Claude invoke a real forwarded Fusion tool through the bridge? UNPROVEN / BLOCKED. The bridge binary and real 62-tool payload are present, but this environment still cannot exercise an authenticated bridge session; no forwarded MCP tool was invoked.
- Do forwarded tool calls traverse ACP
session/request_permission? UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed). The explicit client-siderequestPermissioninstrumentation recorded zero callbacks because the auth-readiness gate failed before a forwarded tool call.
Escalation path: rerun U9 with an environment where the pinned bridge can reach an authenticated claude, the same non-empty session/new.mcpServers shape, and explicit session/request_permission instrumentation. Sponsor the missing bridge/ACP MCP permission-forwarding capability upstream: the bridge/ACP layer must forward session/new.mcpServers to the underlying Claude session and surface forwarded tool calls through ACP session/request_permission or an MCP-layer permission hook. If an authenticated rerun still ignores mcpServers, cannot invoke the forwarded tools, or bypasses the ACP permission gate without an MCP-layer permission hook or sensitive-tool exclusion, Route A remains blocked. A claude -p fallback is not an acceptable Route-A completion path.
FN-6475 sponsorship record (2026-06-15): upstream sponsorship was authored in docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md and filed as https://github.com/moabualruz/claude-code-cli-acp/issues/2. This records the requested MCP passthrough plus permission-gate traversal / MCP-layer hook contract only; OQ1 remains UNRESOLVED / BLOCKED and the combined Route A verdict remains NOT GO until a later authenticated rerun proves both required U9 answers.
U14 internal mechanisms: GO for design, subject to U9. Route A should use a second acp-claude runtime posture rather than mutating the generic acp runtime; inject the ACP bridge client from the engine registerExtensionProviders seam into the vendored @fusion/pi-claude-cli provider options; and add AgentRuntimeOptions.mcpServers to both the engine runtime contract and the ACP plugin-local structural copy, with newAcpSession defaulting to [] for Route-B compatibility.
U9 verdict — MCP-over-ACP through the Claude bridge (2026-06-15)
U9 MECHANICS = GO (overturns the prior headless NOT-GO chain; see plan OQ1).
Spike: pinned claude-code-cli-acp 0.1.1 driven directly over ACP (SDK 0.24.0)
in an interactive TTY with claude logged in, non-empty session/new.mcpServers
= one stdio custom-tools server exposing fn_task_list.
- Auth: bridged
claudeauthenticated via the interactive login session — no/loginwall. - (1) MCP forwarding: PROVEN — Claude invoked
mcp__custom-tools__fn_task_list; the MCP server'stools/callexecuted; result returned via atool_callsession/update. - (2) Permission gate: GATED —
session/request_permission(allow_once/allow_always/reject) fired before execution. The ACP permission floor holds; forwarded MCP calls are NOT bypassed.
Operational precondition (R17): auth works only where the bridged claude can reach
the login/keychain session. The Fusion daemon/worker context is detached from that session
→ Not logged in (this is why FN-6466/6467/6473/6476 failed). Route-A mechanics are
unblocked (U10–U13 buildable); shipping requires the provider's runtime to host an
authenticated claude (keychain/login access, or file-based creds the daemon can read).
R17 resolution (2026-06-15): daemon-auth is the HARD ship-gate — creds are Keychain-only
claude here stores OAuth creds in the macOS Keychain (genp / svce="Claude Code-credentials"),
NOT in a file: ~/.claude/.credentials.json is an empty directory. Therefore forwarding HOME
to the bridge does not give the daemon-hosted claude its credentials. A detached Fusion
daemon/worker runs in a different security session with no login-Keychain access → Not logged in
(the root cause of the FN-6466/6467/6473/6476 failures).
Implication: U9 mechanics are GO, but Route A cannot ship to the daemon-hosted pi-claude-cli
provider until daemon→Keychain auth is solved. Candidate resolutions (each its own follow-up):
- Host the provider's bridge in a process with login-Keychain access (run within the user's Aqua session, not a detached launchd daemon).
- Provide the bridge's
claudea file/API-key credential the daemon CAN read — but the user's auth is claude.ai OAuth, and the env allow-list deliberately excludesANTHROPIC_API_KEYfrom the untrusted bridge; changing that is a security-posture decision. - Grant the daemon explicit Keychain access (
security unlock-keychain/ ACL) — fragile, security-sensitive.
Until one lands, Route A is mechanically proven but operationally blocked on macOS.
R17 CLOSED (2026-06-15): confirmed by user — Claude CLI works in the live fn daemon today
The user confirmed the existing pi-claude-cli (claude -p) provider authenticates in their
running Fusion daemon. Since the daemon is launched from their login session, it has macOS
Keychain access; the ACP bridge's claude inherits the same session and authenticates identically.
R17 is satisfied for the supported (login-session) daemon. Residual (documented, not blocking):
detached/headless launchd daemons would still need a credential-delivery solution — out of scope
for the supported setup. Route A (U10–U13) is cleared to build.
U11 tool-flow verification PASSED (2026-06-15): enablement gate cleared
Live run against pinned claude-code-cli-acp 0.1.1 in an authenticated session,
returning cancelled to every session/request_permission (what streamViaAcp
does). Two tests, fresh session each:
- Forwarded MCP tool (
fn_task_list): Claude firedToolSearchfirst (internal, completed), thenmcp__custom-tools__fn_task_list— a permission request fired, we cancelled, the call went tofailed, and the schema server'stools/callwas NEVER reached (no execution marker). Forwarded tools do not execute when cancelled. - Native Bash: permission request fired, we cancelled,
Bashwent tofailed, the side-effect file was never created. Native tools do not execute when cancelled.
Conclusion: the bridge gates tool execution BEHIND session/request_permission
(no TOCTOU window); streamViaAcp's deny-by-default handler + break-early on
pi-known tools is SAFE. Also validated: the bridged claude authenticates only
with the richer env allow-list (HOME/PATH + USER/SHELL/LANG/XDG_*) that
streamViaAcp forwards — a thin {HOME,PATH} env fails with "Not logged in".
The Route A enablement gate is CLEARED. Harness: /tmp/acp-toolflow/verify2.mjs.
Route A architecture notes (2026-06-15, from review)
- Two parallel MCP-forwarding paths, by design. U10 wires
mcpServersthrough the engineAgentRuntimeOptions→ ACP plugin adapternewAcpSession(for the engine-drivenacpruntime). U11'spi-claude-cliprovider does NOT consume that field —streamViaAcpdrives its OWN inline ACP client and buildsmcpServerslocally viabuildAcpMcpServers(KTD10: the provider speaks ACP directly via the published bridge path, never through the plugin adapter). The two intersect only at the shared schema-only MCP server shape. Do not "wire U10 into U11" — that would double-forward. - Known residual: ACP-path token usage/cost reads zero.
streamViaAcpsynthesizes pi events viacreateEventBridgefrom ACPsession/updates, which carry no token-usage frames, sooutput.usagestays zero on the ACP path. Cost telemetry undercounts when the kill-switch is enabled. The U12 status surface should not treat zero-usage as a bug; wiring usage (if the bridge ever exposes it) is deferred.