diff --git a/docs/acp-contract.md b/docs/acp-contract.md index faef935258..c0b7508fb9 100644 --- a/docs/acp-contract.md +++ b/docs/acp-contract.md @@ -161,3 +161,49 @@ 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.** diff --git a/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md index e73541930f..29053bc0e3 100644 --- a/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md +++ b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md @@ -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, ]`, `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, ]`, `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:[]`, `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. diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index e10891011a..c60afdd89e 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -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; @@ -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; /** diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts index 1e8ece526d..5f8b511653 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts @@ -45,6 +45,23 @@ describe("session driving helpers", () => { } }); + it("newAcpSession forwards non-empty mcpServers to session/new (U10 — Route A)", async () => { + const newSession = vi.fn(async () => ({ sessionId: "s1", modes: undefined })); + const fakeConn = { conn: { newSession } } as unknown as AcpConnection; + const servers = [ + { name: "custom-tools", command: "node", args: ["server.cjs"], env: [] as { name: string; value: string }[] }, + ]; + await newAcpSession(fakeConn, { cwd: "/tmp/work", mcpServers: servers }); + expect(newSession).toHaveBeenCalledWith({ cwd: "/tmp/work", mcpServers: servers }); + }); + + it("newAcpSession defaults mcpServers to [] when absent (Route B read-only posture)", async () => { + const newSession = vi.fn(async () => ({ sessionId: "s1", modes: undefined })); + const fakeConn = { conn: { newSession } } as unknown as AcpConnection; + await newAcpSession(fakeConn, { cwd: "/tmp/work" }); + expect(newSession).toHaveBeenCalledWith({ cwd: "/tmp/work", mcpServers: [] }); + }); + it("promptAcpSession resolves with end_turn for a normal turn", async () => { const conn = await open(); try { diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts index 0c60f4498f..56ebe24fea 100644 --- a/plugins/fusion-plugin-acp-runtime/src/provider.ts +++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts @@ -29,7 +29,7 @@ import { createEventBridge } from "./event-bridge.js"; import { resolvePermission, type ResolvePermissionOptions } from "./control-handler.js"; import { createFsHandlers } from "./fs-capabilities.js"; import { boundIdentifier } from "./sanitize.js"; -import type { AcpCallbacks, PermissionGate } from "./types.js"; +import type { AcpCallbacks, AcpMcpServer, PermissionGate } from "./types.js"; /** Options enabling the U7 fs client capabilities on the bridging handler. */ export interface FsHandlerBuildOptions { @@ -346,14 +346,19 @@ export interface NewAcpSessionResult { } /** - * Open a fresh ACP session via `session/new`. Always passes an empty - * `mcpServers` (KTD5 — Fusion custom-tool forwarding is deferred). + * Open a fresh ACP session via `session/new`. Forwards `opts.mcpServers` (U10 — + * Route A): when present and non-empty, the agent can call those Fusion tools and + * each call still routes through the U5 permission floor. Defaults to `[]` so + * Route B read-only ask turns keep their no-tools posture. */ export async function newAcpSession( connection: AcpConnection, - opts: { cwd: string }, + opts: { cwd: string; mcpServers?: AcpMcpServer[] }, ): Promise { - const res = await connection.conn.newSession({ cwd: opts.cwd, mcpServers: [] }); + const res = await connection.conn.newSession({ + cwd: opts.cwd, + mcpServers: opts.mcpServers ?? [], + }); // `sessionId` is agent-supplied/untrusted (U6/Risk S7): bound its length and // strip path separators / NUL bytes before it is stored on the session or // could ever touch a resume-file path. diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts index 97f7ccbd2c..92753e391d 100644 --- a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -82,10 +82,15 @@ export class AcpRuntimeAdapter implements AgentRuntime { clientHandler, }); - // Open the ACP session over the task worktree (empty mcpServers — KTD5). + // Open the ACP session over the task worktree. Forward MCP servers when the + // caller supplied them (U10 — Route A); absent/empty keeps the Route B + // read-only ask posture. Tool calls still route through the U5 permission floor. let sessionId: string; try { - const opened = await newAcpSession(connection, { cwd: options.cwd }); + const opened = await newAcpSession(connection, { + cwd: options.cwd, + mcpServers: options.mcpServers, + }); sessionId = opened.sessionId; } catch (err) { // Don't leak the subprocess if session/new fails after a good handshake. diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts index 55e4ebaade..cb623a49d1 100644 --- a/plugins/fusion-plugin-acp-runtime/src/types.ts +++ b/plugins/fusion-plugin-acp-runtime/src/types.ts @@ -20,6 +20,19 @@ export interface AcpCallbacks { onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; } +/** + * A stdio MCP server forwarded to the agent on `session/new` (U10 — Route A). + * `env` is explicit name/value pairs; inherited `process.env` is NEVER forwarded + * to the untrusted agent. Maps 1:1 onto an ACP `mcpServers` entry and onto what + * `pi-claude-cli`'s `mcp-config.ts` builds for `--mcp-config`. + */ +export interface AcpMcpServer { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + /** Per-category permission disposition (mirrors the engine policy shape). */ export type GateDisposition = "allow" | "block" | "require-approval"; @@ -92,6 +105,12 @@ export interface AgentRuntimeOptions { defaultThinkingLevel?: string; /** Per-run permission gate, populated by the engine. See PermissionGate. */ actionGateContext?: PermissionGate; + /** + * MCP servers to forward on `session/new` (U10 — Route A). When present and + * non-empty, the agent can call these tools (each call still routes through the + * U5 permission floor). Absent/empty preserves Route B's read-only ask posture. + */ + mcpServers?: AcpMcpServer[]; } /** Live ACP session state tracked by the runtime adapter. */