From adb3eb3a37991207c7e831aa7ee1b062f6bce466 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 12:00:26 -0700 Subject: [PATCH] FN-8224: add Claude ACP runtime support Add a bundled Claude Code ACP runtime with model discovery and CLI distribution integration. - Add the Claude ACP runtime plugin, bridge, tool forwarding, and tests. - Register Claude model discovery and cached picker support in the dashboard. - Stage the plugin in CLI and desktop packaging with its pinned dependency. - Add workspace, lockfile, and release metadata. Files changed: .changeset/fn-8224-claude-acp-runtime.md | 7 + packages/cli/package.json | 1 + packages/cli/src/__tests__/bundle-output.test.ts | 32 +- .../cli/src/plugins/staged-bundled-plugin-ids.ts | 1 + packages/cli/tsup.config.ts | 40 +- .../core/src/plugins/bundled-plugin-install.ts | 2 + packages/dashboard/package.json | 3 +- packages/dashboard/src/claude-model-cache.ts | 185 +++++++ packages/dashboard/src/routes.ts | 1 + .../dashboard/src/routes/register-model-routes.ts | 11 + packages/dashboard/src/runtime-provider-probes.ts | 15 + packages/dashboard/vitest.config.ts | 4 + packages/desktop/scripts/workspace-tools.ts | 1 + plugins/fusion-plugin-claude-runtime/README.md | 7 + plugins/fusion-plugin-claude-runtime/manifest.json | 1 + plugins/fusion-plugin-claude-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 3 + .../src/__tests__/index.test.ts | 3 + .../src/__tests__/provider.test.ts | 8 + .../src/__tests__/runtime-adapter.test.ts | 7 + .../src/__tests__/tool-bridge.test.ts | 79 +++ .../src/acp-settings.ts | 21 + .../src/acp/VENDORED.md | 30 ++ .../src/acp/cli-spawn.ts | 188 +++++++ .../src/acp/control-handler.ts | 302 ++++++++++++ .../src/acp/event-bridge.ts | 308 ++++++++++++ .../src/acp/fs-capabilities.ts | 263 ++++++++++ .../fusion-plugin-claude-runtime/src/acp/index.ts | 16 + .../src/acp/path-jail.ts | 229 +++++++++ .../src/acp/process-manager.ts | 177 +++++++ .../src/acp/prompt-builder.ts | 87 ++++ .../src/acp/provider.ts | 542 +++++++++++++++++++++ .../src/acp/runtime-adapter.ts | 190 ++++++++ .../src/acp/sanitize.ts | 81 +++ .../src/acp/tool-mapping.ts | 47 ++ .../fusion-plugin-claude-runtime/src/acp/types.ts | 189 +++++++ .../fusion-plugin-claude-runtime/src/cli-spawn.ts | 31 ++ plugins/fusion-plugin-claude-runtime/src/index.ts | 95 ++++ .../src/mcp-forwarding.ts | 114 +++++ .../src/mcp-schema-server.cjs | 155 ++++++ plugins/fusion-plugin-claude-runtime/src/probe.ts | 8 + .../fusion-plugin-claude-runtime/src/provider.ts | 8 + .../src/runtime-adapter.ts | 424 ++++++++++++++++ .../src/skill-loader.ts | 290 +++++++++++ .../src/tool-bridge.ts | 264 ++++++++++ plugins/fusion-plugin-claude-runtime/src/types.ts | 142 ++++++ plugins/fusion-plugin-claude-runtime/tsconfig.json | 10 + .../fusion-plugin-claude-runtime/vitest.config.ts | 22 + pnpm-lock.yaml | 131 ++++- pnpm-workspace.yaml | 1 + 50 files changed, 4753 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8224 Fusion-Task-Lineage: 4e626913-32bb-4baa-a142-816c7f4ee7c4 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8224-claude-acp-runtime.md | 7 + packages/cli/package.json | 1 + .../cli/src/__tests__/bundle-output.test.ts | 32 +- .../src/plugins/staged-bundled-plugin-ids.ts | 1 + packages/cli/tsup.config.ts | 40 +- .../src/plugins/bundled-plugin-install.ts | 2 + packages/dashboard/package.json | 3 +- packages/dashboard/src/claude-model-cache.ts | 185 ++++++ packages/dashboard/src/routes.ts | 1 + .../src/routes/register-model-routes.ts | 11 + .../dashboard/src/runtime-provider-probes.ts | 15 + packages/dashboard/vitest.config.ts | 4 + packages/desktop/scripts/workspace-tools.ts | 1 + .../fusion-plugin-claude-runtime/README.md | 7 + .../manifest.json | 1 + .../fusion-plugin-claude-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 3 + .../src/__tests__/index.test.ts | 3 + .../src/__tests__/provider.test.ts | 8 + .../src/__tests__/runtime-adapter.test.ts | 7 + .../src/__tests__/tool-bridge.test.ts | 79 +++ .../src/acp-settings.ts | 21 + .../src/acp/VENDORED.md | 30 + .../src/acp/cli-spawn.ts | 188 ++++++ .../src/acp/control-handler.ts | 302 ++++++++++ .../src/acp/event-bridge.ts | 308 ++++++++++ .../src/acp/fs-capabilities.ts | 263 +++++++++ .../src/acp/index.ts | 16 + .../src/acp/path-jail.ts | 229 ++++++++ .../src/acp/process-manager.ts | 177 ++++++ .../src/acp/prompt-builder.ts | 87 +++ .../src/acp/provider.ts | 542 ++++++++++++++++++ .../src/acp/runtime-adapter.ts | 190 ++++++ .../src/acp/sanitize.ts | 81 +++ .../src/acp/tool-mapping.ts | 47 ++ .../src/acp/types.ts | 189 ++++++ .../src/cli-spawn.ts | 31 + .../fusion-plugin-claude-runtime/src/index.ts | 95 +++ .../src/mcp-forwarding.ts | 114 ++++ .../src/mcp-schema-server.cjs | 155 +++++ .../fusion-plugin-claude-runtime/src/probe.ts | 8 + .../src/provider.ts | 8 + .../src/runtime-adapter.ts | 424 ++++++++++++++ .../src/skill-loader.ts | 290 ++++++++++ .../src/tool-bridge.ts | 264 +++++++++ .../fusion-plugin-claude-runtime/src/types.ts | 142 +++++ .../tsconfig.json | 10 + .../vitest.config.ts | 22 + pnpm-lock.yaml | 131 ++++- pnpm-workspace.yaml | 1 + 50 files changed, 4753 insertions(+), 24 deletions(-) create mode 100644 .changeset/fn-8224-claude-acp-runtime.md create mode 100644 packages/dashboard/src/claude-model-cache.ts create mode 100644 plugins/fusion-plugin-claude-runtime/README.md create mode 100644 plugins/fusion-plugin-claude-runtime/manifest.json create mode 100644 plugins/fusion-plugin-claude-runtime/package.json create mode 100644 plugins/fusion-plugin-claude-runtime/src/__tests__/cli-spawn.test.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/__tests__/index.test.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/__tests__/provider.test.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp-settings.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/VENDORED.md create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/cli-spawn.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/control-handler.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/event-bridge.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/fs-capabilities.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/index.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/path-jail.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/process-manager.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/prompt-builder.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/provider.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/runtime-adapter.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/sanitize.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/tool-mapping.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/acp/types.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/cli-spawn.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/index.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/mcp-forwarding.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/mcp-schema-server.cjs create mode 100644 plugins/fusion-plugin-claude-runtime/src/probe.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/provider.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/skill-loader.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts create mode 100644 plugins/fusion-plugin-claude-runtime/src/types.ts create mode 100644 plugins/fusion-plugin-claude-runtime/tsconfig.json create mode 100644 plugins/fusion-plugin-claude-runtime/vitest.config.ts diff --git a/.changeset/fn-8224-claude-acp-runtime.md b/.changeset/fn-8224-claude-acp-runtime.md new file mode 100644 index 0000000000..d3e193a251 --- /dev/null +++ b/.changeset/fn-8224-claude-acp-runtime.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a first-class Claude runtime that drives Claude Code over ACP. +category: feature +dev: New bundled `fusion-plugin-claude-runtime` (provider `claude-cli`, runtime `claude`) composes the pinned `claude-code-cli-acp` bridge and is additive to experimental `pi-claude-cli` Route A. diff --git a/packages/cli/package.json b/packages/cli/package.json index 2c371f2b62..8d8811cd63 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,6 +62,7 @@ "dependencies": { "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", + "claude-code-cli-acp": "0.1.1", "dockerode": "^4.0.12", "electron": "^33.4.11", "embedded-postgres": "15.18.0-beta.17", diff --git a/packages/cli/src/__tests__/bundle-output.test.ts b/packages/cli/src/__tests__/bundle-output.test.ts index 28a9a1b4d0..145a706b19 100644 --- a/packages/cli/src/__tests__/bundle-output.test.ts +++ b/packages/cli/src/__tests__/bundle-output.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll } from "vitest"; -import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { resolvePluginSkillBodyPath } from "@fusion/core"; @@ -379,6 +379,36 @@ describe("CLI bundle output", () => { expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(true); }); + it("stages a portable Claude ACP launcher and declares every platform bridge", () => { + const bridgeRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-claude-runtime", "bridge"); + const executableName = process.platform === "win32" ? "claude-code-cli-acp.cmd" : "claude-code-cli-acp"; + const bridgePath = join(bridgeRoot, executableName); + const launcherManifestPath = join(bridgeRoot, "node_modules", "claude-code-cli-acp", "package.json"); + const launcherManifest = JSON.parse(readFileSync(launcherManifestPath, "utf8")) as { + optionalDependencies?: Record; + }; + const cliManifest = JSON.parse(readFileSync(join(cliRoot, "package.json"), "utf8")) as { + dependencies?: Record; + }; + const supportedPlatformPackages = [ + "claude-code-cli-acp-darwin-arm64", + "claude-code-cli-acp-darwin-x64", + "claude-code-cli-acp-linux-arm64", + "claude-code-cli-acp-linux-x64", + "claude-code-cli-acp-win32-arm64", + "claude-code-cli-acp-win32-x64", + ]; + + expect(existsSync(bridgePath)).toBe(true); + expect(existsSync(join(bridgeRoot, "node_modules", "claude-code-cli-acp", "bin", "claude-code-cli-acp.js"))).toBe(true); + expect(cliManifest.dependencies?.["claude-code-cli-acp"]).toBe("0.1.1"); + expect(Object.keys(launcherManifest.optionalDependencies ?? {}).sort()).toEqual(supportedPlatformPackages); + if (process.platform !== "win32") expect(statSync(bridgePath).mode & 0o111).not.toBe(0); + + // Native binaries intentionally are not staged from the build host. npm installs the matching + // optional package from this manifest on the operator's platform, including platforms unavailable to CI. + }, 35_000); + it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => { const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime"); const manifestPath = join(stagedRoot, "manifest.json"); diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts index 9e54364c1a..b6c0bfc6a8 100644 --- a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -4,6 +4,7 @@ export const RUNTIME_PLUGIN_IDS = [ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-grok-runtime", + "fusion-plugin-claude-runtime", // FNXC:OmpAcp 2026-07-11-23:35: Oh My Pi ACP runtime (omp acp) — staged like acp/droid for explicit runtime use. "fusion-plugin-omp-runtime", "fusion-plugin-droid-runtime", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 5af775883c..6681ded875 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "tsup"; import { spawn } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuildBuild } from "esbuild"; @@ -29,6 +30,7 @@ const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([ // FNXC:GrokAcp 2026-07-11-14:00: Grok ACP ships mcp-schema-server.cjs so // session/new can forward executable Fusion fn_* tools to grok agent stdio. "fusion-plugin-grok-runtime", + "fusion-plugin-claude-runtime", // FNXC:OmpAcp 2026-07-14-00:05: OMP ACP ships the same bridge asset for fn_* tools. "fusion-plugin-omp-runtime", ]); @@ -245,6 +247,42 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal cpSync(mcpServerAsset, join(destDir, "mcp-schema-server.cjs")); } + if (pluginId === "fusion-plugin-claude-runtime") { + /* + * FNXC:ClaudeAcpRuntime 2026-07-18-12:30: + * A published CLI npm package is portable, while ACP's native binary is + * platform-specific. Do not bake the build host's binary into the staged + * plugin: stage the identity-pinned JS launcher and declare it as a CLI + * dependency so npm installs exactly the matching optional native package + * on every operator platform. The launcher resolves that package by name + * through the installed CLI's ancestor node_modules. + */ + const bridgeRequire = createRequire(join(srcDir, "package.json")); + const launcherPackageJson = bridgeRequire.resolve("claude-code-cli-acp/package.json"); + const launcherSourceDir = dirname(launcherPackageJson); + const bridgeDest = join(destDir, "bridge"); + const launcherDestDir = join(bridgeDest, "node_modules", "claude-code-cli-acp"); + + mkdirSync(launcherDestDir, { recursive: true }); + cpSync(join(launcherSourceDir, "bin"), join(launcherDestDir, "bin"), { recursive: true }); + cpSync(launcherPackageJson, join(launcherDestDir, "package.json")); + + const bridgeWrapper = join(bridgeDest, `claude-code-cli-acp${process.platform === "win32" ? ".cmd" : ""}`); + if (process.platform === "win32") { + writeFileSync(bridgeWrapper, "@echo off\r\nnode \"%~dp0node_modules\\claude-code-cli-acp\\bin\\claude-code-cli-acp.js\" %*\r\n"); + } else { + writeFileSync( + bridgeWrapper, + "#!/usr/bin/env node\nimport \"./node_modules/claude-code-cli-acp/bin/claude-code-cli-acp.js\";\n", + ); + chmodSync(bridgeWrapper, 0o755); + } + + if (!existsSync(join(launcherDestDir, "bin", "claude-code-cli-acp.js"))) { + throw new Error(`[tsup] Missing required Claude ACP launcher after staging`); + } + } + const bundledOutput = join(destDir, "bundled.js"); if (!existsSync(bundledOutput)) { throw new Error(`[tsup] Missing bundled output for ${pluginId}: expected ${bundledOutput}`); diff --git a/packages/core/src/plugins/bundled-plugin-install.ts b/packages/core/src/plugins/bundled-plugin-install.ts index aa3b1f3c4d..1ee82ad715 100644 --- a/packages/core/src/plugins/bundled-plugin-install.ts +++ b/packages/core/src/plugins/bundled-plugin-install.ts @@ -24,6 +24,7 @@ import type { PluginStore } from "../plugin-store.js"; const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph"; const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime"; const GROK_RUNTIME_PLUGIN_ID = "fusion-plugin-grok-runtime"; +export const CLAUDE_RUNTIME_PLUGIN_ID = "fusion-plugin-claude-runtime"; export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-dependency-graph", @@ -35,6 +36,7 @@ export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-grok-runtime", + "fusion-plugin-claude-runtime", // FNXC:OmpAcp 2026-07-11-23:35: Oh My Pi ACP runtime available as a staged/bundled install target. "fusion-plugin-omp-runtime", "fusion-plugin-cli-printing-press", diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 3ae36ac133..0efac23ee0 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -151,7 +151,8 @@ "remark-gfm": "^4.0.1", "unified": "^11.0.5", "ws": "^8.18.0", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@fusion-plugin-examples/claude-runtime": "workspace:*" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/dashboard/src/claude-model-cache.ts b/packages/dashboard/src/claude-model-cache.ts new file mode 100644 index 0000000000..c3033b53c0 --- /dev/null +++ b/packages/dashboard/src/claude-model-cache.ts @@ -0,0 +1,185 @@ +/** + * Claude CLI discovery → model-picker mapping, behind a short-TTL, + * single-flight cache so `/api/models` never spawns the `claude` CLI per + * request. + * + * FNXC:ClaudeCli 2026-07-08-00:00: + * FN-7705: mirrors the landed Cursor picker cache (cursor-model-cache.ts, + * FN-7696) end to end. With the Claude Runtime plugin installed and the + * "Claude — via Claude CLI" provider toggle enabled (`useClaudeCli === true`), + * this module owns two contracts: + * 1. A deterministic discovery→model-id mapping (id = discovered id; name + * = label ?? id) so picker selections remain stable across requests. + * 2. A per-binaryPath TTL cache (default 60s) with single-flight + * de-duplication of concurrent in-flight fetches, so parallel + * `/api/models` requests spawn `claude` at most once per TTL window. + * A missing/failed/unavailable `claude` binary (ENOENT, non-zero exit, + * timeout, no API key configured) must degrade to an empty model list — + * never throw — so `/api/models` always returns HTTP 200 with existing rows + * intact. The empty result is cached briefly too, so a persistently- + * unavailable binary does not turn into a spawn-per-request storm. Claude has + * its own settings toggle (`useClaudeCli`); the toggle gate lives in the + * `/api/models` merge site (register-model-routes.ts), not in this module. + */ + +import { discoverClaudeCliModels } from "./runtime-provider-probes.js"; + +/** Stable model-picker row shape emitted for a Claude-discovered model. */ +export interface ClaudePickerModel { + provider: "claude-cli"; + id: string; + name: string; + reasoning: boolean; + contextWindow: number; +} + +/** The picker provider id used for all Claude-derived model rows. */ +export const CLAUDE_PICKER_PROVIDER_ID = "claude-cli" as const; + +/** Default cache TTL for Claude model discovery, in milliseconds. */ +const DEFAULT_TTL_MS = 60_000; + +/** + * FNXC:ModelCatalog 2026-07-08-00:00: + * FN-7710: mirrors the Cursor picker cache's negative-TTL hardening + * (cursor-model-cache.ts). A transient cold-start empty/unavailable discovery result was + * previously cached for the full `DEFAULT_TTL_MS` (60s), same as a real successful result — + * so a first-load empty right after the provider is toggled on could persist for a minute. + * Empty/unavailable results now use this much shorter negative TTL so a transient cold-start + * empty self-heals quickly, while a non-empty successful discovery keeps the normal 60s TTL. + * Single-flight and never-throw/never-spawn-per-request guarantees are unchanged — only how + * long an empty result is trusted. + */ +const EMPTY_RESULT_TTL_MS = 5_000; + +/** + * Map Claude CLI discovery output into the stable `/api/models` row shape. + * + * The discovered `id` is used as the stable model id. `name` falls back to + * `id` when no `label` is provided. `reasoning`/`contextWindow` default to + * `false`/`0` — the real `claude models` text output carries no such + * metadata today; this is pass-through only, never fabricated. + * + * Discovered entries that map to the same id are de-duplicated, keeping the + * first occurrence. + */ +export function claudeDiscoveryToModels( + models: ReadonlyArray<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>, +): ClaudePickerModel[] { + const seen = new Set(); + const result: ClaudePickerModel[] = []; + + for (const model of models) { + const id = model.id?.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + result.push({ + provider: CLAUDE_PICKER_PROVIDER_ID, + id, + name: model.label?.trim() || id, + reasoning: model.reasoning ?? false, + contextWindow: model.contextWindow ?? 0, + }); + } + + return result; +} + +interface CacheEntry { + /** Timestamp (ms) at which this entry was populated. */ + fetchedAt: number; + /** The resolved (possibly empty, on failure/unavailability) model list. */ + models: ClaudePickerModel[]; + /** The TTL that applies to this specific entry (short for empty results; see FN-7710). */ + ttlMs: number; +} + +/** Per-binaryPath cache of the most recently resolved Claude picker models. */ +const cache = new Map(); + +/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */ +const inFlight = new Map>(); + +/** + * Reset all cached/in-flight state. Test-only escape hatch — production code + * should never need this since entries expire naturally via TTL. + */ +export function __resetClaudePickerModelsCacheForTests(): void { + cache.clear(); + inFlight.clear(); +} + +export interface GetClaudePickerModelsOptions { + /** Override the Claude CLI binary path. Defaults to `"claude"`. */ + binaryPath?: string; + /** Cache TTL in milliseconds. Defaults to 60s. */ + ttlMs?: number; + /** Injectable clock (ms epoch) for deterministic tests. Defaults to `Date.now`. */ + now?: () => number; +} + +/** + * Resolve the Claude CLI binary path: explicit override, then the bare + * `"claude"` command (resolved via PATH by the CLI spawn layer). + */ +function resolveBinaryPath(explicit?: string): string { + return explicit ?? "claude"; +} + +/** + * Fetch Claude CLI-discovered models for the model picker, behind a + * short-TTL, single-flight cache keyed by binary path. + * + * Never throws: a `discoverClaudeCliModels` failure or an unavailable-binary + * result (empty models + `fallbackUsed: true`) resolves to `[]`, which is + * itself cached briefly (same TTL) so a persistently-unavailable binary does + * not spawn the CLI on every call. + */ +export async function getClaudePickerModels( + opts?: GetClaudePickerModelsOptions, +): Promise { + const binaryPath = resolveBinaryPath(opts?.binaryPath); + const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + const now = opts?.now ?? Date.now; + const nowMs = now(); + + const cached = cache.get(binaryPath); + if (cached && nowMs - cached.fetchedAt < cached.ttlMs) { + return cached.models; + } + + const existingInFlight = inFlight.get(binaryPath); + if (existingInFlight) { + return existingInFlight; + } + + const fetchPromise = (async (): Promise => { + try { + const result = await discoverClaudeCliModels({ binaryPath }); + if (!result || result.models.length === 0) { + return []; + } + return claudeDiscoveryToModels(result.models); + } catch { + // Degrade to zero Claude rows on any spawn/parse failure (ENOENT, + // non-zero exit, timeout, no API key configured) — never let a Claude + // error propagate into /api/models. See FNXC:ClaudeCli comment above. + return []; + } + })(); + + inFlight.set(binaryPath, fetchPromise); + + try { + const models = await fetchPromise; + // FN-7710: empty/unavailable results use a short negative TTL so a + // transient cold-start empty self-heals quickly instead of persisting + // for the full 60s TTL (see FNXC:ModelCatalog comment above). + const effectiveTtlMs = models.length === 0 ? EMPTY_RESULT_TTL_MS : ttlMs; + cache.set(binaryPath, { fetchedAt: now(), models, ttlMs: effectiveTtlMs }); + return models; + } finally { + inFlight.delete(binaryPath); + } +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 45b813dd73..3777f484da 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -104,6 +104,7 @@ const BUNDLED_PLUGIN_IDS = new Set([ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-grok-runtime", + "fusion-plugin-claude-runtime", "fusion-plugin-omp-runtime", "fusion-plugin-cli-printing-press", "fusion-plugin-compound-engineering", diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 06d47e16bb..f4c256dc07 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -6,6 +6,7 @@ import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js"; import { getGrokPickerModels, GROK_PICKER_PROVIDER_ID } from "../grok-model-cache.js"; +import { getClaudePickerModels, CLAUDE_PICKER_PROVIDER_ID } from "../claude-model-cache.js"; import { getOmpPickerModels, OMP_PICKER_PROVIDER_ID } from "../omp-model-cache.js"; import { getHermesPickerModels, HERMES_PICKER_PROVIDER_ID } from "../hermes-model-cache.js"; import type { AuthStorageLike } from "../routes.js"; @@ -394,6 +395,15 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { so an existing row always wins over a colliding Grok row — purely additive, must never displace, overwrite, or filter out an existing row. */ + if (useClaudeCli) { + try { + for (const model of await getClaudePickerModels()) { + const key = `${model.provider}/${model.id}`; + if (!seenModelKeys.has(key)) { seenModelKeys.add(key); models.push(model); } + } + } catch (error: unknown) { runtimeLogger.child("models").warn(`Failed to load claude-cli models: ${error instanceof Error ? error.message : String(error)}`); } + } + if (useGrokCli) { // getGrokPickerModels never throws by contract (see // grok-model-cache.ts), but this try/catch is a defensive belt so a @@ -452,6 +462,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { */ const configuredProviders = await getConfiguredProviderNames(options?.authStorage); if (useClaudeCli) configuredProviders.add("pi-claude-cli"); + if (useClaudeCli) configuredProviders.add(CLAUDE_PICKER_PROVIDER_ID); if (useDroidCli) configuredProviders.add("droid-cli"); if (useLlamaCpp) configuredProviders.add("llama-server"); // FNXC:ModelCatalog 2026-07-08-00:05 (FN-7696): allow-list "cursor-cli" diff --git a/packages/dashboard/src/runtime-provider-probes.ts b/packages/dashboard/src/runtime-provider-probes.ts index 45a37deb62..1b8245ea1c 100644 --- a/packages/dashboard/src/runtime-provider-probes.ts +++ b/packages/dashboard/src/runtime-provider-probes.ts @@ -36,6 +36,12 @@ import { type GrokBinaryStatus, } from "@fusion-plugin-examples/grok-runtime"; +import { + discoverClaudeProviderModels, + probeClaudeBinary, + type ClaudeBinaryStatus, +} from "@fusion-plugin-examples/claude-runtime"; + /* FNXC:OmpAcp 2026-07-13-22:50: Oh My Pi (omp) ACP runtime probe façade — same boundary as Grok/Cursor so route handlers and tests mock here without importing the plugin package directly. @@ -72,6 +78,7 @@ export type { OpenClawBinaryStatus, CursorBinaryStatus, GrokBinaryStatus, + ClaudeBinaryStatus, OmpBinaryStatus, PaperclipAgentSummary, PaperclipCliDiscoveryResult, @@ -88,6 +95,14 @@ export async function probeGrokCliProvider(opts?: { binaryPath?: string }): Prom return probeGrokBinary(opts); } +export async function probeClaudeCliProvider(opts?: { binaryPath?: string }): Promise { + return probeClaudeBinary(opts); +} + +export async function discoverClaudeCliModels(opts?: { binaryPath?: string; timeoutMs?: number }) { + return discoverClaudeProviderModels(opts); +} + /* FNXC:OmpAcp 2026-07-11-23:35: Oh My Pi (omp) ACP runtime probe façade — same boundary pattern as Grok/Cursor so diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index ac45950334..36e6e13925 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -596,6 +596,10 @@ export default defineConfig({ __dirname, "../../plugins/fusion-plugin-grok-runtime/src/index.ts", ), + "@fusion-plugin-examples/claude-runtime": resolve( + __dirname, + "../../plugins/fusion-plugin-claude-runtime/src/index.ts", + ), /* FNXC:OmpAcp 2026-07-11-23:35: runtime-provider-probes.ts imports probeOmpBinary from @fusion-plugin-examples/omp-runtime. diff --git a/packages/desktop/scripts/workspace-tools.ts b/packages/desktop/scripts/workspace-tools.ts index bc8cd34176..4c1d6e5d4d 100644 --- a/packages/desktop/scripts/workspace-tools.ts +++ b/packages/desktop/scripts/workspace-tools.ts @@ -85,6 +85,7 @@ export const DASHBOARD_RUNTIME_PLUGIN_PACKAGES = [ "plugins/fusion-plugin-paperclip-runtime", "plugins/fusion-plugin-cursor-runtime", "plugins/fusion-plugin-grok-runtime", + "plugins/fusion-plugin-claude-runtime", "plugins/fusion-plugin-omp-runtime", "plugins/fusion-plugin-droid-runtime", "plugins/fusion-plugin-roadmap", diff --git a/plugins/fusion-plugin-claude-runtime/README.md b/plugins/fusion-plugin-claude-runtime/README.md new file mode 100644 index 0000000000..34fd98e788 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/README.md @@ -0,0 +1,7 @@ +# Claude Runtime Plugin + +`fusion-plugin-claude-runtime` exposes Claude Code as Fusion runtime `claude` and CLI provider `claude-cli`. It communicates through Agent Client Protocol (ACP), preserving streaming updates, tool calls, and multi-turn sessions. + +The plugin uses the pinned `claude-code-cli-acp` bridge (`0.1.1`). CLI packaging stages its reviewed launcher beside the bundled plugin, while the published `@runfusion/fusion` dependency installs the matching optional native bridge for the operator's OS and CPU. The runtime never falls back to a same-named executable on `PATH`. + +This is additive to Fusion's experimental `pi-claude-cli` Route A. Route A remains available; selecting the `claude` runtime explicitly selects this first-class ACP transport. diff --git a/plugins/fusion-plugin-claude-runtime/manifest.json b/plugins/fusion-plugin-claude-runtime/manifest.json new file mode 100644 index 0000000000..cadd80d2c3 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/manifest.json @@ -0,0 +1 @@ +{"id":"fusion-plugin-claude-runtime","name":"Claude Runtime Plugin","version":"0.1.0","description":"Provides Claude Code model provider and runtime integration over ACP"} diff --git a/plugins/fusion-plugin-claude-runtime/package.json b/plugins/fusion-plugin-claude-runtime/package.json new file mode 100644 index 0000000000..d6cabf46ae --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/package.json @@ -0,0 +1 @@ +{"name":"@fusion-plugin-examples/claude-runtime","version":"0.1.0","type":"module","description":"Claude Code runtime plugin for Fusion (ACP bridge)","keywords":["fusion-plugin","claude","acp","agent-client-protocol","runtime"],"exports":{".":{"types":"./src/index.ts","source":"./src/index.ts","import":"./dist/index.js"},"./probe":{"types":"./src/probe.ts","source":"./src/probe.ts","import":"./dist/probe.js"}},"private":true,"scripts":{"build":"tsc","test":"vitest run --silent=passed-only --reporter=dot"},"dependencies":{"@agentclientprotocol/sdk":"0.24.0","@fusion/core":"workspace:*","@fusion/plugin-sdk":"workspace:*","claude-code-cli-acp":"0.1.1"},"peerDependencies":{"@earendil-works/pi-ai":"*","@earendil-works/pi-coding-agent":"*"},"devDependencies":{"@types/node":"^25.5.2","typescript":"^5.7.0","vitest":"^4.1.0"}} diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/cli-spawn.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/cli-spawn.test.ts new file mode 100644 index 0000000000..df83a1e173 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/cli-spawn.test.ts @@ -0,0 +1,3 @@ +import { describe, expect, it } from "vitest"; +import { bundledClaudeBridgeBinPath, resolveBundledClaudeBridgeBinary } from "../cli-spawn.js"; +describe("staged Claude bridge resolver", () => { it("only accepts an identity-pinned bridge beneath its plugin root", () => { const root="/tmp/plugin"; const path=bundledClaudeBridgeBinPath(root); expect(resolveBundledClaudeBridgeBinary({pluginRoot:root,exists:(candidate)=>candidate===path})).toMatchObject({kind:"resolved",path}); }); it("does not fall back to PATH", () => expect(resolveBundledClaudeBridgeBinary({pluginRoot:"/tmp/plugin",exists:()=>false}).kind).toBe("not_resolved")); }); diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/index.test.ts new file mode 100644 index 0000000000..5422401968 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/index.test.ts @@ -0,0 +1,3 @@ +import { describe, expect, it } from "vitest"; +import plugin from "../index.js"; +describe("Claude runtime plugin", () => { it("registers Claude runtime and provider ids", () => { expect(plugin.manifest.id).toBe("fusion-plugin-claude-runtime"); expect(plugin.runtime?.metadata.runtimeId).toBe("claude"); expect(plugin.cliProviders?.[0]?.providerId).toBe("claude-cli"); }); }); diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/provider.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/provider.test.ts new file mode 100644 index 0000000000..10d95423df --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/provider.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it, vi } from "vitest"; +vi.mock("../probe.js", () => ({ probeClaudeBinary: vi.fn() })); +import { probeClaudeBinary } from "../probe.js"; +import { discoverClaudeProviderModels } from "../provider.js"; +describe("discoverClaudeProviderModels", () => { + it("returns qualified provider-safe Claude ids when bridge is available", async () => { vi.mocked(probeClaudeBinary).mockResolvedValue({ available:true, probeDurationMs:1 }); const result=await discoverClaudeProviderModels(); expect(result.models.map((m)=>m.id)).toContain("claude-sonnet-4-20250514"); expect(result.fallbackUsed).toBe(false); }); + it("degrades to empty fallback when the bridge is unavailable", async () => { vi.mocked(probeClaudeBinary).mockResolvedValue({ available:false, reason:"missing", probeDurationMs:1 }); await expect(discoverClaudeProviderModels()).resolves.toMatchObject({models:[],fallbackUsed:true,reason:"missing"}); }); +}); diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts new file mode 100644 index 0000000000..8a84659e2b --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it, vi } from "vitest"; +import { ClaudeRuntimeAdapter } from "../runtime-adapter.js"; +const options={cwd:"/tmp",systemPrompt:"",onText:vi.fn()}; +describe("ClaudeRuntimeAdapter", () => { + it("returns a visible diagnostic instead of rejecting on ACP create failure", async () => { const adapter=new ClaudeRuntimeAdapter({createAcpAdapter:()=>({createSession:async()=>{throw new Error("bridge unavailable")},promptWithFallback:async()=>undefined,describeModel:()=>"claude/default"})}); const result=await adapter.createSession(options); expect(result.session.state.errorMessage).toContain("Claude ACP failed"); expect(options.onText).toHaveBeenCalled(); }); + it("returns a visible diagnostic for follow-up prompts without a live connection", async () => { const adapter=new ClaudeRuntimeAdapter({createAcpAdapter:()=>({createSession:async()=>{throw new Error("bridge unavailable")},promptWithFallback:async()=>undefined,describeModel:()=>"claude/default"})}); const {session}=await adapter.createSession(options); await adapter.promptWithFallback(session,"again"); expect(options.onText).toHaveBeenLastCalledWith(expect.stringContaining("no live connection")); }); +}); diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts new file mode 100644 index 0000000000..7f7771cd4d --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts @@ -0,0 +1,79 @@ +import { request } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { startFusionToolBridge } from "../tool-bridge.js"; + +function bridgeEnv(bridge: NonNullable>>, name: string): string { + if (!("env" in bridge.mcpServer)) throw new Error("custom tool bridge must use stdio MCP"); + const value = bridge.mcpServer.env.find((entry) => entry.name === name)?.value; + if (!value) throw new Error(`missing ${name}`); + return value; +} + +async function post(url: string, body: string, token?: string): Promise<{ status: number; body: string }> { + const target = new URL("/tool-call", url); + return await new Promise((resolve, reject) => { + const req = request( + { + hostname: target.hostname, + port: target.port, + path: target.pathname, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }, + (res) => { + let response = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (response += chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body: response })); + }, + ); + req.once("error", reject); + req.end(body); + }); +} + +describe("startFusionToolBridge", () => { + it("requires a session capability and action-gate authorization before executing a custom tool", async () => { + const execute = vi.fn().mockResolvedValue({ text: "done" }); + const bridge = await startFusionToolBridge( + [{ name: "fn_task_update", execute }], + { actionGateContext: { permissionPolicy: { rules: { task_agent_mutation: "allow" } } }, allowUnrestricted: true }, + ); + expect(bridge).not.toBeNull(); + if (!bridge) return; + + const url = bridgeEnv(bridge, "FUSION_GROK_TOOL_BRIDGE_URL"); + const token = bridgeEnv(bridge, "FUSION_TOOL_BRIDGE_CAPABILITY"); + try { + expect((await post(url, JSON.stringify({ name: "fn_task_update" }))).status).toBe(401); + expect(execute).not.toHaveBeenCalled(); + + const response = await post(url, JSON.stringify({ name: "fn_task_update", arguments: { step: 1 } }), token); + expect(response.status).toBe(200); + expect(execute).toHaveBeenCalledOnce(); + } finally { + await bridge.dispose(); + } + }); + + it("default-denies when no action policy is available and bounds request bodies", async () => { + const execute = vi.fn(); + const bridge = await startFusionToolBridge([{ name: "fn_task_update", execute }]); + expect(bridge).not.toBeNull(); + if (!bridge) return; + + const url = bridgeEnv(bridge, "FUSION_GROK_TOOL_BRIDGE_URL"); + const token = bridgeEnv(bridge, "FUSION_TOOL_BRIDGE_CAPABILITY"); + try { + expect((await post(url, JSON.stringify({ name: "fn_task_update" }), token)).status).toBe(403); + expect(execute).not.toHaveBeenCalled(); + expect((await post(url, "x".repeat(1_048_577), token)).status).toBe(413); + } finally { + await bridge.dispose(); + } + }); +}); diff --git a/plugins/fusion-plugin-claude-runtime/src/acp-settings.ts b/plugins/fusion-plugin-claude-runtime/src/acp-settings.ts new file mode 100644 index 0000000000..c2c48dc3e7 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp-settings.ts @@ -0,0 +1,21 @@ +import { resolveBundledClaudeBridgeBinary } from "./cli-spawn.js"; + +/** Only minimal login/path context crosses the untrusted ACP subprocess boundary. */ +export const CLAUDE_ACP_ENV_ALLOWLIST = ["HOME", "PATH", "USER", "SHELL", "LANG", "LC_ALL", "TERM", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"] as const; +export function normalizeClaudeCliModel(model: string | undefined): string | undefined { + const value=model?.trim(); if (!value) return undefined; + for (const prefix of ["claude-cli/", "claude/"]) if (value.startsWith(prefix)) return value.slice(prefix.length).trim() || undefined; + return value; +} +export function modelForCli(model: string | undefined): string | undefined { const value=normalizeClaudeCliModel(model); return value === "default" ? undefined : value; } +/** Builds settings for the pinned bridge, never a same-named PATH executable. */ +export function buildClaudeAcpRuntimeSettings(options: { model?: string; pluginDirs?: string[]; binary?: string } = {}): Record { + const bridge=options.binary ? {kind:"resolved" as const,path:options.binary} : resolveBundledClaudeBridgeBinary(); + /* + FNXC:ClaudeAcp 2026-07-18-11:45: + Claude is an untrusted ACP subprocess. Do not silently acknowledge unrestricted + sensitive operations: the action gate must require approval unless an operator + explicitly opts in through a supported setting. + */ + return {acpBinaryPath: bridge.path ?? "", acpArgs: [], acpModel: options.model ?? "claude/default", acpEnvAllowList:[...CLAUDE_ACP_ENV_ALLOWLIST], acpFsRead:false, acpFsWrite:false, acpAllowUnrestricted:false, pluginDirs:options.pluginDirs ?? [], binaryResolution:bridge}; +} diff --git a/plugins/fusion-plugin-claude-runtime/src/acp/VENDORED.md b/plugins/fusion-plugin-claude-runtime/src/acp/VENDORED.md new file mode 100644 index 0000000000..509f7f37c5 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/VENDORED.md @@ -0,0 +1,30 @@ +# Vendored ACP client + +**Source:** `plugins/fusion-plugin-acp-runtime/src/` (Fusion ACP runtime plugin) +**Vendored:** 2026-07-11 for Grok ACP self-containment + +## Why + +`fusion-plugin-grok-runtime` is a **bundled, auto-installed** runtime. +`fusion-plugin-acp-runtime` is **experimental / on-demand**. Importing the latter at runtime would couple Grok availability to the ACP plugin install path and drag Claude-bridge packaging into Grok. + +## What is copied + +Client-side ACP only: + +| Module | Role | +| --- | --- | +| `runtime-adapter.ts` | `AgentRuntime` lifecycle | +| `provider.ts` | connect / session / authenticate / prompt | +| `process-manager.ts` | spawn env allow-list + SIGKILL registry | +| `event-bridge.ts` | `session/update` → Fusion callbacks | +| `control-handler.ts` | permission floor | +| `fs-capabilities.ts` / `path-jail.ts` | optional client fs | +| `cli-spawn.ts` | settings resolution | +| `prompt-builder.ts`, `sanitize.ts`, `tool-mapping.ts`, `types.ts` | support | + +**Not** copied: plugin `index.ts`, Claude bridge setup, generic ACP probe/setup UI. + +## Syncing + +When fixing ACP client bugs in `fusion-plugin-acp-runtime`, re-copy the modules above into this directory (or cherry-pick the same change) and note the date in FNXC comments. diff --git a/plugins/fusion-plugin-claude-runtime/src/acp/cli-spawn.ts b/plugins/fusion-plugin-claude-runtime/src/acp/cli-spawn.ts new file mode 100644 index 0000000000..f8952a9029 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/cli-spawn.ts @@ -0,0 +1,188 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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). + +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CLAUDE_CODE_CLI_ACP_BINARY = "claude-code-cli-acp"; + +export interface AcpBinaryResolution { + kind: "resolved" | "not_resolved"; + requested: string; + path?: string; + reason?: string; +} + +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[]; + /** Env allow-list entries that must be present before spawning this profile. */ + requiredEnv: 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; + /** Bundled bridge resolution status when `acpBinaryPath` asks for it. */ + binaryResolution?: AcpBinaryResolution; + /** + * FNXC:ClaudeAcp 2026-07-11-15:00: + * When set, call ACP authenticate after initialize (Claude headless scripting + * contract). preferMethods are tried in order against advertised authMethods. + */ + authenticate?: { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: 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; +} + +function pluginRootDir(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export interface ResolveBundledClaudeBridgeOptions { + pluginRoot?: string; + exists?: (path: string) => boolean; +} + +export function bundledClaudeBridgeBinPath(pluginRoot = pluginRootDir()): string { + const extension = process.platform === "win32" ? ".cmd" : ""; + return join(pluginRoot, "node_modules", ".bin", `${CLAUDE_CODE_CLI_ACP_BINARY}${extension}`); +} + +export function resolveBundledClaudeBridgeBinary( + options: ResolveBundledClaudeBridgeOptions = {}, +): AcpBinaryResolution { + const root = options.pluginRoot ?? pluginRootDir(); + const exists = options.exists ?? existsSync; + const candidate = bundledClaudeBridgeBinPath(root); + /* + FNXC:ACP-RouteB 2026-06-14-19:47: + The Claude ACP bridge is a pinned plugin dependency, not a PATH-selected executable. Resolve the sentinel to the plugin-owned node_modules/.bin shim so a same-named global binary cannot replace the reviewed bridge. + */ + if (!exists(candidate)) { + return { + kind: "not_resolved", + requested: CLAUDE_CODE_CLI_ACP_BINARY, + path: candidate, + reason: `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} binary was not found at ${candidate}`, + }; + } + if (!isAbsolute(candidate)) { + return { + kind: "not_resolved", + requested: CLAUDE_CODE_CLI_ACP_BINARY, + path: candidate, + reason: `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} path is not absolute`, + }; + } + return { kind: "resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY, path: candidate }; +} + +export function resolveCliSettings(settings?: Record): AcpCliSettings { + const requestedBinaryPath = asTrimmedString(settings?.acpBinaryPath); + let binaryPath = requestedBinaryPath ?? "acp-agent"; + let binaryResolution: AcpBinaryResolution | undefined; + if (requestedBinaryPath === CLAUDE_CODE_CLI_ACP_BINARY) { + binaryResolution = resolveBundledClaudeBridgeBinary(); + if (binaryResolution.kind === "resolved" && binaryResolution.path) { + binaryPath = binaryResolution.path; + } + } + 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); + const authenticate = asAuthenticateSettings(settings?.acpAuthenticate); + return { + binaryPath, + args, + model, + fsRead, + fsWrite, + envAllowList, + requiredEnv: [], + allowUnrestricted, + binaryResolution, + authenticate, + }; +} + +function asAuthenticateSettings(value: unknown): AcpCliSettings["authenticate"] { + if (value === true) { + return { preferMethods: ["xai.api_key", "cached_token"], meta: { headless: true }, require: true }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const obj = value as Record; + const preferMethods = asStringArray(obj.preferMethods); + const methodId = asTrimmedString(obj.methodId); + const meta = + obj.meta && typeof obj.meta === "object" && !Array.isArray(obj.meta) + ? (obj.meta as Record) + : { headless: true }; + const require = obj.require === true; + if (!preferMethods && !methodId && !require) return undefined; + return { + ...(preferMethods ? { preferMethods } : {}), + ...(methodId ? { methodId } : {}), + meta, + require, + }; +} + +export function resolveClaudeBridgeAskSettings(settings?: Record): AcpCliSettings { + const resolved = resolveCliSettings({ + ...settings, + acpBinaryPath: CLAUDE_CODE_CLI_ACP_BINARY, + acpArgs: [], + acpFsRead: false, + acpFsWrite: false, + acpEnvAllowList: ["HOME", "PATH"], + acpAllowUnrestricted: false, + }); + return { ...resolved, requiredEnv: ["HOME"] }; +} diff --git a/plugins/fusion-plugin-claude-runtime/src/acp/control-handler.ts b/plugins/fusion-plugin-claude-runtime/src/acp/control-handler.ts new file mode 100644 index 0000000000..80dc481c6e --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/control-handler.ts @@ -0,0 +1,302 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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-claude-runtime/src/acp/event-bridge.ts b/plugins/fusion-plugin-claude-runtime/src/acp/event-bridge.ts new file mode 100644 index 0000000000..e68bdded42 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/event-bridge.ts @@ -0,0 +1,308 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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-claude-runtime/src/acp/fs-capabilities.ts b/plugins/fusion-plugin-claude-runtime/src/acp/fs-capabilities.ts new file mode 100644 index 0000000000..36c7fea48c --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/fs-capabilities.ts @@ -0,0 +1,263 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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-claude-runtime/src/acp/index.ts b/plugins/fusion-plugin-claude-runtime/src/acp/index.ts new file mode 100644 index 0000000000..798e0df1c0 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/index.ts @@ -0,0 +1,16 @@ +/* +FNXC:ClaudeAcp 2026-07-11-16:00: +Vendored ACP client implementation for the Claude runtime. Copied from +plugins/fusion-plugin-acp-runtime/src (not imported) so the bundled Claude plugin +is self-contained and does not depend on the experimental/on-demand +fusion-plugin-acp-runtime package at runtime. Keep this tree focused on the +JSON-RPC/stdio client (connect, session, event bridge, permission floor, +process registry). Claude-specific spawn/auth/skills/MCP live outside this folder. +*/ + +export { AcpRuntimeAdapter } from "./runtime-adapter.js"; +export { killAllProcesses } from "./process-manager.js"; +export { authenticateAcpConnection, AcpAuthRequiredError, connect } from "./provider.js"; +export { resolveCliSettings } from "./cli-spawn.js"; +export type { AcpCliSettings } from "./cli-spawn.js"; +export type { AcpMcpServer, AgentRuntimeOptions as AcpAgentRuntimeOptions } from "./types.js"; diff --git a/plugins/fusion-plugin-claude-runtime/src/acp/path-jail.ts b/plugins/fusion-plugin-claude-runtime/src/acp/path-jail.ts new file mode 100644 index 0000000000..1b6124fc56 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/path-jail.ts @@ -0,0 +1,229 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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-claude-runtime/src/acp/process-manager.ts b/plugins/fusion-plugin-claude-runtime/src/acp/process-manager.ts new file mode 100644 index 0000000000..5b7e615984 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/process-manager.ts @@ -0,0 +1,177 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// port-4040-allowlist: this file documents the reserved dashboard port in kill-guard comments only; no kill targets it. +// 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"; +import { redactSecrets } from "@fusion/core"; + +function debugLog(message: string): void { + if (process.env.PI_ACP_DEBUG !== "1" && process.env.FUSION_GROK_ACP_DEBUG !== "1") return; + console.error(`[claude-acp] ${message}`); +} + +/* +FNXC:ProcessLifecycle 2026-07-16-07:00: +Vitest resets the Claude plugin module graph while retaining the worker's `process`. +Keep the ACP child registry on `process` so the one guarded exit listener also +reaps children registered by later module evaluations; adding one listener per +evaluation causes MaxListenersExceededWarning in the dashboard backfill lane. +*/ +const ACTIVE_PROCESSES_KEY = Symbol.for("fusion.plugin.claude-runtime.activeProcesses"); +const processWithActiveProcesses = process as typeof process & { + [key: symbol]: Set | undefined; +}; + +/** Registry of active agent subprocesses for teardown. Self-cleans on exit. */ +const activeProcesses = + processWithActiveProcesses[ACTIVE_PROCESSES_KEY] ?? + (processWithActiveProcesses[ACTIVE_PROCESSES_KEY] = 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(); +} + +export class MissingAcpEnvError extends Error { + readonly code = "ACP_MISSING_ENV"; + constructor(readonly missingKeys: string[]) { + super(`Missing required ACP environment variable(s): ${missingKeys.join(", ")}`); + this.name = "MissingAcpEnvError"; + } +} + +export interface BuildSpawnEnvOptions { + required?: string[]; + sourceEnv?: NodeJS.ProcessEnv; +} + +/** + * 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[], options: BuildSpawnEnvOptions = {}): NodeJS.ProcessEnv { + /* + FNXC:ACP-RouteB 2026-06-14-19:52: + Claude bridge subprocesses may receive HOME so the real `claude` can read ~/.claude auth and PATH so the bridge can locate sub-executables. Do not forward ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or inherited process.env because the bridge is an untrusted external process. + */ + const sourceEnv = options.sourceEnv ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const key of allowList) { + const value = sourceEnv[key]; + if (typeof value === "string") env[key] = value; + } + const missing = (options.required ?? []).filter((key) => typeof env[key] !== "string"); + if (missing.length > 0) { + throw new MissingAcpEnvError(missing); + } + 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; + +// Secret redaction (Risk S8) lives in @fusion/core so PTY/process owners share +// one implementation; re-exported here to preserve this module's public surface. +export { redactSecrets }; + +/** + * 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-claude-runtime/src/acp/prompt-builder.ts b/plugins/fusion-plugin-claude-runtime/src/acp/prompt-builder.ts new file mode 100644 index 0000000000..a78822be63 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/prompt-builder.ts @@ -0,0 +1,87 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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[]; +} + +/* +FNXC:ClaudeAcp 2026-07-12-07:15: +Dashboard chat forwards attachments as promptWithFallback options +`{ images: ChatImageContent[] }` where each item is +`{ type: "image", data: base64, mimeType }`. ACP session/prompt needs +ContentBlock image variants. Extract defensively so pi-style ImageContent +and PromptImage shapes both work; ignore malformed entries. +*/ +/** + * Pull image attachments from Fusion `promptWithFallback` options. + * Accepts `{ images: Array<{ data, mimeType, uri? }> }` (chat / pi ImageContent). + */ +export function extractPromptImagesFromOptions(options: unknown): PromptImage[] | undefined { + if (!options || typeof options !== "object" || Array.isArray(options)) { + return undefined; + } + const raw = (options as { images?: unknown }).images; + if (!Array.isArray(raw) || raw.length === 0) { + return undefined; + } + const images: PromptImage[] = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const rec = item as Record; + const data = typeof rec.data === "string" ? rec.data : undefined; + const mimeType = typeof rec.mimeType === "string" ? rec.mimeType : undefined; + if (!data || !mimeType || data.length === 0 || mimeType.length === 0) continue; + // Prefer explicit uri; else map absolute filesystem `path` to file:// for agents. + let uri = typeof rec.uri === "string" && rec.uri.length > 0 ? rec.uri : undefined; + if (!uri && typeof rec.path === "string" && rec.path.length > 0) { + uri = rec.path.startsWith("file:") ? rec.path : `file://${rec.path}`; + } + images.push({ data, mimeType, ...(uri ? { uri } : {}) }); + } + return images.length > 0 ? images : undefined; +} + +/** + * 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-claude-runtime/src/acp/provider.ts b/plugins/fusion-plugin-claude-runtime/src/acp/provider.ts new file mode 100644 index 0000000000..ee30e13dba --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/provider.ts @@ -0,0 +1,542 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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, AcpMcpServer, 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), + ); + }); + }, + // FNXC:ClaudeAcp 2026-07-12-07:00: swallow `_x.ai/*` extension notifications + // (hook_execution, session admin, …) so ACP SDK does not log -32601. + }; + + // 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; + /** + * FNXC:ClaudeAcp 2026-07-11-15:00: + * Optional post-initialize authenticate (xAI Claude docs: initialize → authenticate + * → session/new). Prefer methods listed in preferMethods that the agent + * advertised; when require is true, missing auth fails closed. + * See https://docs.x.ai/build/cli/headless-scripting#acp + */ + authenticate?: { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: boolean; + }; +} + +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 })) + : []; + + /* + FNXC:ClaudeAcp 2026-07-11-15:00: + Official Claude ACP scripting requires authenticate after initialize (method + xai.api_key when XAI_API_KEY is set, else cached_token) with + `_meta: { headless: true }` before session/new. Generic ACP agents that + advertise no preferred methods skip this step. + */ + if (opts.authenticate) { + try { + await authenticateAcpConnection( + { conn, authMethods }, + opts.authenticate, + ); + } catch (err) { + dispose(); + throw err; + } + } + + return { + conn, + child, + agentCapabilities: initResult.agentCapabilities, + authMethods, + stderr, + dispose, + }; +} + +export class AcpAuthRequiredError extends Error { + readonly code = "acp_auth_required" as const; + constructor(readonly availableMethodIds: string[]) { + super( + availableMethodIds.length > 0 + ? `ACP agent requires authentication but no preferred method matched (available: ${availableMethodIds.join(", ")})` + : "ACP agent requires authentication but advertised no auth methods", + ); + this.name = "AcpAuthRequiredError"; + } +} + +/** + * Call ACP `authenticate` with the first preferred method the agent advertised. + * No-ops when neither methodId nor a preferred method is available and require + * is false. + */ +export async function authenticateAcpConnection( + connection: Pick, + opts: { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: boolean; + }, +): Promise<{ methodId: string } | undefined> { + const available = connection.authMethods.map((m) => m.id); + const availableSet = new Set(available); + let methodId = opts.methodId?.trim(); + if (methodId && !availableSet.has(methodId)) { + methodId = undefined; + } + if (!methodId) { + for (const candidate of opts.preferMethods ?? []) { + if (availableSet.has(candidate)) { + methodId = candidate; + break; + } + } + } + if (!methodId) { + if (opts.require) { + throw new AcpAuthRequiredError(available); + } + return undefined; + } + await connection.conn.authenticate({ + methodId, + _meta: opts.meta ?? { headless: true }, + }); + return { methodId }; +} + +// --- 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`. Forwards `opts.mcpServers` (U10 — + * Route A): when present and non-empty, the agent can call those Fusion tools and + * each call still routes through the U5 permission floor. Defaults to `[]` so + * Route B read-only ask turns keep their no-tools posture. + */ +export async function newAcpSession( + connection: AcpConnection, + opts: { + cwd: string; + mcpServers?: AcpMcpServer[]; + /** + * FNXC:ClaudeAcp 2026-07-11-14:00: + * Optional ACP `_meta` bag for agent-specific session setup (Claude uses + * `pluginDirs`, `rules`, `systemPromptOverride`). Opaque to the generic + * ACP client — agents interpret their own keys. + */ + meta?: Record; + }, +): Promise { + const res = await connection.conn.newSession({ + cwd: opts.cwd, + mcpServers: (opts.mcpServers ?? []) as never, + ...(opts.meta && Object.keys(opts.meta).length > 0 ? { _meta: opts.meta } : {}), + }); + // `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-claude-runtime/src/acp/runtime-adapter.ts b/plugins/fusion-plugin-claude-runtime/src/acp/runtime-adapter.ts new file mode 100644 index 0000000000..5262761b7e --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/runtime-adapter.ts @@ -0,0 +1,190 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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, extractPromptImagesFromOptions } 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). + // Optional authenticate (Claude headless ACP: initialize → authenticate → session/new). + const connection = await connect({ + binaryPath: this.settings.binaryPath, + args: this.settings.args, + cwd: options.cwd, + env: buildSpawnEnv(this.settings.envAllowList, { required: this.settings.requiredEnv }), + advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite }, + clientHandler, + ...(this.settings.authenticate ? { authenticate: this.settings.authenticate } : {}), + }); + + // Open the ACP session over the task worktree. Forward MCP servers when the + // caller supplied them (U10 — Route A); absent/empty keeps the Route B + // read-only ask posture. Tool calls still route through the U5 permission floor. + // + // FNXC:ClaudeAcp 2026-07-11-14:00: + // Callers (Claude runtime) may also pass `_meta` (pluginDirs / rules / + // systemPromptOverride) via options.sessionMeta so agent-specific skill and + // prompt setup rides on session/new without a second protocol hop. + let sessionId: string; + try { + const sessionMeta = + options && typeof options === "object" && "sessionMeta" in options + ? (options as { sessionMeta?: Record }).sessionMeta + : undefined; + const opened = await newAcpSession(connection, { + cwd: options.cwd, + mcpServers: options.mcpServers, + meta: sessionMeta, + }); + 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<{ stopReason?: string }> { + 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?.(); + /* + FNXC:ClaudeAcp 2026-07-12-07:15: + Chat/triage pass `{ images: [{ type:"image", data, mimeType }] }` through + promptWithFallback. Previously options were ignored (`_options`) so Claude ACP + and generic ACP sessions never received image ContentBlocks on session/prompt. + */ + const images = extractPromptImagesFromOptions(options); + const blocks = buildPromptBlocks(prompt, images ? { images } : undefined); + // 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. + /* + FNXC:ACP-RouteB 2026-06-14-20:09: + Route-B validation must distinguish clean end_turn answers from truncated or cancelled turns. Surface ACP stopReason to the engine runner instead of discarding it so callers can reject syntactically complete JSON recovered from incomplete output. + */ + const stopReason = await promptAcpSession(acp.connection, acp.sessionId, blocks); + return { stopReason }; + } + + 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-claude-runtime/src/acp/sanitize.ts b/plugins/fusion-plugin-claude-runtime/src/acp/sanitize.ts new file mode 100644 index 0000000000..3631d869c5 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/sanitize.ts @@ -0,0 +1,81 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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-claude-runtime/src/acp/tool-mapping.ts b/plugins/fusion-plugin-claude-runtime/src/acp/tool-mapping.ts new file mode 100644 index 0000000000..6a21d32f41 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/tool-mapping.ts @@ -0,0 +1,47 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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-claude-runtime/src/acp/types.ts b/plugins/fusion-plugin-claude-runtime/src/acp/types.ts new file mode 100644 index 0000000000..14449c3092 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/acp/types.ts @@ -0,0 +1,189 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:ClaudeAcp 2026-07-11-16:00). */ +// 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; +} + +/** + * MCP servers forwarded to the agent on `session/new` (U10 — Route A). + * `env` / `headers` are explicit name/value pairs; inherited `process.env` is + * NEVER forwarded to the untrusted agent. + * + * FNXC:ClaudeAcp 2026-07-11-14:00: + * Widen beyond stdio so Claude ACP can receive Fusion operator MCP servers over + * http/sse (Claude advertises mcpCapabilities.http/sse) as well as the classic + * stdio custom-tools bridge used by Route A. + */ +export interface AcpMcpServerStdio { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + +export interface AcpMcpServerHttp { + type: "http"; + name: string; + url: string; + headers: { name: string; value: string }[]; +} + +export interface AcpMcpServerSse { + type: "sse"; + name: string; + url: string; + headers: { name: string; value: string }[]; +} + +export type AcpMcpServer = AcpMcpServerStdio | AcpMcpServerHttp | AcpMcpServerSse; + +/** 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; + /** + * MCP servers to forward on `session/new` (U10 — Route A). When present and + * non-empty, the agent can call these tools (each call still routes through the + * U5 permission floor). Absent/empty preserves Route B's read-only ask posture. + */ + mcpServers?: AcpMcpServer[]; + /** + * FNXC:ClaudeAcp 2026-07-11-14:00: + * Opaque ACP `session/new._meta` for agent-specific setup (Claude pluginDirs / + * rules / systemPromptOverride). Ignored by agents that do not read `_meta`. + */ + sessionMeta?: Record; +} + +/** 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 AgentPromptResult { + stopReason?: string; +} + +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-claude-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-claude-runtime/src/cli-spawn.ts new file mode 100644 index 0000000000..a7651d67b1 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/cli-spawn.ts @@ -0,0 +1,31 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CLAUDE_CODE_CLI_ACP_BINARY = "claude-code-cli-acp"; +export interface ClaudeBridgeResolution { kind: "resolved" | "not_resolved"; requested: string; path?: string; reason?: string } +/* +FNXC:ClaudeAcp 2026-07-18-11:55: +The plugin runs both from source (`src/`), a standalone build (`dist/`), and +Fusion's single-file `bundled.js`. Only the first two sit one directory below the +plugin root; bundled.js sits at the root. Resolve the staged bridge relative to +that layout so published CLI sessions do not look in `dist/plugins/bridge`. +*/ +function pluginRootDir(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + return ["src", "dist"].includes(basename(moduleDir)) ? resolve(moduleDir, "..") : moduleDir; +} +/** Resolve only the identity-pinned bridge staged beside this bundled plugin. */ +export function bundledClaudeBridgeBinPath(pluginRoot = pluginRootDir()): string { + return join(pluginRoot, "bridge", `${CLAUDE_CODE_CLI_ACP_BINARY}${process.platform === "win32" ? ".cmd" : ""}`); +} +export function resolveBundledClaudeBridgeBinary(options: { pluginRoot?: string; exists?: (path: string) => boolean } = {}): ClaudeBridgeResolution { + const candidate = bundledClaudeBridgeBinPath(options.pluginRoot ?? pluginRootDir()); + const exists = options.exists ?? existsSync; + if (!exists(candidate) || !isAbsolute(candidate)) return { kind: "not_resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY, path: candidate, reason: `Staged ${CLAUDE_CODE_CLI_ACP_BINARY} bridge was not found at ${candidate}` }; + return { kind: "resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY, path: candidate }; +} +export async function runClaudeCommand(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((done) => { let stdout="", stderr="", settled=false; const finish=(r:{code:number|null;stdout:string;stderr:string})=>{if(!settled){settled=true;clearTimeout(timer);done(r)}}; const child=spawn(binary,args,{stdio:["ignore","pipe","pipe"],shell:process.platform==="win32"}); const timer=setTimeout(()=>{try{child.kill("SIGKILL")}catch{ /* process already exited */ } finish({code:124,stdout,stderr})},timeoutMs); child.stdout?.on("data",c=>stdout+=String(c)); child.stderr?.on("data",c=>stderr+=String(c)); child.once("error",e=>finish({code:127,stdout,stderr:`${stderr}${e.message}`})); child.once("close",code=>finish({code,stdout,stderr})); }); +} diff --git a/plugins/fusion-plugin-claude-runtime/src/index.ts b/plugins/fusion-plugin-claude-runtime/src/index.ts new file mode 100644 index 0000000000..98be16f1b7 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/index.ts @@ -0,0 +1,95 @@ +import { definePlugin } from "@fusion/plugin-sdk"; +import type { FusionPlugin } from "@fusion/plugin-sdk"; +import { killAllProcesses } from "./acp/index.js"; +import { probeClaudeBinary } from "./probe.js"; +import { discoverClaudeProviderModels } from "./provider.js"; +import { ClaudeRuntimeAdapter } from "./runtime-adapter.js"; + +/* +FNXC:ClaudeAcpRuntime 2026-07-17-12:00: +FN-8224 adds a first-class Claude ACP runtime. It composes the reviewed, +identity-pinned claude-code-cli-acp bridge, mirrors the bundled Grok runtime, +and is additive to the existing experimental pi-claude-cli Route A. +*/ + +/* +FNXC:ProcessLifecycle 2026-07-16-07:00: +The dashboard backfill worker repeatedly evaluates this plugin through +`vi.resetModules()` while retaining the process singleton. Install one exit +listener per Claude lifecycle owner and use the process-shared registry in the +ACP manager so it reaps children from every evaluation. Do not appease this +with `setMaxListeners`; the listener must stay bounded. +*/ +const PROCESS_EXIT_HOOK_KEY = Symbol.for("fusion.plugin.claude-runtime.exitCleanup"); +const processWithExitHook = process as typeof process & { [key: symbol]: boolean | undefined }; +if (!processWithExitHook[PROCESS_EXIT_HOOK_KEY]) { + process.on("exit", killAllProcesses); + processWithExitHook[PROCESS_EXIT_HOOK_KEY] = true; +} + +const plugin: FusionPlugin = definePlugin({ + manifest: { + id: "fusion-plugin-claude-runtime", + name: "Claude Runtime Plugin", + version: "0.1.0", + description: "Claude CLI runtime support for Fusion (ACP agent stdio)", + runtime: { + runtimeId: "claude", + name: "Claude Runtime", + version: "0.1.0", + }, + }, + state: "installed", + hooks: { + onLoad: (ctx) => { + ctx.logger.info( + "Claude Runtime Plugin loaded — transport=ACP (claude-code-cli-acp); probe uses claude --version", + ); + }, + }, + runtime: { + metadata: { + runtimeId: "claude", + name: "Claude Runtime", + version: "0.1.0", + }, + factory: async () => new ClaudeRuntimeAdapter(), + }, + cliProviders: [ + { + providerId: "claude-cli", + displayName: "Claude CLI", + binaryName: "claude", + providerType: "cli", + statusRoute: "/providers/claude-cli/status", + authRoute: "/auth/claude-cli", + actions: [ + { actionId: "enable", label: "Enable", actionType: "enable", method: "POST", route: "/auth/claude-cli" }, + { actionId: "disable", label: "Disable", actionType: "disable", method: "POST", route: "/auth/claude-cli" }, + { actionId: "test", label: "Test", actionType: "test", method: "GET", route: "/providers/claude-cli/status" } + ], + probe: async () => { + const status = await probeClaudeBinary(); + return { + available: status.available, + authenticated: status.authenticated, + binaryPath: status.binaryPath, + binaryName: status.binaryName, + version: status.version, + reason: status.reason, + }; + }, + discoverModels: discoverClaudeProviderModels, + runtime: { + runtimeId: "claude", + createAdapter: async () => new ClaudeRuntimeAdapter(), + }, + }, + ], +}); + +export default plugin; +export { probeClaudeBinary } from "./probe.js"; +export { discoverClaudeProviderModels } from "./provider.js"; +export { ClaudeRuntimeAdapter } from "./runtime-adapter.js"; +export type { ClaudeBinaryStatus } from "./types.js"; diff --git a/plugins/fusion-plugin-claude-runtime/src/mcp-forwarding.ts b/plugins/fusion-plugin-claude-runtime/src/mcp-forwarding.ts new file mode 100644 index 0000000000..e7c0e19ffa --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/mcp-forwarding.ts @@ -0,0 +1,114 @@ +/* +FNXC:ClaudeAcp 2026-07-11-14:00: +Convert engine-resolved MCP server definitions (FN-7022 three-transport shape) +into ACP `session/new.mcpServers` entries so Claude agent stdio receives the same +operator-approved MCP set as other Fusion AI lanes. Env/header secrets are +already materialized by the engine; this module only reshapes them and never logs +server contents. +*/ + +export type AcpMcpServer = + | { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; + } + | { + type: "http"; + name: string; + url: string; + headers: { name: string; value: string }[]; + } + | { + type: "sse"; + name: string; + url: string; + headers: { name: string; value: string }[]; + }; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function mapEntries(map: Record | undefined): { name: string; value: string }[] { + if (!map) return []; + return Object.entries(map) + .filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string") + .map(([name, value]) => ({ name, value })); +} + +/** + * Normalize engine `mcpServers` (ResolvedMcpServerDefinition or legacy ACP + * stdio shape) into the ACP wire format Claude accepts. + */ +export function toAcpMcpServers(servers: unknown): AcpMcpServer[] { + if (!Array.isArray(servers) || servers.length === 0) return []; + const out: AcpMcpServer[] = []; + + for (const raw of servers) { + const server = asRecord(raw); + if (!server) continue; + const name = typeof server.name === "string" ? server.name.trim() : ""; + if (!name || server.enabled === false) continue; + + // Legacy ACP stdio shape: { name, command, args, env: [{name,value}] } + if (typeof server.command === "string" && server.command.trim() && !("transport" in server) && !("type" in server) && !("url" in server)) { + const envPairs = Array.isArray(server.env) + ? server.env + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)) + .filter((entry) => typeof entry.name === "string" && typeof entry.value === "string") + .map((entry) => ({ name: String(entry.name), value: String(entry.value) })) + : mapEntries(asRecord(server.env) as Record | undefined); + out.push({ + name, + command: server.command.trim(), + args: Array.isArray(server.args) ? server.args.filter((a): a is string => typeof a === "string") : [], + env: envPairs, + }); + continue; + } + + const transport = typeof server.transport === "string" ? server.transport : typeof server.type === "string" ? server.type : "stdio"; + + if (transport === "stdio") { + const command = typeof server.command === "string" ? server.command.trim() : ""; + if (!command) continue; + out.push({ + name, + command, + args: Array.isArray(server.args) ? server.args.filter((a): a is string => typeof a === "string") : [], + env: mapEntries(asRecord(server.env) as Record | undefined), + }); + continue; + } + + if (transport === "http" || transport === "streamable-http") { + const url = typeof server.url === "string" ? server.url.trim() : ""; + if (!url) continue; + out.push({ + type: "http", + name, + url, + headers: mapEntries(asRecord(server.headers) as Record | undefined), + }); + continue; + } + + if (transport === "sse") { + const url = typeof server.url === "string" ? server.url.trim() : ""; + if (!url) continue; + out.push({ + type: "sse", + name, + url, + headers: mapEntries(asRecord(server.headers) as Record | undefined), + }); + } + } + + return out; +} diff --git a/plugins/fusion-plugin-claude-runtime/src/mcp-schema-server.cjs b/plugins/fusion-plugin-claude-runtime/src/mcp-schema-server.cjs new file mode 100644 index 0000000000..5f147d5e0b --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/mcp-schema-server.cjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/* +FNXC:GrokAcp 2026-07-11-14:00: +Executable MCP bridge for Fusion custom tools (fn_*) on the Grok ACP path. +tools/list is served from a schema file; tools/call POSTs to a localhost bridge +owned by GrokRuntimeAdapter so ToolDefinition.execute runs in-process with the +engine's closures. Unlike the Claude/Droid schema-only break-early servers, +Grok actually invokes MCP tools/call itself. +*/ +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const readline = require("readline"); +// FNXC:GrokAcp 2026-07-11-18:30: CJS has no global URL under eslint no-undef; use node:url. +const { URL } = require("node:url"); + +const schemaPath = process.argv[2]; +const bridgeUrl = process.env.FUSION_GROK_TOOL_BRIDGE_URL; +const capabilityToken = process.env.FUSION_TOOL_BRIDGE_CAPABILITY; +if (!schemaPath || !bridgeUrl || !capabilityToken) { + process.stderr.write("fusion-tools-mcp-server: missing schema path, bridge URL, or capability\n"); + process.exit(1); +} + +let tools = []; +try { + tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8")); + if (!Array.isArray(tools)) tools = []; +} catch { + process.exit(1); +} + +function write(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +function callBridge(toolName, args) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ name: toolName, arguments: args ?? {} }); + const url = new URL("/tool-call", bridgeUrl); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + authorization: `Bearer ${capabilityToken}`, + }, + timeout: 120_000, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + try { + resolve(JSON.parse(data || "{}")); + } catch (err) { + reject(err); + } + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error("tool bridge timeout")); + }); + req.write(body); + req.end(); + }); +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + + if (msg.method === "initialize") { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "fusion-custom-tools", version: "1.0.0" }, + }, + }); + return; + } + + if (msg.method === "notifications/initialized" || msg.method === "initialized") { + return; + } + + if (msg.method === "tools/list") { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + inputSchema: tool.inputSchema ?? { type: "object", properties: {} }, + })), + }, + }); + return; + } + + if (msg.method === "tools/call") { + const toolName = msg.params?.name; + const args = msg.params?.arguments ?? {}; + callBridge(toolName, args) + .then((result) => { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + content: Array.isArray(result.content) + ? result.content + : [{ type: "text", text: typeof result.text === "string" ? result.text : JSON.stringify(result) }], + isError: result.isError === true, + }, + }); + }) + .catch((err) => { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + isError: true, + }, + }); + }); + return; + } + + if (msg.id !== undefined) { + write({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` }, + }); + } +}); diff --git a/plugins/fusion-plugin-claude-runtime/src/probe.ts b/plugins/fusion-plugin-claude-runtime/src/probe.ts new file mode 100644 index 0000000000..b1874bfe3c --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/probe.ts @@ -0,0 +1,8 @@ +import { resolveBundledClaudeBridgeBinary, runClaudeCommand } from "./cli-spawn.js"; +import type { ClaudeBinaryStatus } from "./types.js"; +/** Claude auth belongs to its CLI/ACP bridge; availability never requires a Fusion-visible API key. */ +export async function probeClaudeBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise { + const startedAt=Date.now(); const bridge=resolveBundledClaudeBridgeBinary(); const binary=options?.binaryPath?.trim() || "claude"; const result=await runClaudeCommand(binary,["--version"],options?.timeoutMs ?? 3000); + if (result.code===0 && bridge.kind==="resolved") return {available:true,authenticated:true,binaryName:binary,binaryPath:bridge.path,version:result.stdout.trim()||undefined,probeDurationMs:Date.now()-startedAt}; + return {available:false,authenticated:false,binaryName:binary,binaryPath:bridge.path,reason:bridge.reason ?? (result.stderr.trim() || "Claude CLI unavailable"),probeDurationMs:Date.now()-startedAt}; +} diff --git a/plugins/fusion-plugin-claude-runtime/src/provider.ts b/plugins/fusion-plugin-claude-runtime/src/provider.ts new file mode 100644 index 0000000000..0c80863470 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/provider.ts @@ -0,0 +1,8 @@ +import { probeClaudeBinary } from "./probe.js"; +const KNOWN_CLAUDE_MODELS = ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-3-5-haiku-20241022"]; +export async function discoverClaudeProviderModels(options?: unknown) { + const settings=options && typeof options === "object" ? options as { binaryPath?: string; timeoutMs?: number } : {}; + const probe=await probeClaudeBinary(settings); + if (!probe.available) return {models: [], source:"probe", fallbackUsed:true, reason:probe.reason ?? "Claude ACP bridge unavailable"}; + return {models: KNOWN_CLAUDE_MODELS.map((id)=>({id,label:id})), source:"known", fallbackUsed:false}; +} diff --git a/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts new file mode 100644 index 0000000000..8ed98377a4 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts @@ -0,0 +1,424 @@ +import { AcpRuntimeAdapter } from "./acp/index.js"; +import { + buildClaudeAcpRuntimeSettings, + modelForCli, + normalizeClaudeCliModel, +} from "./acp-settings.js"; +import { toAcpMcpServers, type AcpMcpServer } from "./mcp-forwarding.js"; +import { + buildClaudeSkillRules, + extractRequestedSkillNames, + stageClaudeSessionSkills, +} from "./skill-loader.js"; +import { startFusionToolBridge, type FusionToolBridge, type ToolLike } from "./tool-bridge.js"; +import type { + AgentRuntime, + AgentRuntimeOptions, + AgentSession, + AgentSessionResult, + ClaudeSession, +} from "./types.js"; + +/* +FNXC:ClaudeAcp 2026-07-11-12:00: +Replace the one-shot headless path (`claude -p --output-format json`) with native +ACP transport (`claude agent stdio`) for realtime streaming, tool visibility, and +multi-turn session reuse. Implementation composes a vendored AcpRuntimeAdapter +(copied under ./acp/, not imported from fusion-plugin-acp-runtime) with +Claude-specific binary/args/env. Keep resolve-never-reject on prompt failures so +chat/executor always get a well-formed turn; surface create/prompt failures as +visible onText diagnostics rather than silent empty bubbles (FN-7779 invariant). + +FNXC:ClaudeAcp 2026-07-11-16:00: +Do not import `@fusion-plugin-examples/acp-runtime`. Claude is bundled/auto-install; +the generic ACP plugin is experimental. Vendor the client modules under src/acp/. + +FNXC:ClaudeCliRouting 2026-07-10-10:54: +FN-7753's auto-derived `claude` runtime routing from a `claude-cli/*` model selection +still preserves the concrete model. Normalize provider-qualified ids +(`claude-cli/` or `claude/`) and pass only the concrete id as `claude agent -m`; +the no-model Runtime-mode path keeps `claude/default` and omits `-m`. + +FNXC:ClaudeAcp 2026-07-11-14:00: +Load Fusion tools + skills into the ACP session: + - Operator MCP servers → session/new.mcpServers (stdio/http/sse) + - Engine customTools (fn_*) → loopback MCP bridge + fusion-custom-tools server + - Skills → session-scoped --plugin-dir / _meta.pluginDirs + rules context +*/ + +export type AcpAdapterFactory = (settings: Record) => { + createSession(options: AgentRuntimeOptions): Promise; + promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise; + describeModel(session: AgentSession): string; + dispose?(session: AgentSession): Promise; +}; + +export interface ClaudeRuntimeAdapterOptions { + /** Binary name/path to invoke. Defaults to "claude" (PATH resolution). */ + binary?: string; + /** + * Injectable ACP adapter factory for tests. Production uses + * `AcpRuntimeAdapter` with Claude ACP settings. + */ + createAcpAdapter?: AcpAdapterFactory; +} + +/** Turn-scoped stream accumulators stored on the session for prompt finalization. */ +interface TurnAccum { + text: string; +} + +interface SessionResources { + toolBridge?: FusionToolBridge | null; + skillStaging?: { dispose: () => void } | null; +} + +function compactDiagnostic(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function describeCreateFailure(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + return compactDiagnostic( + `Claude ACP failed to start: ${reason}. Ensure the \`claude\` binary is installed and authenticated (` + + `\`claude agent stdio\`), or set XAI_API_KEY / GROK_API_KEY for key-based auth.`, + ); +} + +/* +FNXC:ClaudeAcp 2026-07-15-18:45: +`promptAcpSession` (acp-runtime provider.ts) already re-shapes JSON-RPC faults into a diagnostic +carrying the rpc code — e.g. `Internal error (acp rpc code -32603, retryable)`. Pass that through +verbatim rather than re-flattening to `error.message`, so the engine's transient classifier can +recognize a provider-side blip and retry instead of parking the task permanently. + +FN-8004: the bare message reaching the merger was "Internal error", matched no transient pattern, +and terminally failed an auto-merge whose branch work was complete and correct. +*/ +function describePromptFailure(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + return compactDiagnostic(`Claude ACP turn failed: ${reason}`); +} + +function appendMessage(session: ClaudeSession, role: "user" | "assistant", content: string): void { + const entry = { role, content }; + session.state.messages.push(entry); + if (session.messages !== session.state.messages) { + session.messages.push(entry); + } +} + +const TURN_ACCUM = Symbol("claudeTurnAccum"); +const SESSION_RESOURCES = Symbol("claudeSessionResources"); + +type SessionWithExtras = ClaudeSession & { + [TURN_ACCUM]?: TurnAccum; + [SESSION_RESOURCES]?: SessionResources; +}; + +function getTurnAccum(session: ClaudeSession): TurnAccum { + const s = session as SessionWithExtras; + if (!s[TURN_ACCUM]) { + s[TURN_ACCUM] = { text: "" }; + } + return s[TURN_ACCUM]; +} + +function resetTurnAccum(session: ClaudeSession): void { + getTurnAccum(session).text = ""; +} + +function collectCustomTools(options: AgentRuntimeOptions): ToolLike[] { + const fromCustom = Array.isArray(options.customTools) ? (options.customTools as ToolLike[]) : []; + /* + FNXC:ClaudeAcp 2026-07-11-19:00: + AgentRuntimeOptions.tools is typed as "coding"|"readonly"|undefined, but some call sites pass + an array of ToolDefinitions. Narrow via Array.isArray on the tools field, then cast the array + value only — never cast the whole options object to { tools: ToolLike[] } (TS2352). + */ + const toolsField = (options as { tools?: unknown }).tools; + const maybeToolsArray = Array.isArray(toolsField) ? (toolsField as ToolLike[]) : []; + return [...fromCustom, ...maybeToolsArray]; +} + +function ensureClaudeSessionShape( + session: AgentSession, + model: string, + options: AgentRuntimeOptions, + turnAccum: TurnAccum, + resources: SessionResources, +): ClaudeSession { + const messages: unknown[] = + Array.isArray((session as ClaudeSession).messages) ? (session as ClaudeSession).messages : []; + const existingState = (session as { state?: ClaudeSession["state"] }).state; + const state: ClaudeSession["state"] = existingState ?? { messages }; + if (!Array.isArray(state.messages)) { + state.messages = messages; + } + + const claude = session as ClaudeSession; + claude.model = model; + claude.systemPrompt = claude.systemPrompt ?? options.systemPrompt; + claude.messages = state.messages; + claude.state = state; + claude.lastModelDescription = `claude/${model}`; + // Prefer callbacks already installed on the ACP session (wrapped at create + // for turnAccum + engine fans-out). Only fall back to the raw engine options. + claude.callbacks = { + onText: claude.callbacks?.onText ?? options.onText, + onThinking: claude.callbacks?.onThinking ?? options.onThinking, + onToolStart: claude.callbacks?.onToolStart ?? options.onToolStart, + onToolEnd: claude.callbacks?.onToolEnd ?? options.onToolEnd, + }; + + const originalDispose = typeof claude.dispose === "function" ? claude.dispose.bind(claude) : () => undefined; + claude.dispose = () => { + void resources.toolBridge?.dispose(); + resources.skillStaging?.dispose(); + originalDispose(); + }; + + (claude as SessionWithExtras)[TURN_ACCUM] = turnAccum; + (claude as SessionWithExtras)[SESSION_RESOURCES] = resources; + return claude; +} + +function createDeadSession( + model: string, + options: AgentRuntimeOptions, + diagnostic: string, + resources?: SessionResources, +): ClaudeSession { + const messages: unknown[] = []; + const session: ClaudeSession = { + model, + systemPrompt: options.systemPrompt, + messages, + state: { messages, errorMessage: diagnostic }, + sessionId: undefined, + lastModelDescription: `claude/${model}`, + callbacks: { + onText: options.onText, + onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, + }, + dispose: () => { + void resources?.toolBridge?.dispose(); + resources?.skillStaging?.dispose(); + }, + }; + return session; +} + +export class ClaudeRuntimeAdapter implements AgentRuntime { + readonly id = "claude"; + readonly name = "Claude Runtime"; + private readonly binary?: string; + private readonly createAcpAdapter: AcpAdapterFactory; + /** Per-session ACP adapter so model-specific spawn args stay consistent. */ + private readonly adapters = new WeakMap>(); + + constructor(options?: ClaudeRuntimeAdapterOptions) { + // The default is the staged, identity-pinned Claude ACP bridge; injection is test-only. + this.binary = options?.binary; + /* + FNXC:ClaudeAcp 2026-07-11-19:00: + AcpRuntimeAdapter returns ACP AgentSession shapes (acp/types); AcpAdapterFactory is typed + against Claude AgentSessionResult (messages/state). createSession always runs + ensureClaudeSessionShape after the ACP create, so the production factory is a deliberate + structural bridge via unknown rather than unifying the two session interfaces here. + */ + this.createAcpAdapter = + options?.createAcpAdapter ?? + ((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType); + } + + async createSession( + options: AgentRuntimeOptions = { + cwd: process.cwd(), + systemPrompt: "", + }, + ): Promise { + const model = normalizeClaudeCliModel(options.defaultModelId) ?? "claude/default"; + const turnAccum: TurnAccum = { text: "" }; + const resources: SessionResources = {}; + + // ── Skills ──────────────────────────────────────────────────────────── + const requestedSkillNames = extractRequestedSkillNames({ + skills: options.skills, + skillSelection: options.skillSelection, + }); + const skillStaging = stageClaudeSessionSkills({ + requestedSkillNames, + additionalSkillPaths: options.additionalSkillPaths, + includeFusionSkill: true, + }); + resources.skillStaging = skillStaging; + + // ── Operator MCP + Fusion custom tools ──────────────────────────────── + const operatorMcp = toAcpMcpServers(options.mcpServers); + let toolBridge: FusionToolBridge | null = null; + try { + toolBridge = await startFusionToolBridge(collectCustomTools(options), { + actionGateContext: options.actionGateContext, + }); + resources.toolBridge = toolBridge; + } catch { + toolBridge = null; + } + + const mcpServers: AcpMcpServer[] = [ + ...operatorMcp, + ...(toolBridge ? [toolBridge.mcpServer] : []), + ]; + + const rules = buildClaudeSkillRules({ + skillNames: skillStaging.skillNames.length > 0 ? skillStaging.skillNames : requestedSkillNames, + toolMode: typeof options.tools === "string" ? options.tools : "coding", + fusionToolCount: toolBridge?.toolCount, + operatorMcpCount: operatorMcp.length, + }); + + const systemPromptParts = [options.systemPrompt?.trim() ?? "", rules].filter((part) => part.length > 0); + const systemPrompt = systemPromptParts.join("\n\n"); + + const sessionMeta: Record = { + pluginDirs: [skillStaging.pluginDir], + rules, + ...(systemPrompt ? { systemPromptOverride: systemPrompt } : {}), + }; + + const sessionOptions: AgentRuntimeOptions = { + ...options, + cwd: options.cwd?.trim() ? options.cwd : process.cwd(), + systemPrompt, + defaultModelId: modelForCli(model) ?? model, + mcpServers, + sessionMeta, + onText: (delta: string) => { + turnAccum.text += delta; + options.onText?.(delta); + }, + onThinking: (delta: string) => { + options.onThinking?.(delta); + }, + onToolStart: (name: string, args?: unknown) => { + options.onToolStart?.(name, args); + }, + onToolEnd: (name: string, isError: boolean, result?: unknown) => { + options.onToolEnd?.(name, isError, result); + }, + }; + + const settings = buildClaudeAcpRuntimeSettings({ + ...(this.binary ? { binary: this.binary } : {}), + model, + pluginDirs: [skillStaging.pluginDir], + }); + const acp = this.createAcpAdapter(settings); + + try { + const result = await acp.createSession(sessionOptions); + const session = ensureClaudeSessionShape(result.session, model, options, turnAccum, resources); + this.adapters.set(session, acp); + return { session, sessionFile: result.sessionFile }; + } catch (error) { + const diagnostic = describeCreateFailure(error); + const session = createDeadSession(model, sessionOptions, diagnostic, resources); + session.callbacks.onText?.(diagnostic); + appendMessage(session, "assistant", diagnostic); + return { session, sessionFile: undefined }; + } + } + + async promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise { + const claudeSession = session as ClaudeSession; + appendMessage(claudeSession, "user", prompt); + resetTurnAccum(claudeSession); + + const acp = this.adapters.get(session); + const hasConnection = + acp && "connection" in session && Boolean((session as { connection?: unknown }).connection); + + /* + FNXC:ClaudeAcp 2026-07-12-06:15: + Dead / disposed sessions have no ACP connection. Follow-up prompts must not + append a user message and return silently — always re-surface a diagnostic + via onText + assistant message so multi-turn chat stays visible. Prefer the + previous errorMessage when present so operators still see the root cause. + */ + if (!hasConnection) { + const existing = claudeSession.state.errorMessage?.trim(); + const diagnostic = existing + ? `Claude ACP session has no live connection (previous error: ${existing}). Start a new session to retry.` + : "Claude ACP session has no live connection. The `claude agent stdio` process failed to start or was disposed."; + claudeSession.state.errorMessage = diagnostic; + claudeSession.callbacks.onText?.(diagnostic); + appendMessage(claudeSession, "assistant", diagnostic); + return; + } + + try { + const result = await acp!.promptWithFallback(session, prompt, options); + const assistantText = getTurnAccum(claudeSession).text; + if (assistantText.length > 0) { + appendMessage(claudeSession, "assistant", assistantText); + } else if (result && typeof result === "object" && "stopReason" in result) { + const stopReason = result.stopReason; + if (stopReason && stopReason !== "end_turn" && stopReason !== "EndTurn") { + const diagnostic = `Claude ACP ended with stopReason ${stopReason} and produced no assistant text.`; + claudeSession.state.errorMessage = diagnostic; + claudeSession.callbacks.onText?.(diagnostic); + appendMessage(claudeSession, "assistant", diagnostic); + } + } + return result; + } catch (error) { + const assistantText = getTurnAccum(claudeSession).text; + if (assistantText.length === 0) { + const diagnostic = describePromptFailure(error); + claudeSession.state.errorMessage = diagnostic; + claudeSession.callbacks.onText?.(diagnostic); + appendMessage(claudeSession, "assistant", diagnostic); + } else { + appendMessage(claudeSession, "assistant", assistantText); + } + return; + } + } + + describeModel(session: AgentSession): string { + const claudeSession = session as ClaudeSession; + return claudeSession.lastModelDescription || `claude/${claudeSession.model ?? "default"}`; + } + + async dispose(session: AgentSession): Promise { + const resources = (session as SessionWithExtras)[SESSION_RESOURCES]; + try { + await resources?.toolBridge?.dispose(); + } catch { + // best-effort + } + try { + resources?.skillStaging?.dispose(); + } catch { + // best-effort + } + const acp = this.adapters.get(session); + if (acp && typeof acp.dispose === "function") { + await acp.dispose(session); + return; + } + const claude = session as ClaudeSession; + claude.dispose?.(); + } +} diff --git a/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts b/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts new file mode 100644 index 0000000000..d15c022783 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts @@ -0,0 +1,290 @@ +/* +FNXC:ClaudeAcp 2026-07-11-14:00: +Stage Fusion + session skills so Claude ACP discovers them the same way pi does. +Claude loads skills from trusted `--plugin-dir` / `_meta.pluginDirs` plugins +(skills/ SKILL.md tree). We materialize a session-scoped plugin directory with: + - the bundled Fusion skill (fn_* tool catalog + workflows) + - skills from engine additionalSkillPaths / skill roots +Requested skill names are also listed in runtime context rules so the agent +still sees the selection when a skill file cannot be resolved on disk. + +FNXC:ClaudeAcp 2026-07-12-06:15: +Packaged `@runfusion/fusion` publishes `skill/**` (not only monorepo +`packages/cli/skill/fusion`). Expand fusion-skill candidates so CLI installs under +`dist/plugins/fusion-plugin-claude-runtime/` still resolve `skill/fusion` at the +package root, via parent walks, createRequire of `@runfusion/fusion/package.json`, +and optional `FUSION_SKILL_SOURCE`. Missing fusion skill must not fail session +create — rules still list requested skills via buildClaudeSkillRules. +*/ + +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const FUSION_SKILL_NAME = "fusion"; + +export interface ClaudeSkillStagingResult { + pluginDir: string; + skillNames: string[]; + dispose: () => void; +} + +function isSkillDir(dir: string): boolean { + return existsSync(join(dir, "SKILL.md")); +} + +function pushUnique(out: string[], candidate: string | null | undefined): void { + if (!candidate) return; + const resolved = resolve(candidate); + if (!out.includes(resolved)) out.push(resolved); +} + +function pushSkillLayoutsAtRoot(out: string[], root: string): void { + pushUnique(out, join(root, "skill", FUSION_SKILL_NAME)); + pushUnique(out, join(root, "packages", "cli", "skill", FUSION_SKILL_NAME)); +} + +function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void { + let dir = startDir; + for (let i = 0; i < maxParents; i++) { + pushSkillLayoutsAtRoot(out, dir); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } +} + +function pushPackageRequireCandidates(out: string[], from: string): void { + try { + const require = createRequire(from); + const pkgJson = require.resolve("@runfusion/fusion/package.json"); + pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME)); + } catch { + // Package not resolvable from this origin (plugin-only tree, tests, etc.). + } +} + +/** + * Ordered candidate directories for the bundled Fusion skill (`skill/fusion` with SKILL.md). + * First existing skill dir wins in resolveBundledFusionSkillSource. + */ +export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + const candidates: string[] = []; + const envSource = process.env.FUSION_SKILL_SOURCE?.trim(); + if (envSource) { + pushUnique(candidates, envSource); + } + + const here = fileURLToPath(moduleUrl); + const moduleDir = dirname(here); + + // Monorepo source checkout relative to plugins/fusion-plugin-claude-runtime/src + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME)); + // Relative siblings used in various dist layouts + pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); + /* + FNXC:ClaudeAcp 2026-07-12-06:15: + Published package layout: dist/plugins/fusion-plugin-claude-runtime/* → ../../../skill/fusion + at the @runfusion/fusion package root (files includes skill/**). + */ + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "../../../skill", FUSION_SKILL_NAME)); + + walkAncestorSkillCandidates(candidates, moduleDir, 8); + pushPackageRequireCandidates(candidates, moduleUrl); + + const argv1 = typeof process.argv[1] === "string" ? process.argv[1].trim() : ""; + if (argv1) { + try { + const argvPath = resolve(argv1); + pushPackageRequireCandidates(candidates, argvPath); + walkAncestorSkillCandidates(candidates, dirname(argvPath), 8); + } catch { + // ignore bad argv paths + } + } + + return candidates; +} + +export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null { + for (const candidate of getFusionSkillSourceCandidates(moduleUrl)) { + if (isSkillDir(candidate)) return candidate; + } + return null; +} + +function installSkillDir(sourceDir: string, targetDir: string): boolean { + if (!isSkillDir(sourceDir)) return false; + mkdirSync(dirname(targetDir), { recursive: true }); + if (existsSync(targetDir)) { + rmSync(targetDir, { recursive: true, force: true }); + } + try { + symlinkSync(sourceDir, targetDir, "dir"); + return true; + } catch { + try { + cpSync(sourceDir, targetDir, { recursive: true }); + return true; + } catch { + return false; + } + } +} + +function collectSkillsFromRoot(root: string, out: Map): void { + if (!existsSync(root)) return; + // Root may itself be a skill (…/skills/foo with SKILL.md) or a skills container. + if (isSkillDir(root)) { + out.set(basename(root), root); + return; + } + let entries: string[] = []; + try { + entries = readdirSync(root); + } catch { + return; + } + for (const entry of entries) { + const child = join(root, entry); + if (isSkillDir(child)) { + out.set(entry, child); + } + } +} + +export interface StageClaudeSkillsOptions { + /** Engine-requested skill names (skillSelection / skills). */ + requestedSkillNames?: string[]; + /** Extra skill roots (plugin skill dirs, CE install roots, etc.). */ + additionalSkillPaths?: string[]; + /** Always include the bundled Fusion skill (default true). */ + includeFusionSkill?: boolean; +} + +/** + * Build a session-scoped Claude plugin directory with Fusion + requested skills. + */ +export function stageClaudeSessionSkills(options: StageClaudeSkillsOptions = {}): ClaudeSkillStagingResult { + const pluginDir = mkdtempSync(join(tmpdir(), "fusion-claude-plugin-")); + const skillsDir = join(pluginDir, "skills"); + mkdirSync(skillsDir, { recursive: true }); + + const installed = new Map(); + const includeFusion = options.includeFusionSkill !== false; + + if (includeFusion) { + const fusionSource = resolveBundledFusionSkillSource(); + if (fusionSource && installSkillDir(fusionSource, join(skillsDir, FUSION_SKILL_NAME))) { + installed.set(FUSION_SKILL_NAME, fusionSource); + } + } + + for (const root of options.additionalSkillPaths ?? []) { + if (typeof root !== "string" || !root.trim()) continue; + collectSkillsFromRoot(root.trim(), installed); + } + + // Re-install collected skills (may overwrite with higher-priority roots). + for (const [name, source] of installed) { + if (name === FUSION_SKILL_NAME && includeFusion) continue; // already installed + installSkillDir(source, join(skillsDir, name)); + } + + // Second pass: additionalSkillPaths may have added fusion under a different name path. + for (const [name, source] of installed) { + if (!existsSync(join(skillsDir, name))) { + installSkillDir(source, join(skillsDir, name)); + } + } + + writeFileSync( + join(pluginDir, "plugin.json"), + JSON.stringify( + { + name: "fusion-session-skills", + version: "0.1.0", + description: "Session-scoped Fusion skills for Claude ACP", + }, + null, + 2, + ), + ); + + const skillNames = Array.from( + new Set([ + ...installed.keys(), + ...(options.requestedSkillNames ?? []).filter((n) => typeof n === "string" && n.trim().length > 0), + ]), + ); + + return { + pluginDir, + skillNames, + dispose: () => { + try { + rmSync(pluginDir, { recursive: true, force: true }); + } catch { + // best-effort + } + }, + }; +} + +/** + * Build a short rules block listing requested skills and reminding Claude to use + * Fusion tools/MCP when available. + */ +export function buildClaudeSkillRules(options: { + skillNames: string[]; + toolMode?: string; + fusionToolCount?: number; + operatorMcpCount?: number; +}): string { + const lines = [ + "Fusion runtime context for this session:", + `- Tool mode: ${options.toolMode ?? "coding"}`, + ]; + if (options.skillNames.length > 0) { + lines.push(`- Loaded / requested skills: ${options.skillNames.join(", ")}`); + } + if (typeof options.fusionToolCount === "number") { + lines.push(`- Fusion custom tools (fn_*) available via MCP server "fusion-custom-tools": ${options.fusionToolCount}`); + } + if (typeof options.operatorMcpCount === "number" && options.operatorMcpCount > 0) { + lines.push(`- Operator MCP servers forwarded into this session: ${options.operatorMcpCount}`); + } + lines.push( + "- Prefer Fusion fn_* MCP tools for task board / coordination actions (e.g. fn_task_done, fn_task_list) when they are available.", + "- Use the Fusion skill workflows when planning or managing tasks.", + ); + return lines.join("\n"); +} + +export function extractRequestedSkillNames(options: { + skills?: unknown; + skillSelection?: unknown; +}): string[] { + const fromSkills = Array.isArray(options.skills) + ? options.skills.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const selection = options.skillSelection as { requestedSkillNames?: unknown } | undefined; + const fromSelection = Array.isArray(selection?.requestedSkillNames) + ? selection.requestedSkillNames.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + return Array.from(new Set(fromSkills.length > 0 ? fromSkills : fromSelection)); +} diff --git a/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts b/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts new file mode 100644 index 0000000000..f9d5740d63 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts @@ -0,0 +1,264 @@ +/* +FNXC:ClaudeAcp 2026-07-11-14:00: +Host Fusion custom tools (fn_*) for the Claude ACP agent. ToolDefinition.execute +closures only work in-process, so ClaudeRuntimeAdapter starts a loopback HTTP +bridge and pairs it with fusion-tools-mcp-server.cjs (stdio MCP) that Claude +connects to via session/new.mcpServers. Dispose closes the bridge so no port +is left open after the session ends. +*/ + +import { createServer, type Server } from "node:http"; +import { rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes, randomUUID } from "node:crypto"; +import { effectiveDisposition, runApprovalForCategory } from "./acp/control-handler.js"; +import type { FusionCategory } from "./acp/types.js"; +import type { AcpMcpServer } from "./mcp-forwarding.js"; +import type { PermissionGate } from "./types.js"; + +const BUILT_IN_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "grep", "find"]); +const MAX_TOOL_CALL_BODY_BYTES = 1_048_576; + +export interface ToolLike { + name: string; + description?: string; + parameters?: Record; + execute?: ( + toolCallId: string, + params: unknown, + signal?: AbortSignal, + onUpdate?: unknown, + ctx?: unknown, + ) => Promise | unknown; +} + +export interface McpToolDef { + name: string; + description: string; + inputSchema: Record; +} + +export interface FusionToolBridge { + mcpServer: AcpMcpServer; + dispose: () => Promise; + toolCount: number; +} + +export interface FusionToolBridgeOptions { + /** Per-session engine action gate; absent gates default-deny custom tool calls. */ + actionGateContext?: PermissionGate; + /** Honours the ACP runtime's explicit unrestricted-risk acknowledgement. */ + allowUnrestricted?: boolean; +} + +/** + * Fusion fn_* tools can mutate tasks, agents, secrets, and the workspace. They + * therefore use the action gate's task/agent mutation category as the + * conservative common floor instead of trusting a tool name supplied by ACP. + */ +const FUSION_TOOL_CATEGORY: FusionCategory = "task_agent_mutation"; + +export function toolsToMcpToolDefs(tools: ReadonlyArray | undefined): McpToolDef[] { + if (!Array.isArray(tools)) return []; + return tools + .filter((tool) => tool && typeof tool.name === "string" && tool.name.trim().length > 0 && !BUILT_IN_TOOL_NAMES.has(tool.name)) + .map((tool) => ({ + name: tool.name, + description: typeof tool.description === "string" ? tool.description : "", + inputSchema: tool.parameters ?? { type: "object", properties: {} }, + })); +} + +function fusionToolsMcpServerPath(): string { + // Packaged CLI copies this as mcp-schema-server.cjs next to the bundled plugin. + return join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs"); +} + +function resultToText(result: unknown): string { + if (result == null) return ""; + if (typeof result === "string") return result; + if (typeof result === "object") { + const obj = result as { content?: unknown; text?: unknown; details?: unknown }; + if (typeof obj.text === "string") return obj.text; + if (Array.isArray(obj.content)) { + return obj.content + .map((block) => { + if (block && typeof block === "object" && "text" in block && typeof (block as { text: unknown }).text === "string") { + return (block as { text: string }).text; + } + return JSON.stringify(block); + }) + .join("\n"); + } + } + try { + return JSON.stringify(result); + } catch { + return String(result); + } +} + +/** + * Start a loopback tool bridge and return the ACP mcpServers entry Claude should + * connect to for Fusion custom tools. Returns null when there are no tools. + */ +export async function startFusionToolBridge( + tools: ReadonlyArray | undefined, + options: FusionToolBridgeOptions = {}, +): Promise { + const defs = toolsToMcpToolDefs(tools); + if (defs.length === 0) return null; + + const byName = new Map(); + for (const tool of tools ?? []) { + if (tool && typeof tool.name === "string" && typeof tool.execute === "function") { + byName.set(tool.name, tool); + } + } + + const schemaPath = join(tmpdir(), `fusion-claude-mcp-schemas-${process.pid}-${randomUUID()}.json`); + writeFileSync(schemaPath, JSON.stringify(defs)); + const capabilityToken = randomBytes(32).toString("base64url"); + + const server: Server = createServer(async (req, res) => { + const reject = (statusCode: number, text: string): void => { + res.statusCode = statusCode; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ isError: true, text })); + }; + if (req.method !== "POST" || req.url !== "/tool-call") { + reject(404, "not found"); + return; + } + if (req.headers.authorization !== `Bearer ${capabilityToken}`) { + reject(401, "unauthorized"); + return; + } + let body = ""; + let bodyBytes = 0; + try { + for await (const chunk of req) { + bodyBytes += Buffer.byteLength(chunk); + if (bodyBytes > MAX_TOOL_CALL_BODY_BYTES) { + // Drain the remaining stream before returning a bounded rejection so + // the client receives a deterministic 413 instead of a reset socket. + req.resume(); + reject(413, "tool call body exceeds limit"); + return; + } + body += chunk; + } + } catch { + if (!res.writableEnded) reject(400, "invalid request body"); + return; + } + let parsed: { name?: string; arguments?: unknown }; + try { + parsed = JSON.parse(body || "{}") as { name?: string; arguments?: unknown }; + } catch { + reject(400, "invalid JSON body"); + return; + } + const name = typeof parsed.name === "string" ? parsed.name : ""; + const tool = byName.get(name); + if (!tool?.execute) { + reject(404, `Unknown Fusion tool: ${name}`); + return; + } + + /* + FNXC:ClaudeAcp 2026-07-17-15:30: + The loopback port is not an authorization boundary: another local process + can discover it. Require the per-session capability token and evaluate each + fn_* call through Fusion's action gate before its in-process closure runs. + Missing policy/HITL support is default-deny; custom tools are conservatively + categorized as task_agent_mutation rather than trusting ACP-provided names. + */ + const gate = options.actionGateContext; + if (!gate?.permissionPolicy) { + reject(403, "Fusion action policy is unavailable for this tool call"); + return; + } + const disposition = effectiveDisposition(FUSION_TOOL_CATEGORY, gate, { + allowUnrestricted: options.allowUnrestricted === true, + }); + const allowed = + disposition === "allow" + ? true + : disposition === "require-approval" + ? await runApprovalForCategory(gate, { + category: FUSION_TOOL_CATEGORY, + toolName: name, + dedupeKey: `claude-mcp|${name}|${JSON.stringify(parsed.arguments ?? {})}`, + args: + parsed.arguments && typeof parsed.arguments === "object" + ? (parsed.arguments as Record) + : {}, + }) === "allow" + : false; + if (!allowed) { + reject(403, "Fusion action policy denied this tool call"); + return; + } + try { + const result = await tool.execute(`claude-mcp-${randomUUID()}`, parsed.arguments ?? {}, undefined, undefined, undefined); + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + isError: false, + content: [{ type: "text", text: resultToText(result) }], + }), + ); + } catch (err) { + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + isError: true, + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + }), + ); + } + }); + + const address = await new Promise<{ port: number }>((resolve, reject) => { + server.once("error", reject); + // Bind loopback only — never expose Fusion tools on a public interface. + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("tool bridge failed to bind")); + return; + } + resolve({ port: addr.port }); + }); + }); + + const bridgeUrl = `http://127.0.0.1:${address.port}`; + const serverPath = fusionToolsMcpServerPath(); + + return { + toolCount: defs.length, + mcpServer: { + name: "fusion-custom-tools", + command: process.execPath, + args: [serverPath, schemaPath], + env: [ + { name: "FUSION_GROK_TOOL_BRIDGE_URL", value: bridgeUrl }, + { name: "FUSION_TOOL_BRIDGE_CAPABILITY", value: capabilityToken }, + ], + }, + dispose: async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + // FNXC:ClaudeAcp 2026-07-17-11:35: The schema is session-scoped and can + // contain tool descriptions. Remove it with the loopback server so repeated + // Claude sessions do not leave unbounded artifacts in the OS temp directory. + rmSync(schemaPath, { force: true }); + }, + }; +} diff --git a/plugins/fusion-plugin-claude-runtime/src/types.ts b/plugins/fusion-plugin-claude-runtime/src/types.ts new file mode 100644 index 0000000000..9a12d1544b --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/types.ts @@ -0,0 +1,142 @@ +/* +FNXC:ClaudeAcp 2026-07-11-12:00: +Claude runtime now drives xAI Claude Build TUI over ACP (`claude agent stdio`) instead +of one-shot `--output-format json`. Session state mirrors the chat/executor +contract (top-level `messages` + optional `state.errorMessage`) while the live +ACP connection lives on the composed AcpSession fields (`connection`, `dispose`). +*/ + +/** Narrow permission gate view (structural copy; no @fusion/engine import). */ +export type GateDisposition = "allow" | "block" | "require-approval"; + +export type ApprovalStatus = "pending" | "approved" | "denied" | "completed"; + +export interface PermissionGate { + permissionPolicy?: { + rules?: Record; + }; + createApprovalRequest?: ( + decision: unknown, + args: Record, + ) => Promise | unknown; + findApprovalByDedupeKey?: ( + dedupeKey: string, + ) => Promise<{ id: string; status: ApprovalStatus } | null> | { id: string; status: ApprovalStatus } | null; + pauseForApproval?: (info: { + approvalRequestId: string; + decision: unknown; + }) => Promise | void; + markApprovalCompleted?: (approvalRequestId: string) => Promise | void; +} + +export interface AcpMcpServer { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + +export interface ClaudeCallbacks { + /** Streams assistant text deltas from ACP `agent_message_chunk` updates. */ + onText?: (text: string) => void; + /** Streams reasoning from ACP `agent_thought_chunk` updates. */ + onThinking?: (text: string) => void; + /** ACP `tool_call` / start of a tool invocation. */ + onToolStart?: (toolName: string, args?: unknown) => void; + /** ACP `tool_call_update` terminal status. */ + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; +} + +export interface ClaudeSession { + model: string; + systemPrompt?: string; + messages: unknown[]; + state: { errorMessage?: string; messages: unknown[] }; + sessionId?: string; + lastModelDescription: string; + callbacks: ClaudeCallbacks; + /** Live ACP connection when createSession succeeded (composed AcpSession). */ + connection?: unknown; + resetTurn?: () => void; + dispose?: () => void; +} + +export type AgentSession = ClaudeSession; + +export interface AgentRuntimeOptions { + cwd?: string; + systemPrompt?: string; + tools?: "coding" | "readonly"; + defaultModelId?: string; + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; + signal?: AbortSignal; + actionGateContext?: PermissionGate; + mcpServers?: AcpMcpServer[] | unknown[]; + /** Engine-injected Fusion tools (fn_*) with in-process execute closures. */ + customTools?: unknown[]; + /** Convenience skill name list. */ + skills?: string[]; + /** Structured skill selection from session skill context. */ + skillSelection?: { requestedSkillNames?: string[] }; + /** Extra skill roots (plugin skills, CE install dirs). */ + additionalSkillPaths?: string[]; + /** Opaque ACP session/new._meta (pluginDirs / rules / systemPromptOverride). */ + sessionMeta?: Record; +} + +export interface AgentSessionResult { + session: AgentSession; + sessionFile?: string; +} + +export interface AgentPromptResult { + stopReason?: string; +} + +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; +} + +export interface ClaudeBinaryStatus { + available: boolean; + /** + * FNXC:ClaudeCli 2026-07-09-00:00: + * FN-7716: means "Claude CLI runtime ready" (the `claude` binary is available + * on PATH or at a configured path) — NOT "a Fusion-visible API key was + * found". The `claude` CLI owns its own authentication (env var, project + * `.env`, `claude -k`, etc.); Fusion no longer requires visibility into a + * key to treat the provider as authenticated. See `apiKeyDetected` for the + * non-blocking informational key-presence signal. + */ + authenticated?: boolean; + /** + * FNXC:ClaudeCli 2026-07-09-00:00: + * FN-7716: non-blocking informational hint only — true when Fusion itself + * detected a Claude API key (GROK_API_KEY env var or + * ~/.claude/user-settings.json `apiKey`). Never gates `authenticated` or + * enable/disable; the direct xAI OpenAI-compatible streaming path + * (FN-7711/FN-7714) uses $GROK_API_KEY when present regardless of this CLI + * probe. + */ + apiKeyDetected?: boolean; + binaryPath?: string; + binaryName?: string; + configuredBinaryPath?: string; + usingConfiguredBinaryPath?: boolean; + diagnostics?: string[]; + version?: string; + reason?: string; + probeDurationMs: number; +} diff --git a/plugins/fusion-plugin-claude-runtime/tsconfig.json b/plugins/fusion-plugin-claude-runtime/tsconfig.json new file mode 100644 index 0000000000..ac4dbc8f69 --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/plugins/fusion-plugin-claude-runtime/vitest.config.ts b/plugins/fusion-plugin-claude-runtime/vitest.config.ts new file mode 100644 index 0000000000..ccd5ae0ff0 --- /dev/null +++ b/plugins/fusion-plugin-claude-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"], + environment: "node", + 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, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd243d4fdf..1c51127b94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.80.10 version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + claude-code-cli-acp: + specifier: 0.1.1 + version: 0.1.1 dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -248,6 +251,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.80.10 version: 0.80.10(ws@8.20.0)(zod@3.25.76) + '@fusion-plugin-examples/claude-runtime': + specifier: workspace:* + version: link:../../plugins/fusion-plugin-claude-runtime '@fusion-plugin-examples/cli-printing-press': specifier: workspace:* version: link:../../plugins/fusion-plugin-cli-printing-press @@ -804,6 +810,37 @@ importers: specifier: ^4.1.0 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-claude-runtime: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.24.0 + version: 0.24.0(zod@4.3.6) + '@earendil-works/pi-ai': + specifier: 0.80.10 + version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.21.1)(zod@4.3.6) + '@earendil-works/pi-coding-agent': + specifier: 0.80.10 + version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.21.1)(zod@4.3.6) + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core + '@fusion/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + claude-code-cli-acp: + specifier: 0.1.1 + version: 0.1.1 + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.9.5 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-cli-printing-press: dependencies: '@fusion/core': @@ -9886,7 +9923,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 - '@types/node': 25.5.2 + '@types/node': 25.9.5 find-up: 4.1.0 fs-extra: 8.1.0 @@ -10356,13 +10393,13 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -10372,7 +10409,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/d3-array@3.2.2': {} @@ -10499,7 +10536,7 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.47': @@ -10516,7 +10553,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -10529,11 +10566,11 @@ snapshots: '@types/fs-extra@8.1.5': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/geojson@7946.0.16': {} @@ -10549,7 +10586,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/mdast@4.0.4': dependencies: @@ -10568,11 +10605,10 @@ snapshots: '@types/node@25.9.5': dependencies: undici-types: 7.24.6 - optional: true '@types/plist@3.0.5': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 xmlbuilder: 15.1.1 optional: true @@ -10598,28 +10634,28 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/responselike@1.0.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/retry@0.12.0': {} '@types/send@1.2.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/slice-ansi@4.0.0': {} '@types/ssh2@1.15.5': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 '@types/trusted-types@2.0.7': optional: true @@ -10642,7 +10678,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.9.5 optional: true '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': @@ -10885,6 +10921,14 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + '@vitest/mocker@4.1.8(vite@6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 @@ -14541,7 +14585,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.5.2 + '@types/node': 25.9.5 long: 5.3.2 proxy-addr@2.0.7: @@ -15084,7 +15128,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.5 simple-yenc@1.0.4: {} @@ -15535,8 +15579,7 @@ snapshots: undici-types@7.18.2: {} - undici-types@7.24.6: - optional: true + undici-types@7.24.6: {} undici@7.24.6: {} @@ -15690,6 +15733,21 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 + vite@6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.9.5 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.21.0 + yaml: 2.9.0 + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.8 @@ -15752,6 +15810,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.9.5 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + happy-dom: 20.10.1 + jsdom: 29.0.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} w3c-keyname@2.2.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ecbc056f74..ee0862cd1a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,7 @@ packages: - "plugins/fusion-plugin-acp-runtime" - "plugins/fusion-plugin-cursor-runtime" - "plugins/fusion-plugin-grok-runtime" + - "plugins/fusion-plugin-claude-runtime" - "plugins/fusion-plugin-omp-runtime" - "plugins/fusion-plugin-agent-browser" - "plugins/fusion-plugin-whatsapp-chat"