Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/acp-route-a-claude-cli-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@runfusion/fusion": minor
---

Route Fusion's Claude CLI path through the ACP bridge (`claude-code-cli-acp`) instead of `claude -p` (Route A, dormant behind an OFF-by-default kill-switch).

- **U10** — forward `mcpServers` on ACP `session/new` through the runtime contract (`AgentRuntimeOptions.mcpServers` + the plugin's `newAcpSession`); defaults to `[]` so existing read-only ACP "ask" turns are unchanged.
- **U11** — `streamViaAcp`: the `pi-claude-cli` provider can drive Claude through the bundled ACP bridge, returning the same `AssistantMessageEventStream` as the `-p` path. Dispatched only when `FUSION_CLAUDE_ACP=1` and a bridge path are present, so the live `-p` path is byte-for-byte untouched by default. Full-history prompting, schema-only MCP forwarding with break-early on pi-known tools, control-char/size sanitization, env allow-list, process-registry registration, and inactivity timeout.
- **KTD10** — the ACP runtime plugin publishes its identity-pinned bundled bridge path on load so the kill-switch needs no manual path; it does not enable the transport.

The Claude-via-pi OAuth path is unchanged. Live verification confirmed the bridge gates tool execution behind `session/request_permission` (forwarded MCP tools and native tools do not execute when cancelled). Remaining for a follow-up: picker/auth/status surface (U12), workflow `model`-node verification (U13), and production rollout.
73 changes: 73 additions & 0 deletions docs/acp-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,76 @@ FN-6476 was the genuinely-authenticated U9 escalation rerun, but this worktree s
**FN-6475 sponsorship record (2026-06-15):** upstream sponsorship was authored in [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](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/update`s, 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.
10 changes: 10 additions & 0 deletions docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ sequenceDiagram
- **FN-6473 escalation outcome (2026-06-15): UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This explicit escalation again verified real bridge prerequisites (`claude` **2.1.177** at `/Users/eclipxe/.local/bin/claude`, plugin-local pinned `claude-code-cli-acp` **0.1.1**, unchanged lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`) and drove the pinned bridge with explicit `session/request_permission` instrumentation. The non-empty ACP payload was the Route-A `custom-tools` stdio server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, <temp schema file>]`, `env: []`) carrying **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` entry. The prompt instructed Claude to call `fn_task_list`, but the turn ended with **`Not logged in · Please run /login`**, stopReason `end_turn`, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** and answer **(2)** remains **UNPROVEN / BLOCKED** (neither GATED nor BYPASSED observed). Sponsor bridge/ACP MCP permission-forwarding and rerun in a genuinely authenticated bridge environment; never resolve this by falling back to `claude -p`.
- **FN-6475 upstream sponsorship (2026-06-15): sponsorship authored and filed; combined Route A verdict remains NOT GO.** The ready-to-file package is committed at [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](../upstream/claude-code-cli-acp-mcp-permission-forwarding.md) and filed upstream as https://github.com/moabualruz/claude-code-cli-acp/issues/2. It requests both required upstream capabilities: forwarding ACP `session/new.mcpServers` to authenticated `claude`, and routing forwarded MCP tool calls through ACP `session/request_permission` or an equivalent MCP-layer permission hook. This is an escalation/tracking action only; OQ1 stays **UNRESOLVED / BLOCKED**, U9 stays **NOT GO**, and no `claude -p` fallback is acceptable.
- **FN-6476 genuinely-authenticated rerun attempt (2026-06-15): still UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This run re-confirmed `claude` **2.1.177** at `/Users/eclipxe/.local/bin/claude`, pinned `claude-code-cli-acp` **0.1.1** under `plugins/fusion-plugin-acp-runtime/node_modules/.bin`, and unchanged lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`. The FN-6473 payload was supplied by the committed OQ1 record and rebuilt from the real Route-A shape: one `custom-tools` stdio server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, <temp schema file>]`, `env: []`) carrying **62** Fusion custom-tool names from `packages/cli/src/extension.ts`. The authenticated-readiness proof opened ACP directly and drove a no-MCP prompt turn before any tool verdict; the bridge returned **`Not logged in · Please run /login`** with stopReason `end_turn`, zero tool-like updates, and zero `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** (no forwarded Fusion tool invoked) and answer **(2)** remains **UNPROVEN / BLOCKED** (neither GATED nor BYPASSED observed). FN-6475 remains the sponsorship path; no `claude -p` fallback is acceptable.
- **✅ INTERACTIVE-SESSION SPIKE (2026-06-15): U9 MECHANICS = GO — overturns the NOT-GO chain above, with one operational precondition.** Run in an **interactive TTY with `claude` logged in** (`loggedIn:true`, claude.ai / eclipxe@gmail.com) — the exact condition every headless task (FN-6466/6467/6473/6476) lacked. Pinned bridge `claude-code-cli-acp` **0.1.1** driven directly over ACP (SDK **0.24.0**) with a **non-empty** `session/new.mcpServers`: one stdio server `custom-tools` (`command:"node"`, `args:[<mcp-server.cjs exposing fn_task_list>]`, `env:[]`). Observed: **(auth)** no `/login` wall — the bridged `claude` authenticated via the interactive login/keychain session; **(1) forwarded-tool invocation = PROVEN** — Claude invoked `mcp__custom-tools__fn_task_list`, the MCP server's `tools/call` executed (ground-truth marker file written), and the result flowed back as a `tool_call` `session/update`; **(2) gate traversal = GATED** — a `session/request_permission` (options `allow_once`/`allow_always`/`reject`) fired **before** execution. So the bridge forwards MCP **and** the ACP permission floor holds (NOT bypassed) — both security-critical answers resolved positively. **THE RESIDUAL IS OPERATIONAL, NOT MECHANICAL:** auth succeeds only where the bridged `claude` can reach the login/keychain session. FN-6476 "authenticated" but ran in the **Fusion daemon/worker context** detached from that session → `Not logged in`. **Conclusion: U9 mechanics GO; U10–U13 are unblocked for implementation. New Route-A acceptance gate (R17): the runtime that hosts the `pi-claude-cli` provider must have an authenticated `claude` (keychain/login access, or file-based creds the daemon can read).** The FN-6475 upstream issue is no longer the mechanics blocker; the daemon-auth precondition is the remaining ship gate. Harness: `/tmp/acp-u9-*/{spike.mjs,mcp-server.cjs}`.
- **OQ2 (blocking sub-gate of U11) — Resume loss is amnesia, not a slowdown.** On resume the provider sends **only the latest user turn** (`buildResumePrompt`, `packages/pi-claude-cli/src/provider.ts:114-125`) and relies on `--resume` to load prior conversation from disk. The ACP path opens a **fresh session per turn** with no `sessionId` passthrough (`loadAcpSession` deferred). Dropping resume **without** switching to full-history prompts makes Claude answer multi-turn chat/executor conversations with zero prior context — silently. **Decision required in U11:** either thread `sessionId` → `loadAcpSession`, or send full flattened history (`buildPrompt`) every turn. No path may send latest-turn-only without resume.
- **OQ3 (blocking sub-gate of U11) — Tool-call & partial-message fidelity through the round-trip.** The provider consumes native `stream-json` with `--include-partial-messages` (exact tool-call argument boundaries); the ACP path re-derives chunks from transcript-JSONL → ACP `session/update` → the event bridge, which sanitizes/space-repairs/bounds the stream. Confirm tool-call arguments survive with intact start/end correlation and no space-repair corruption of JSON args, and that executor/reviewer lanes tolerate the transformed deltas. Capture exact tool-call argument bytes in U11's characterization tests, not just token ordering.

Expand Down Expand Up @@ -382,6 +383,15 @@ plugins/fusion-plugin-acp-runtime/src/
- Model id selected in settings is forwarded to the ACP session.
- Characterization tests for the prior `-p` behavior are updated, not left asserting the old transport.

**Implementation notes (design-confirmed 2026-06-15, ready to execute):**
- **Contract to match:** `streamViaCli(model, context, options): AssistantMessageEventStream` (from `@earendil-works/pi-ai`). The new `streamViaAcp` must return the same `AssistantMessageEventStream` and push the same event shapes: streamed `text`/`thinking` deltas, `ToolCall` events, and a terminal `{ type: "done", reason, message }` (an `AssistantMessage` with `content:[]` on error — pi's `extractResult` crashes on `error`-typed events, so end with `done` even on failure, mirroring `endStreamWithError` at `provider.ts:181-198`).
- **Branch point:** in `index.ts` `streamSimple` (lines 222-235), dispatch on the kill-switch: `useAcpBridge() ? streamViaAcp(model, context, {...options, mcpServers, bridgePath}) : streamViaCli(...)`. Kill-switch OFF by default (R14) — e.g. `FUSION_CLAUDE_ACP==="1"` or a `useClaudeCliAcp` global setting — so the live `-p` path is untouched until soak.
- **KTD10 injection seam:** add `@agentclientprotocol/sdk` as a `pi-claude-cli` dependency (vendored package — allowed) so the extension speaks ACP without importing `@fusion/engine`. The **bridge binary path** is injected via `streamSimple` options the same way `mcpConfigPath` is today (engine resolves it from the acp-runtime plugin bundle at `registerExtensionProviders`, `pi.ts:1366-1422`, and threads it in) — the extension never reaches into the plugin's `node_modules` itself.
- **MCP servers:** reuse the tool list `ensureMcpConfig` already assembles (`index.ts:223-230`) to build the `AcpMcpServer[]` (`{name:"custom-tools",command:"node",args:[schemaServer,schemaFile],env:[]}`) — the same shape U9 proved and U10 forwards.
- **Prompt (R13):** always `buildPrompt(context)` (full flattened history) — never the `buildResumePrompt` latest-turn-only branch (`provider.ts:115-124`), since the ACP path has no `--resume`.
- **ACP→pi event translation** parallels `plugins/fusion-plugin-acp-runtime/src/event-bridge.ts` (ACP `session/update` → callbacks) but targets pi's `AssistantMessageEventStream` instead of Fusion callbacks; reuse `tool-mapping.ts` for Claude↔pi tool names.
- **Verification:** drive the live bridge exactly as the U9 harness did (`/tmp/acp-u9-*/spike.mjs`) but asserting pi-stream output, before enabling the kill-switch in any lane.

### U12. Settings, picker, auth, and status surface

**Goal:** Make the Claude-CLI toggle/picker/status reflect the ACP-backed reality without forcing user re-selection.
Expand Down
19 changes: 19 additions & 0 deletions packages/engine/src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ export interface AgentRuntimeContext {
requestedSkillNames?: string[];
}

/**
* A stdio MCP server forwarded to a runtime's agent session (U10 — Route A ACP).
* `env` is explicit name/value pairs; inherited `process.env` is never forwarded.
* Runtimes that don't speak MCP ignore this field.
*/
export interface AgentMcpServerConfig {
name: string;
command: string;
args: string[];
env: { name: string; value: string }[];
}

export interface AgentRuntimeOptions {
/** Working directory for the agent session */
cwd: string;
Expand Down Expand Up @@ -78,6 +90,13 @@ export interface AgentRuntimeOptions {
skills?: string[];
/** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */
runtimeContext?: AgentRuntimeContext;
/**
* MCP servers to forward to the runtime's agent session (U10 — Route A ACP).
* Consumed by runtimes that speak MCP (e.g. the ACP runtime forwards them on
* `session/new`); ignored by runtimes that don't. Tool calls still route
* through the runtime's permission floor.
*/
mcpServers?: AgentMcpServerConfig[];
/** Optional task-scoped environment variables for session-local subprocesses. */
taskEnv?: NodeJS.ProcessEnv;
/**
Expand Down
Loading
Loading