Merge pull request #1680 from Runfusion/feature/acp-route-a

feat: migrate Claude off `claude -p` to ACP runtime + bridge (Route B + U10; Route A in progress)
This commit is contained in:
gsxdsm
2026-06-16 03:08:00 +08:00
committed by GitHub
18 changed files with 935 additions and 84 deletions

View File

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

View File

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

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

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

@@ -8,6 +8,7 @@
import { getModels } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { streamViaCli } from "./src/provider.js";
import { streamViaAcp } from "./src/acp-driver.js";
import {
validateCliPresenceAsync,
validateCliAuthAsync,
@@ -18,9 +19,32 @@ import {
getCustomToolDefs,
toolsFromContext,
writeMcpConfig,
buildAcpMcpServers,
type McpToolDef,
} from "./src/mcp-config.js";
/**
* Route A kill-switch (U11). When `FUSION_CLAUDE_ACP=1` AND a bridge binary path
* is available (`FUSION_CLAUDE_ACP_BRIDGE`, injected by the engine seam per
* KTD10), the provider drives Claude through the ACP bridge instead of
* `claude -p`. OFF by default: the live `-p` path is untouched until soak.
*/
function resolveAcpBridgePath(): string | undefined {
if (process.env.FUSION_CLAUDE_ACP !== "1") return undefined;
const p = process.env.FUSION_CLAUDE_ACP_BRIDGE;
return typeof p === "string" && p.length > 0 ? p : undefined;
}
/** Resolve custom tool defs the same way ensureMcpConfig does (context → registry). */
function resolveToolDefs(
pi: ExtensionAPI,
contextTools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }>,
): McpToolDef[] {
let toolDefs = toolsFromContext(contextTools);
if (toolDefs.length === 0 && Array.isArray(pi.getAllTools())) toolDefs = getCustomToolDefs(pi);
return toolDefs;
}
// Kill all active Claude subprocesses on process exit to prevent orphans
process.on("exit", killAllProcesses);
@@ -220,14 +244,29 @@ export default function (pi: ExtensionAPI) {
api: "pi-claude-cli",
models,
streamSimple: (model, context, options) => {
const configPath = ensureMcpConfig(
pi,
(context as { tools?: ReadonlyArray<{
name: string;
description: string;
parameters: Record<string, unknown>;
}> }).tools,
);
const contextTools = (context as { tools?: ReadonlyArray<{
name: string;
description: string;
parameters: Record<string, unknown>;
}> }).tools;
// Route A (U11): drive Claude through the ACP bridge when the kill-switch
// is on AND a bridge path is injected. OFF by default → `-p` path below.
const bridgePath = resolveAcpBridgePath();
if (bridgePath) {
const toolDefs = resolveToolDefs(pi, contextTools);
const hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12);
return streamViaAcp(model, context, {
...options,
bridgePath,
mcpServers: buildAcpMcpServers(toolDefs, hash),
// Forward only HOME/PATH so the bridged `claude` authenticates from the
// login/keychain session (R17); never inherited process.env or API keys.
bridgeEnv: { HOME: process.env.HOME, PATH: process.env.PATH },
});
}
const configPath = ensureMcpConfig(pi, contextTools);
return streamViaCli(model, context, {
...options,
mcpConfigPath: configPath,

View File

@@ -19,6 +19,9 @@
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/pi-claude-cli"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.24.0"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*"

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Spy on both transports so we can assert which one streamSimple dispatches to.
const { streamViaCli, streamViaAcp } = vi.hoisted(() => ({
streamViaCli: vi.fn(() => ({ kind: "cli" })),
streamViaAcp: vi.fn(() => ({ kind: "acp" })),
}));
vi.mock("../provider.js", () => ({ streamViaCli }));
vi.mock("../acp-driver.js", () => ({ streamViaAcp }));
// Registering the provider kicks off async CLI presence/auth probes; stub them
// so the test doesn't trip the "real CLI launch blocked" guard.
vi.mock("../process-manager.js", () => ({
validateCliPresenceAsync: vi.fn(async () => ({ ok: true })),
validateCliAuthAsync: vi.fn(async () => ({ ok: true })),
killAllProcesses: vi.fn(),
}));
// Belt-and-suspenders: no real CLI spawn even if a probe slips through.
vi.mock("node:child_process", () => ({ spawn: vi.fn(() => ({ on: vi.fn(), stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, stdin: { write: vi.fn(), end: vi.fn() }, kill: vi.fn() })), execSync: vi.fn(() => Buffer.from("")) }));
vi.mock("@earendil-works/pi-ai", () => ({
getModels: vi.fn(() => []),
AssistantMessageEventStream: vi.fn(),
calculateCost: vi.fn(),
}));
import register from "../../index.js";
function registerAndGetStreamSimple() {
const calls: Array<[string, { streamSimple: (...a: unknown[]) => unknown }]> = [];
const pi = {
registerProvider: (name: string, cfg: { streamSimple: (...a: unknown[]) => unknown }) => calls.push([name, cfg]),
on: vi.fn(),
getAllTools: () => [],
setActiveTools: vi.fn(),
} as never;
register(pi);
return calls[0][1].streamSimple;
}
const MODEL = { id: "claude-sonnet-4-5" } as never;
const CTX = { messages: [{ role: "user", content: "hi" }], tools: [] } as never;
describe("streamSimple kill-switch dispatch (U11/R9/R14)", () => {
const saved = { flag: process.env.FUSION_CLAUDE_ACP, bridge: process.env.FUSION_CLAUDE_ACP_BRIDGE };
beforeEach(() => { streamViaCli.mockClear(); streamViaAcp.mockClear(); });
afterEach(() => {
process.env.FUSION_CLAUDE_ACP = saved.flag;
process.env.FUSION_CLAUDE_ACP_BRIDGE = saved.bridge;
});
it("defaults to the -p path (streamViaCli) when the kill-switch is OFF", () => {
delete process.env.FUSION_CLAUDE_ACP;
const streamSimple = registerAndGetStreamSimple();
streamSimple(MODEL, CTX, {});
expect(streamViaCli).toHaveBeenCalledTimes(1);
expect(streamViaAcp).not.toHaveBeenCalled();
});
it("stays on -p when the flag is set but NO bridge path is provided", () => {
process.env.FUSION_CLAUDE_ACP = "1";
delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
const streamSimple = registerAndGetStreamSimple();
streamSimple(MODEL, CTX, {});
expect(streamViaCli).toHaveBeenCalledTimes(1);
expect(streamViaAcp).not.toHaveBeenCalled();
});
it("dispatches to the ACP bridge when the flag AND a bridge path are set", () => {
process.env.FUSION_CLAUDE_ACP = "1";
process.env.FUSION_CLAUDE_ACP_BRIDGE = "/abs/claude-code-cli-acp";
const streamSimple = registerAndGetStreamSimple();
streamSimple(MODEL, CTX, {});
expect(streamViaAcp).toHaveBeenCalledTimes(1);
expect(streamViaCli).not.toHaveBeenCalled();
// bridgePath forwarded; env restricted to the allow-list at spawn (driver).
const opts = (streamViaAcp.mock.calls[0] as unknown[])[2] as { bridgePath?: string; bridgeEnv?: Record<string, unknown> };
expect(opts.bridgePath).toBe("/abs/claude-code-cli-acp");
expect(opts.bridgeEnv).toBeDefined();
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
// Synthetic ACP session/update sequence the mocked prompt() will replay.
let scriptedUpdates: Array<Record<string, unknown>> = [];
// Driver validates the bridge path with existsSync — make the fake path "exist".
vi.mock("node:fs", () => ({ existsSync: () => true }));
vi.mock("node:child_process", () => ({
spawn: vi.fn(() => {
const proc = new EventEmitter() as EventEmitter & Record<string, unknown>;
proc.stdin = new PassThrough();
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
proc.kill = vi.fn();
proc.pid = 4242;
return proc;
}),
}));
// Mock the ACP SDK: ClientSideConnection.prompt() replays scriptedUpdates onto
// the client handler, then resolves — so we exercise the real translation logic.
vi.mock("@agentclientprotocol/sdk", () => ({
PROTOCOL_VERSION: 1,
ndJsonStream: vi.fn(() => ({})),
ClientSideConnection: vi.fn(function (this: Record<string, unknown>, factory: () => { sessionUpdate: (p: unknown) => Promise<void> }) {
const handler = factory();
this.initialize = vi.fn(async () => ({ protocolVersion: 1 }));
this.newSession = vi.fn(async () => ({ sessionId: "s1" }));
this.prompt = vi.fn(async () => {
for (const u of scriptedUpdates) await handler.sessionUpdate({ update: u });
return { stopReason: "end_turn" };
});
}),
}));
const { MockStream } = vi.hoisted(() => {
const MockStream: unknown = vi.fn(function (this: Record<string, unknown>) {
const events: Array<Record<string, unknown>> = [];
this.push = vi.fn((e: Record<string, unknown>) => events.push(e));
this.end = vi.fn();
this._events = events;
});
return { MockStream };
});
vi.mock("@earendil-works/pi-ai", () => ({
AssistantMessageEventStream: MockStream,
calculateCost: vi.fn(),
}));
import { streamViaAcp } from "../acp-driver.js";
const MODEL = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" } as never;
const CTX = { messages: [{ role: "user", content: "hi" }] } as never;
const OPTS = { bridgePath: "/fake/claude-code-cli-acp", cwd: "/tmp", mcpServers: [], bridgeEnv: { HOME: "/h", PATH: "/b" } };
function eventsOf(stream: { _events: Array<Record<string, unknown>> }) {
return stream._events;
}
const flush = () => new Promise((r) => setTimeout(r, 30));
describe("streamViaAcp — ACP→pi translation (U11)", () => {
beforeEach(() => { scriptedUpdates = []; });
it("translates agent_message_chunk text into pi text events + done(stop)", async () => {
scriptedUpdates = [
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello " } },
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" } },
];
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
const types = eventsOf(stream).map((e) => e.type);
expect(types).toContain("start");
expect(types).toContain("text_start");
expect(types.filter((t) => t === "text_delta").length).toBe(2);
const done = eventsOf(stream).find((e) => e.type === "done");
expect(done).toBeDefined();
expect(done!.reason).toBe("stop");
});
it("breaks early on a tool_call: emits toolcall_start + done(toolUse), no execution", async () => {
scriptedUpdates = [
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "let me check" } },
{ sessionUpdate: "tool_call", toolCallId: "t1", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
// anything after the tool call must be ignored (break-early)
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "SHOULD NOT APPEAR" } },
];
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
const types = eventsOf(stream).map((e) => e.type);
expect(types).toContain("toolcall_start");
const done = eventsOf(stream).find((e) => e.type === "done");
expect(done!.reason).toBe("toolUse");
// break-early: the post-tool text delta must not have been translated
const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta);
expect(deltas.join("")).not.toContain("SHOULD NOT APPEAR");
});
it("does NOT break early on an internal ToolSearch; breaks on the real fn_* tool (U9 sequence)", async () => {
// Claude emits ToolSearch (not pi-known) to load the deferred MCP tool FIRST,
// then the real mcp__custom-tools__fn_task_list. The old code aborted on
// ToolSearch; the gated code must wait for the real tool (P0 fix).
scriptedUpdates = [
{ sessionUpdate: "tool_call", toolCallId: "ts1", _meta: { claudeCode: { toolName: "ToolSearch" } }, rawInput: { query: "x" } },
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "found it, calling" } },
{ sessionUpdate: "tool_call", toolCallId: "real", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
];
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
// The text AFTER ToolSearch must have been processed (we didn't abort on ToolSearch)
const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta);
expect(deltas.join("")).toContain("found it");
// And we broke on the real tool
expect(eventsOf(stream).some((e) => e.type === "toolcall_start")).toBe(true);
const done = eventsOf(stream).find((e) => e.type === "done");
expect(done!.reason).toBe("toolUse");
});
it("ends with done even when the turn produces no content", async () => {
scriptedUpdates = [];
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
expect(eventsOf(stream).some((e) => e.type === "done")).toBe(true);
});
});

View File

@@ -0,0 +1,315 @@
/**
* ACP transport for the pi-claude-cli provider (U11 — Route A).
*
* `streamViaAcp` is the drop-in alternative to `streamViaCli` that drives Claude
* through the `claude-code-cli-acp` bridge over the Agent Client Protocol instead
* of `claude -p`. It returns the SAME `AssistantMessageEventStream` shape, so the
* provider's `streamSimple` can dispatch to either transport behind a kill-switch.
*
* Design (see plan U11):
* - Full-history prompt EVERY turn (`buildPrompt`) — the ACP path has no Claude
* `--resume`, so we never send the latest-turn-only `buildResumePrompt` (R13).
* - MCP tool SCHEMAS are forwarded on `session/new` so Claude knows the Fusion
* tools and emits correct `tool_use` calls; we DO NOT let the bridge execute
* them. We break early ONLY on a pi-known tool (mirroring the `-p` guard) — and
* surface it to pi, which runs the tool itself. Claude's INTERNAL tools
* (`ToolSearch`/`Task`/…) are NOT pi-known: we must NOT break on them, or we'd
* abort the turn before the real `fn_*` call (Claude uses `ToolSearch` to load
* deferred MCP tools first). See review P0 (correctness).
* - Translation reuses the tested `createEventBridge` by synthesizing Claude
* stream events from ACP `session/update`s, so pi event sequencing, tool-name
* mapping and arg translation are shared with the `-p` path.
* - Untrusted-output floor: agent text/thinking is control-char-stripped and
* byte-capped; identifiers are bounded (review P1, security).
*
* Auth: the bridge spawns the real `claude`, which authenticates from the host
* login/keychain session (R17). The bridge binary path is injected by the caller
* (engine seam, KTD10) — this module never reaches into the ACP plugin.
*
* NOT-YET-VERIFIED (kill-switch stays OFF until then): the bridge's tool-execution
* ordering (`session/request_permission` vs `tool_call` update) and native-tool
* (Read/Write/Bash) execution-prevention need a live behavioral test against the
* real bridge before any lane enables this path. `requestPermission` denies by
* default and we break early, but the TOCTOU window is unproven (review P2).
*/
import { spawn, type ChildProcess } from "node:child_process";
import { Readable, Writable } from "node:stream";
import { isAbsolute } from "node:path";
import { existsSync } from "node:fs";
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
} from "@agentclientprotocol/sdk";
import { AssistantMessageEventStream } from "@earendil-works/pi-ai";
import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import { buildPrompt, buildSystemPrompt, type PiContext } from "./prompt-builder.js";
import { createEventBridge } from "./event-bridge.js";
import { registerProcess, captureStderr } from "./process-manager.js";
import { isPiKnownClaudeTool } from "./tool-mapping.js";
import type { ClaudeApiEvent } from "./types.js";
/** A stdio MCP server forwarded on `session/new` (schema-only — never executed here). */
export interface AcpMcpServerSpec {
name: string;
command: string;
args: string[];
env: { name: string; value: string }[];
}
/** Options for the ACP transport: pi's stream options plus ACP wiring. */
export type StreamViaAcpOptions = SimpleStreamOptions & {
cwd?: string;
/** Absolute path to the `claude-code-cli-acp` bridge binary (injected by the engine seam). */
bridgePath: string;
/** MCP servers (tool schemas) forwarded so Claude emits correct tool calls. */
mcpServers?: AcpMcpServerSpec[];
/** Env keys to forward to the bridge — filtered to the allow-list below regardless. */
bridgeEnv?: NodeJS.ProcessEnv;
};
const INITIALIZE_TIMEOUT_MS = 30_000;
/** Last-resort guard: kill a silent bridge. Mirrors streamViaCli (30 min). */
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
/** Untrusted-output bounds (review P1, security). */
const MAX_CHUNK_CHARS = 64 * 1024;
const MAX_TURN_CHARS = 5_000_000;
const MAX_ID_CHARS = 256;
/**
* Bridge subprocess env allow-list. The bridged `claude` needs HOME (for
* `~/.claude` auth/keychain, R17) and PATH; terminal vars improve rendering.
* Inherited `process.env` and any secret-bearing keys are NEVER forwarded — the
* filter is enforced HERE, not trusted from the caller (review P2, security).
*/
const BRIDGE_ENV_ALLOWLIST = [
"HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
"TERM", "TERMINFO", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "COLORTERM",
];
function buildBridgeEnv(supplied?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const source = supplied ?? process.env;
const env: NodeJS.ProcessEnv = {};
for (const key of BRIDGE_ENV_ALLOWLIST) {
const v = source[key];
if (typeof v === "string") env[key] = v;
}
return env;
}
/** Strip ANSI escape sequences and C0/C1 control chars (keep \n \r \t), then cap length. */
function sanitizeText(text: string, cap = MAX_CHUNK_CHARS): string {
// eslint-disable-next-line no-control-regex
const stripped = text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
return stripped.length > cap ? stripped.slice(0, cap) : stripped;
}
/** Bound an untrusted identifier (tool id / name) before it becomes a content-block key. */
function boundId(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, MAX_ID_CHARS);
}
/**
* Convert buildPrompt's output into ACP prompt content blocks, PRESERVING image
* blocks (review P1, correctness — flatten-to-text dropped vision input).
*/
function toAcpPromptBlocks(
prompt: string | Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
if (typeof prompt === "string") return [{ type: "text", text: prompt }];
const out: Array<Record<string, unknown>> = [];
for (const b of prompt) {
if (b.type === "text" && typeof b.text === "string") {
out.push({ type: "text", text: b.text });
} else if (b.type === "image" && b.source && typeof b.source === "object") {
const src = b.source as { media_type?: string; data?: string };
if (src.data) out.push({ type: "image", mimeType: src.media_type ?? "image/png", data: src.data });
}
}
return out;
}
/**
* Stream a Claude response via the ACP bridge as an `AssistantMessageEventStream`.
* Mirrors `streamViaCli`'s contract (start → deltas → done; break-early on tools).
*/
export function streamViaAcp(
model: Model<Api>,
context: PiContext,
options: StreamViaAcpOptions,
): AssistantMessageEventStream {
// @ts-expect-error — pi-ai exports AssistantMessageEventStream as a type; the
// constructor exists at runtime (same workaround as streamViaCli).
const stream = new AssistantMessageEventStream();
const bridge = createEventBridge(stream, model);
(async () => {
let child: ChildProcess | undefined;
let getStderr: (() => string) | undefined;
let ended = false;
let turnChars = 0;
let blockIndex = -1;
let openKind: "text" | "thinking" | null = null;
let sawToolCall = false;
let inactivity: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const cleanup = () => {
if (inactivity) { clearTimeout(inactivity); inactivity = undefined; }
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
try { child?.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
};
const armInactivity = () => {
if (inactivity) clearTimeout(inactivity);
inactivity = setTimeout(() => failWith(`ACP bridge inactivity timeout after ${INACTIVITY_TIMEOUT_MS / 1000}s`), INACTIVITY_TIMEOUT_MS);
};
const finish = (reason: "stop" | "tool_use") => {
if (ended) return;
ended = true;
if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
// Downgrade a tool_use turn that surfaced zero pi tool calls → stop, so pi
// doesn't try to dispatch non-existent tools (mirrors provider.ts:366-375).
const toolCount = (bridge.getOutput().content ?? []).filter((c) => (c as { type?: string }).type === "toolCall").length;
const effective: "stop" | "tool_use" = reason === "tool_use" && toolCount > 0 ? "tool_use" : "stop";
bridge.handleEvent({ type: "message_delta", delta: { stop_reason: effective === "tool_use" ? "tool_use" : "end_turn" } } as ClaudeApiEvent);
stream.push({ type: "done", reason: effective === "tool_use" ? "toolUse" : "stop", message: bridge.getOutput() });
stream.end();
cleanup();
};
const failWith = (msg: string) => {
if (ended) return;
ended = true;
const output = bridge.getOutput();
stream.push({
type: "done",
reason: "stop",
message: {
...output,
content: output.content?.length ? output.content : [{ type: "text" as const, text: `Error: ${msg}` }],
stopReason: "stop" as const,
},
});
stream.end();
cleanup();
};
const openBlock = (kind: "text" | "thinking") => {
if (openKind === kind) return;
if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
blockIndex += 1;
openKind = kind;
bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: kind } } as ClaudeApiEvent);
};
// Surface a pi-known tool call to pi and break early (pi executes it, not the bridge).
const surfaceToolAndBreak = (claudeName: string, rawId: string, rawInput: unknown) => {
const id = boundId(rawId);
if (openKind !== null) { bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); openKind = null; }
blockIndex += 1;
bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", name: boundId(claudeName), id } } as ClaudeApiEvent);
bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: sanitizeText(JSON.stringify(rawInput ?? {})) } } as ClaudeApiEvent);
bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
sawToolCall = true;
finish("tool_use");
};
const clientHandler = {
async sessionUpdate(params: { update?: Record<string, unknown> } & Record<string, unknown>) {
if (ended) return;
armInactivity();
const u = (params.update ?? params) as Record<string, unknown>;
const kind = u.sessionUpdate as string;
const content = u.content as { type?: string; text?: string } | undefined;
if (kind === "agent_message_chunk" && content?.type === "text" && content.text) {
if (turnChars >= MAX_TURN_CHARS) return;
openBlock("text");
const text = sanitizeText(content.text);
turnChars += text.length;
bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } } as ClaudeApiEvent);
} else if (kind === "agent_thought_chunk" && content?.text) {
if (turnChars >= MAX_TURN_CHARS) return;
openBlock("thinking");
const text = sanitizeText(content.text);
turnChars += text.length;
bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: text } } as ClaudeApiEvent);
} else if (kind === "tool_call") {
// Break early ONLY on a pi-known tool. Claude's internal tools
// (ToolSearch/Task/…) are not pi-known — let the bridge run them so
// Claude can load deferred MCP schemas and emit the real fn_* call.
const claudeName = ((u._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (u.title as string) ?? "";
if (isPiKnownClaudeTool(claudeName)) {
surfaceToolAndBreak(claudeName, (u.toolCallId as string) ?? `acp_${blockIndex + 1}`, u.rawInput ?? u.input);
}
}
},
async requestPermission(params: Record<string, unknown>) {
// A permission request means the bridge is about to EXECUTE a tool. For a
// pi-known tool, surface it to pi and break early (pi executes it); deny
// by default otherwise. We always return cancelled so the bridge never
// executes Fusion's tools itself.
if (!ended) {
const tc = (params.toolCall ?? {}) as Record<string, unknown>;
const claudeName = ((tc._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (tc.title as string) ?? "";
if (isPiKnownClaudeTool(claudeName)) {
surfaceToolAndBreak(claudeName, (tc.toolCallId as string) ?? `acp_${blockIndex + 1}`, tc.rawInput ?? tc.input);
}
}
return { outcome: { outcome: "cancelled" as const } };
},
};
try {
if (!isAbsolute(options.bridgePath) || !existsSync(options.bridgePath)) {
failWith(`ACP bridge path invalid (must be an absolute, existing binary): ${options.bridgePath}`);
return;
}
child = spawn(options.bridgePath, [], { stdio: ["pipe", "pipe", "pipe"], cwd: options.cwd ?? process.cwd(), env: buildBridgeEnv(options.bridgeEnv) });
registerProcess(child);
getStderr = captureStderr(child);
child.on("error", (e) => failWith(`ACP bridge spawn failed: ${e.message}`));
child.on("close", (code) => { if (!ended) failWith(`ACP bridge exited (code ${code ?? "?"})${getStderr ? `: ${getStderr().slice(-500)}` : ""}`); });
onAbort = () => failWith("aborted");
if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
armInactivity();
const acpStream = ndJsonStream(
Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
);
const conn = new ClientSideConnection(() => clientHandler, acpStream);
const withTimeout = <T>(p: Promise<T>, label: string) =>
Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`ACP ${label} timeout`)), INITIALIZE_TIMEOUT_MS))]);
const init = await withTimeout(
conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } }),
"initialize",
);
if (ended) return;
if (init.protocolVersion !== PROTOCOL_VERSION) { failWith(`incompatible ACP protocol ${init.protocolVersion}`); return; }
const opened = await withTimeout(conn.newSession({ cwd: options.cwd ?? process.cwd(), mcpServers: options.mcpServers ?? [] }), "newSession");
if (ended) return;
const cwd = options.cwd ?? process.cwd();
const systemPrompt = buildSystemPrompt(context, cwd);
const blocks = [
...(systemPrompt ? [{ type: "text" as const, text: `${systemPrompt}\n\n` }] : []),
...toAcpPromptBlocks(buildPrompt(context) as string | Array<Record<string, unknown>>),
];
// ACP ContentBlock[] — text/image shapes match; cast through unknown.
await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters<typeof conn.prompt>[0]["prompt"] });
if (!sawToolCall) finish("stop");
} catch (err) {
failWith(err instanceof Error ? err.message : String(err));
}
})();
return stream;
}

View File

@@ -142,3 +142,32 @@ export function writeMcpConfig(
return configFilePath;
}
/** A stdio MCP server spec for ACP `session/new.mcpServers` (U11 — Route A). */
export interface AcpMcpServerSpec {
name: string;
command: string;
args: string[];
env: { name: string; value: string }[];
}
/**
* Build the ACP `mcpServers` spec for the same schema-only `custom-tools` server
* `writeMcpConfig` produces for `--mcp-config` — but as the inline ACP shape
* (`session/new.mcpServers`) instead of a config-file path (U11). Writes the
* tool-schema file and points the server at the shared `mcp-schema-server.cjs`.
* Returns `[]` when there are no custom tools (Route B read-only posture).
*/
export function buildAcpMcpServers(
toolDefs: McpToolDef[],
cacheKey?: string,
): AcpMcpServerSpec[] {
if (toolDefs.length === 0) return [];
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
const schemaFilePath = join(tmpdir(), `pi-claude-mcp-schemas-${suffix}.json`);
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
const serverPath = join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs");
return [
{ name: "custom-tools", command: "node", args: [serverPath, schemaFilePath], env: [] },
];
}

View File

@@ -133,3 +133,44 @@ describe("resolveCliSettings", () => {
expect(ask.allowUnrestricted).toBe(false);
});
});
describe("KTD10 — onLoad publishes the bundled bridge path (Route A)", () => {
const savedBridge = process.env.FUSION_CLAUDE_ACP_BRIDGE;
const savedFlag = process.env.FUSION_CLAUDE_ACP;
afterEach(() => {
if (savedBridge === undefined) delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
else process.env.FUSION_CLAUDE_ACP_BRIDGE = savedBridge;
if (savedFlag === undefined) delete process.env.FUSION_CLAUDE_ACP;
else process.env.FUSION_CLAUDE_ACP = savedFlag;
});
const fakeCtx = () => ({ settings: {}, logger: { info: () => undefined, warn: () => undefined } });
it("publishes the bundled bridge path to FUSION_CLAUDE_ACP_BRIDGE when unset", () => {
delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
plugin.hooks?.onLoad?.(fakeCtx() as never);
expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBeDefined();
expect(isAbsolute(process.env.FUSION_CLAUDE_ACP_BRIDGE!)).toBe(true);
expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toContain("node_modules/.bin/claude-code-cli-acp");
});
it("does NOT enable the transport — FUSION_CLAUDE_ACP stays unset (kill-switch off)", () => {
delete process.env.FUSION_CLAUDE_ACP;
delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
plugin.hooks?.onLoad?.(fakeCtx() as never);
expect(process.env.FUSION_CLAUDE_ACP).toBeUndefined();
});
it("respects an explicit FUSION_CLAUDE_ACP_BRIDGE override", () => {
process.env.FUSION_CLAUDE_ACP_BRIDGE = "/custom/bridge/path";
plugin.hooks?.onLoad?.(fakeCtx() as never);
expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBe("/custom/bridge/path");
});
it("is idempotent — a second onLoad keeps the first published path and does not throw", () => {
delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
plugin.hooks?.onLoad?.(fakeCtx() as never);
const first = process.env.FUSION_CLAUDE_ACP_BRIDGE;
expect(() => plugin.hooks?.onLoad?.(fakeCtx() as never)).not.toThrow();
expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBe(first);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, vi, afterEach } from "vitest";
// Force the bundled-bridge resolver to "not_resolved" so we can assert onLoad's
// fail-closed branch: when the bridge isn't installed, nothing is published and
// Route A stays unavailable (the kill-switch falls back to `-p`).
vi.mock("../cli-spawn.js", async (importActual) => {
const actual = await importActual<typeof import("../cli-spawn.js")>();
return {
...actual,
resolveBundledClaudeBridgeBinary: () => ({
kind: "not_resolved",
requested: "claude-code-cli-acp",
path: "/missing/claude-code-cli-acp",
reason: "bundled bridge not installed (test)",
}),
};
});
import plugin from "../index.js";
const fakeCtx = () => ({ settings: {}, logger: { info: () => undefined, warn: () => undefined } });
describe("KTD10 fail-closed — bundled bridge not resolved", () => {
const saved = process.env.FUSION_CLAUDE_ACP_BRIDGE;
afterEach(() => {
if (saved === undefined) delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
else process.env.FUSION_CLAUDE_ACP_BRIDGE = saved;
});
it("does NOT publish FUSION_CLAUDE_ACP_BRIDGE when the bridge is not resolved", () => {
delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
plugin.hooks?.onLoad?.(fakeCtx() as never);
expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBeUndefined();
});
});

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

@@ -1,6 +1,6 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { resolveCliSettings, resolveBundledClaudeBridgeBinary } from "./cli-spawn.js";
import { AcpRuntimeAdapter } from "./runtime-adapter.js";
import { killAllProcesses } from "./process-manager.js";
import { setupHooks, setupManifest } from "./setup.js";
@@ -49,6 +49,25 @@ const plugin: FusionPlugin = definePlugin({
"will be auto-approved under an allow-all policy. Prefer an approval-required policy.",
);
}
// KTD10 (Route A): publish the bundled `claude-code-cli-acp` bridge path
// process-wide so the pi-claude-cli provider's kill-switch can resolve it
// WITHOUT a manual FUSION_CLAUDE_ACP_BRIDGE env var. This only PUBLISHES the
// path — the ACP transport stays OFF until an operator sets
// FUSION_CLAUDE_ACP=1 (the rollout gate). An explicit env override wins, and
// the resolver is identity-pinned to the plugin-owned node_modules/.bin shim
// so a same-named global binary cannot replace the reviewed bridge.
if (!process.env.FUSION_CLAUDE_ACP_BRIDGE) {
const resolved = resolveBundledClaudeBridgeBinary();
if (resolved.kind === "resolved") {
process.env.FUSION_CLAUDE_ACP_BRIDGE = resolved.path;
ctx.logger.info(
"ACP Runtime: published bundled Claude bridge path for Route A " +
"(transport stays off until FUSION_CLAUDE_ACP=1).",
);
} else {
ctx.logger.info(`ACP Runtime: bundled Claude bridge not resolved (${resolved.reason}); Route A unavailable.`);
}
}
},
},
runtime: {

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

139
pnpm-lock.yaml generated
View File

@@ -47,10 +47,10 @@ importers:
dependencies:
'@earendil-works/pi-ai':
specifier: ^0.79.1
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-coding-agent':
specifier: ^0.79.1
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
dockerode:
specifier: ^4.0.12
version: 4.0.12
@@ -585,12 +585,15 @@ importers:
packages/pi-claude-cli:
dependencies:
'@agentclientprotocol/sdk':
specifier: 0.24.0
version: 0.24.0(zod@4.3.6)
'@earendil-works/pi-ai':
specifier: '*'
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-coding-agent':
specifier: '*'
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
devDependencies:
'@types/node':
specifier: ^25.5.2
@@ -7911,20 +7914,6 @@ snapshots:
- ws
- zod
'@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
ignore: 7.0.5
typebox: 1.1.38
yaml: 2.9.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
@@ -7953,6 +7942,20 @@ snapshots:
- ws
- zod
'@earendil-works/pi-agent-core@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
ignore: 7.0.5
typebox: 1.1.38
yaml: 2.9.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@earendil-works/pi-agent-core@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
@@ -8001,26 +8004,6 @@ snapshots:
- ws
- zod
'@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
'@mistralai/mistralai': 2.2.1
'@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7
typebox: 1.1.38
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
@@ -8061,6 +8044,26 @@ snapshots:
- ws
- zod
'@earendil-works/pi-ai@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
'@mistralai/mistralai': 2.2.1
'@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7
typebox: 1.1.38
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@earendil-works/pi-ai@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
@@ -8130,35 +8133,6 @@ snapshots:
- ws
- zod
'@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-tui': 0.77.0
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
cross-spawn: 7.0.6
diff: 8.0.4
glob: 13.0.6
highlight.js: 10.7.3
hosted-git-info: 9.0.3
ignore: 7.0.5
jiti: 2.7.0
minimatch: 10.2.5
proper-lockfile: 4.1.2
typebox: 1.1.38
undici: 8.3.0
yaml: 2.9.0
optionalDependencies:
'@mariozechner/clipboard': 0.3.9
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
@@ -8217,6 +8191,35 @@ snapshots:
- ws
- zod
'@earendil-works/pi-coding-agent@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-agent-core': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-tui': 0.79.1
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
cross-spawn: 7.0.6
diff: 8.0.4
glob: 13.0.6
highlight.js: 10.7.3
hosted-git-info: 9.0.3
ignore: 7.0.5
jiti: 2.7.0
minimatch: 10.2.5
proper-lockfile: 4.1.2
typebox: 1.1.38
undici: 8.3.0
yaml: 2.9.0
optionalDependencies:
'@mariozechner/clipboard': 0.3.9
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@earendil-works/pi-coding-agent@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@earendil-works/pi-agent-core': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
@@ -9807,7 +9810,7 @@ snapshots:
obug: 2.1.2
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.8(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
vitest: 4.1.8(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.8':
dependencies: