Files
fusion/docs/acp-contract.md
gsxdsm 642780220e chore(acp): apply subagent-review follow-ups (changeset, KTD10 tests, docs)
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>
2026-06-15 11:40:06 -07:00

23 KiB
Raw Blame History

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.
  • stderr is 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 acpEnvAllowList allow-list only (inherited process.env is 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.1 is pinned under the ACP runtime plugin and the sentinel binary name resolves to this plugin's own node_modules/.bin shim, not to a PATH-selected substitute.
  • The read-only ask posture uses tools: "readonly", acpArgs: [], and leaves acpFsRead / acpFsWrite off. 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: HOME is forwarded so the underlying claude can read ~/.claude auth/session state, and PATH is forwarded for sub-executable resolution. ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, and inherited process.env are not forwarded.
  • checkSetup treats 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 run claude once 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.
  • promptWithFallback surfaces ACP stopReason to the runner. Planning tolerates an absent stop reason, but validation treats abnormal/truncated stops such as max_tokens and cancelled as error regardless of any recovered JSON.
  • Validator prose fallback is constrained: prose can infer fail or blocked, but never pass. A pass requires clean structured JSON (verdict:"pass" or passed:true) from a clean turn.

Readiness = the initialize handshake

There is no --version probe. Readiness is the protocol handshake itself:

  1. Spawn the agent subprocess.
  2. Send initialize { protocolVersion: 1, clientCapabilities: { fs } } under a timeout (default 30s — research flagged Gemini-on-macOS OAuth and Claude-adapter session/new stalls).
  3. The agent responds with its integer protocolVersion, agentCapabilities, and authMethods.
  4. 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 AbortSignal in the runtime contract; teardown enters via an unawaited synchronous dispose() plus the process-registry kill. The registry SIGKILL is the authoritative no-orphan / no-deadlock guarantee; a best-effort session/cancel + pending-permission drain runs first when timing allows but is opportunistic.

Sources

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:

  1. Whether claude-code-cli-acp can forward the real Fusion MCP server(s) supplied through ACP session/new.mcpServers to the underlying interactive claude, using the actual packages/pi-claude-cli/src/mcp-config.ts stdio shape ({ command: "node", args: [serverPath, schemaFilePath] }), not a stub.
  2. Whether a forwarded Fusion tool invocation surfaces back to Fusion as ACP session/request_permission and therefore traverses the existing permission gate, or whether the bridge lets claude invoke 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:

  1. Can Claude invoke a real forwarded Fusion tool through the bridge? UNPROVEN / BLOCKED. The bridge accepts the non-empty mcpServers declaration, but the underlying claude session is still unauthenticated from the bridge's perspective and no forwarded MCP tool was invoked.
  2. 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:

  1. Can Claude invoke a real forwarded Fusion tool through the bridge? UNPROVEN / BLOCKED. The bridge still accepts the non-empty mcpServers declaration, but the underlying claude session remains unauthenticated from the bridge's perspective and no forwarded MCP tool was invoked.
  2. 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:

  1. 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.
  2. Do forwarded tool calls traverse ACP session/request_permission? UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed). The explicit client-side requestPermission instrumentation 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 claude authenticated via the interactive login session — no /login wall.
  • (1) MCP forwarding: PROVEN — Claude invoked mcp__custom-tools__fn_task_list; the MCP server's tools/call executed; result returned via a tool_call session/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):

  1. Host the provider's bridge in a process with login-Keychain access (run within the user's Aqua session, not a detached launchd daemon).
  2. Provide the bridge's claude a file/API-key credential the daemon CAN read — but the user's auth is claude.ai OAuth, and the env allow-list deliberately excludes ANTHROPIC_API_KEY from the untrusted bridge; changing that is a security-posture decision.
  3. 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 fired ToolSearch first (internal, completed), then mcp__custom-tools__fn_task_list — a permission request fired, we cancelled, the call went to failed, and the schema server's tools/call was NEVER reached (no execution marker). Forwarded tools do not execute when cancelled.
  • Native Bash: permission request fired, we cancelled, Bash went to failed, 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 mcpServers through the engine AgentRuntimeOptions → ACP plugin adapter newAcpSession (for the engine-driven acp runtime). U11's pi-claude-cli provider does NOT consume that field — streamViaAcp drives its OWN inline ACP client and builds mcpServers locally via buildAcpMcpServers (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. streamViaAcp synthesizes pi events via createEventBridge from ACP session/updates, which carry no token-usage frames, so output.usage stays 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.