feat(acp): forward MCP servers on session/new (U10) + record U9 GO / R17

Route A unblock + the first Route A code increment.

- U9 verdict recorded (plan OQ1 + docs/acp-contract.md): in an authenticated
  interactive session the pinned claude-code-cli-acp 0.1.1 bridge forwards
  session/new mcpServers to Claude, Claude invokes the forwarded Fusion tool,
  and the call traverses the ACP permission gate (session/request_permission).
  Both security-critical answers resolve positively — overturns the headless
  NOT-GO chain (FN-6466/6467/6473/6476), whose only blocker was running
  detached from the login keychain session.
- R17 (daemon auth) recorded and closed for the supported setup: creds are
  macOS Keychain-only; the user's login-session fn daemon has keychain access
  (the existing claude -p provider authenticates there), so the bridge does too.
- U10: thread an optional mcpServers list through the ACP runtime contract.
  newAcpSession now forwards it (was hardcoded []); AgentRuntimeOptions (engine
  + plugin-local copy) gains the field; defaults to [] to preserve Route B's
  read-only ask posture. Tool calls still route through the U5 permission floor.

Plugin typechecks clean; provider-session tests 12/12 (incl. 2 new forwarding
tests). U11-U13 (provider transport swap, picker/auth, workflow verify) remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-15 03:12:28 -07:00
parent bc6dfd386e
commit 079844e1c1
7 changed files with 119 additions and 7 deletions

View File

@@ -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.**

View File

@@ -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.

View File

@@ -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;
/**

View File

@@ -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 {

View File

@@ -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<NewAcpSessionResult> {
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.

View File

@@ -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.

View File

@@ -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. */