From 6267a762a490d0a2217044794ab2165a0a557e59 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 23:02:55 -0700 Subject: [PATCH] feat: drive Grok CLI sessions over ACP with tools, skills, and MCP Replace one-shot grok -p JSON with native grok agent stdio (ACP) for realtime streaming, tool visibility, and multi-turn sessions. Vendor the ACP client into fusion-plugin-grok-runtime, forward Fusion fn_* tools and operator MCP, stage Fusion skills via --plugin-dir, authenticate per xAI headless docs, and align project chat manager store resolution so Grok chat sessions can send. --- .changeset/grok-acp-transport.md | 7 + docs/grok-cli-contract.md | 219 +++--- packages/cli/tsup.config.ts | 3 + .../src/routes/register-chat-routes.ts | 15 +- .../__tests__/grok-runtime-routing.test.ts | 173 +++-- .../src/__tests__/authenticate.test.ts | 60 ++ .../src/cli-spawn.ts | 35 + .../fusion-plugin-acp-runtime/src/index.ts | 8 + .../fusion-plugin-acp-runtime/src/provider.ts | 100 ++- .../src/runtime-adapter.ts | 12 + .../fusion-plugin-acp-runtime/src/types.ts | 36 +- .../fusion-plugin-grok-runtime/CHANGELOG.md | 7 + plugins/fusion-plugin-grok-runtime/README.md | 43 +- .../fusion-plugin-grok-runtime/manifest.json | 4 +- .../fusion-plugin-grok-runtime/package.json | 8 +- .../src/__tests__/acp-settings.test.ts | 75 ++ .../src/__tests__/cli-stream.test.ts | 70 -- .../src/__tests__/mcp-forwarding.test.ts | 82 ++ .../src/__tests__/runtime-adapter.test.ts | 731 ++++++------------ .../src/__tests__/skill-loader.test.ts | 60 ++ .../src/__tests__/stream-parser.test.ts | 80 -- .../src/__tests__/tool-bridge.test.ts | 57 ++ .../src/acp-settings.ts | 161 ++++ .../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 | 163 ++++ .../src/acp/prompt-builder.ts | 50 ++ .../src/acp/provider.ts | 540 +++++++++++++ .../src/acp/runtime-adapter.ts | 183 +++++ .../src/acp/sanitize.ts | 81 ++ .../src/acp/tool-mapping.ts | 47 ++ .../src/acp/types.ts | 189 +++++ .../src/cli-stream.ts | 48 -- .../fusion-plugin-grok-runtime/src/index.ts | 33 +- .../src/mcp-forwarding.ts | 114 +++ .../src/mcp-schema-server.cjs | 151 ++++ .../src/runtime-adapter.ts | 684 ++++++++-------- .../src/skill-loader.ts | 218 ++++++ .../src/stream-parser.ts | 78 -- .../src/tool-bridge.ts | 178 +++++ .../fusion-plugin-grok-runtime/src/types.ts | 90 ++- pnpm-lock.yaml | 203 +---- 47 files changed, 4839 insertions(+), 1593 deletions(-) create mode 100644 .changeset/grok-acp-transport.md create mode 100644 plugins/fusion-plugin-acp-runtime/src/__tests__/authenticate.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/acp-settings.test.ts delete mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/mcp-forwarding.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts delete mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp-settings.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/VENDORED.md create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/cli-spawn.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/control-handler.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/event-bridge.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/fs-capabilities.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/index.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/path-jail.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/prompt-builder.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/provider.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/runtime-adapter.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/sanitize.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/tool-mapping.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/acp/types.ts delete mode 100644 plugins/fusion-plugin-grok-runtime/src/cli-stream.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/mcp-forwarding.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/mcp-schema-server.cjs create mode 100644 plugins/fusion-plugin-grok-runtime/src/skill-loader.ts delete mode 100644 plugins/fusion-plugin-grok-runtime/src/stream-parser.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts diff --git a/.changeset/grok-acp-transport.md b/.changeset/grok-acp-transport.md new file mode 100644 index 0000000000..d2a98acb99 --- /dev/null +++ b/.changeset/grok-acp-transport.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Drive Grok CLI sessions over ACP with Fusion tools, skills, and MCP loaded. +category: feature +dev: GrokRuntimeAdapter uses vendored ACP client under src/acp/ (not fusion-plugin-acp-runtime import), `grok agent stdio`, MCP/fn_* bridge, and Fusion skills via --plugin-dir / _meta.pluginDirs. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index 6cab531cd1..7050ae668a 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -1,10 +1,15 @@ -# Grok CLI Contract (FN-7790, updated by FN-7796) +# Grok CLI Contract (FN-7790 / FN-7796, updated for ACP transport) -Date: 2026-07-10 +Date: 2026-07-11 ## Ground truth @@ -14,14 +19,84 @@ Fusion shells out to an **operator-installed** `grok` binary. The binary is not External integration evidence: - Canonical upstream: xAI official Grok CLI / Grok Build TUI, surfaced by the installed binary as `grok 0.2.93 (f00f96316d4b)`. -- Docs/homepage: https://grok.com/, https://docs.x.ai/, and `grok --help` / `grok agent --help` for exact flags. +- Docs/homepage: https://grok.com/, https://docs.x.ai/, https://docs.x.ai/build/overview, and `grok --help` / `grok agent --help` / `grok agent stdio --help` for exact flags. +- ACP protocol: https://agentclientprotocol.com - Release/download: operator-installed; Fusion resolves `grok` from PATH or `grokCliBinaryPath` and does not bundle a release artifact. - Binary name: `grok`. - Checksum: `upstream-pending-verification` because Fusion does not pin or download the operator's binary. The previously documented https://github.com/superagent-ai/grok-cli contract is a different product that happens to use the same binary name. Its `grok --prompt --format json` invocation is not accepted by xAI's CLI. -## Failures that shaped the contract +## Agent session transport — ACP (`grok agent stdio`) + +Fusion's `GrokRuntimeAdapter` drives Grok as an ACP (Agent Client Protocol) agent over JSON-RPC/stdio, following [xAI Headless & Scripting](https://docs.x.ai/build/cli/headless-scripting#acp): + +```bash +# Official automation shape (docs.x.ai): suppress update checks in CI/scripts +grok --no-auto-update agent stdio +# with optional model + session skills plugin: +grok --no-auto-update agent --plugin-dir -m grok-4.5 stdio +``` + +ACP session lifecycle (official contract): + +1. `initialize` (protocolVersion 1) +2. **`authenticate`** — prefer `xai.api_key` when `XAI_API_KEY` is set and advertised, else `cached_token`, with `_meta: { headless: true }` +3. `session/new` (cwd, mcpServers, optional `_meta.pluginDirs` / rules) +4. `session/prompt` — completion metadata on the response; assistant text arrives as `session/update` `agent_message_chunk`s + +Auth: local `grok login` (cached token in `~/.grok/auth.json`) **or** `XAI_API_KEY`. Fusion forwards `XAI_API_KEY` on the spawn allow-list. + +Implementation uses a **vendored** ACP client under `plugins/fusion-plugin-grok-runtime/src/acp/` (copied from the ACP runtime plugin; not a package import) with Grok-specific settings: + +| Setting | Grok value | +| --- | --- | +| Binary | `grok` (or configured path) | +| Args | `["agent", "--plugin-dir", "", …, "stdio"]` (optional `-m `) | +| Env | Allow-list including `HOME`/`PATH`/`USER`/XDG + optional `XAI_API_KEY`/`GROK_API_KEY` (never full `process.env`) | +| `acpFsRead` / `acpFsWrite` | `false` (Grok has native tools; client-side fs stays off) | +| `acpAllowUnrestricted` | `true` (operator-selected first-party CLI; non-allow policy categories still gated) | +| `session/new.mcpServers` | Operator MCP servers (stdio/http/sse) + Fusion `fusion-custom-tools` bridge for `fn_*` | +| Skills | Session-scoped Grok plugin (`--plugin-dir` + `_meta.pluginDirs`) with bundled Fusion skill + `additionalSkillPaths` | + +### Fusion tools and skills + + + +1. **Operator MCP** — `options.mcpServers` is reshaped to ACP wire format and forwarded on `session/new`. +2. **Fusion custom tools (`fn_*`)** — engine `customTools` are hosted by a loopback HTTP bridge + stdio MCP server (`mcp-schema-server.cjs`) named `fusion-custom-tools`. Grok invokes tools via real MCP `tools/call`; the bridge runs `ToolDefinition.execute` in-process. +3. **Skills** — the bundled Fusion skill (`packages/cli/skill/fusion`) plus any `additionalSkillPaths` skill roots are staged into a temp plugin directory and loaded via `grok agent --plugin-dir` and `_meta.pluginDirs`. Requested skill names and tool counts are also written into `_meta.rules` / system prompt context. + +### Session lifecycle + +1. `createSession` — spawn `grok agent stdio`, ACP `initialize`, `session/new` over the task cwd. +2. `promptWithFallback` — ACP `session/prompt`; stream `session/update` notifications until terminal `stopReason`. +3. `dispose` — best-effort `session/cancel` + process-registry SIGKILL (authoritative no-orphan guarantee). + +### Streamed update mapping + +| ACP `sessionUpdate` | Fusion callback | +| --- | --- | +| `agent_message_chunk` | `onText` | +| `agent_thought_chunk` | `onThinking` | +| `tool_call` | `onToolStart` | +| `tool_call_update` (terminal) | `onToolEnd` | + +Multi-turn conversations reuse the same ACP session/connection (no cold spawn per prompt). + +### Auth + +Grok owns authentication. Preferred path is a cached session in `~/.grok/auth.json` (requires `HOME` in the allow-list). Optional key-based auth uses `XAI_API_KEY` or `GROK_API_KEY` when no cached token is present. The readiness probe (`grok --version`) proves only binary presence, not authenticated ACP readiness. + +### Permissions + +Tool calls from the Grok agent surface as ACP `session/request_permission` and route through Fusion's per-category action gate (same floor as the generic ACP runtime). Unrestricted policy + `acpAllowUnrestricted` auto-allows sensitive categories for autonomous executor turns; `require-approval` / `block` still apply when configured. + +## Failures that shaped the prior headless contract (historical) ### Wrong-product flags (FN-7790) @@ -31,120 +106,19 @@ The old adapter invocation fails against the real xAI binary: grok --prompt "say hello" --format json ``` -Observed result: - -```text -exit 2 -stdout: -stderr: -error: unexpected argument '--prompt' found - - tip: a similar argument exists: '--prompt-file' - -Usage: grok --prompt-file [PROMPT] -``` - -Because no renderable assistant text is produced, Fusion surfaced a blank/no-message assistant response. - ### Streaming JSON cancellation with zero text (FN-7796) -FN-7790 correctly switched to xAI's real flags and streaming event union, but live triage found `--output-format streaming-json` is intermittently unreliable. The same authenticated `grok 0.2.93` binary sometimes emits only reasoning events, then ends with `stopReason:"Cancelled"` and no `text` event while still exiting 0 with empty stderr. +`--output-format streaming-json` intermittently ended `stopReason:"Cancelled"` with zero `text` events. That motivated the temporary switch to single-object `--output-format json`. ACP replaces both headless modes for agent sessions because it streams reliably over JSON-RPC and carries tool/permission structure. -Live-captured shape: +## Probe and model discovery (unchanged) -```jsonl -{"type":"thought","data":"..."} -{"type":"thought","data":"..."} -{"type":"end","stopReason":"Cancelled","sessionId":"...","requestId":"..."} -``` - -The adapter previously saw parsed events and a successful close, accumulated empty assistant text, set no error, and produced a silent no-message bubble. The reliable replacement is the single-object JSON contract below. - -## Confirmed non-interactive invocation used by Fusion - -Use xAI Grok Build TUI's single-turn prompt mode with **single-object JSON**: +### Version probe ```bash -grok -p "" --output-format json -# equivalent long prompt flag: -grok --single "" --output-format json +grok --version ``` -Supported companion flags used by Fusion: - -- `-p, --single ` — run a single prompt, print the response, and exit. This does not require interactive stdin. -- `--output-format ` — Fusion uses `json` for reliable headless prompts. -- `-m, --model ` — optional concrete model id. Fusion omits this for the model-less `grok/default` Runtime-mode path. -- `--cwd ` — optional working directory. This replaces the wrong-product `--directory` flag. - -Other observed flags include `--prompt-file `, `--prompt-json `, `-s/--session-id `, `--sandbox `, `--system-prompt-override `, and `--max-turns `, but Fusion's adapter does not currently use them. - -## Reliable JSON response schema - -`--output-format json` emits one final JSON object rather than an NDJSON stream. Observed shape: - -```ts -interface GrokJsonResponse { - text?: string; - stopReason?: string; - sessionId?: string; - requestId?: string; - thought?: string; -} -``` - -Example: - -```json -{ - "text": "Hello", - "stopReason": "EndTurn", - "sessionId": "019f4d81-8fb1-7f11-98ca-5ae00654b518", - "requestId": "bb7952e2-f1bc-4574-b409-5cc568817fe5", - "thought": "The user wants me to say hello in one word..." -} -``` - -Mapping in Fusion: - -- `thought` → `onThinking(thought)` when non-empty. -- `text` → `onText(text)` and accumulated assistant content when non-empty. -- `sessionId` → `session.sessionId` when present. -- subprocess `close` remains the authoritative promise resolution point because it carries exit status/stderr diagnostics. - -Live reliability evidence from FN-7796: `grok -p "say hello in one word" --output-format json` returned real text with `stopReason:"EndTurn"` on 4/4 direct runs, and the built `GrokRuntimeAdapter` carried real text through `onText`/persisted assistant content on 3/3 end-to-end runs against the real binary. - -## Streaming JSON event schema (not the primary prompt path) - -`--output-format streaming-json` emits one JSON object per line: - -```ts -type GrokStreamingJsonEvent = - | { type: "thought"; data: string } - | { type: "text"; data: string } - | { type: "end"; stopReason?: string; sessionId?: string; requestId?: string }; -``` - -Successful captured tail: - -```jsonl -{"type":"thought","data":" one"} -{"type":"thought","data":"-"} -{"type":"thought","data":"word"} -{"type":"thought","data":" greeting"} -{"type":"thought","data":"."} -{"type":"text","data":"Hello"} -{"type":"text","data":"!"} -{"type":"end","stopReason":"EndTurn","sessionId":"019f4d1e-2582-70e0-a174-c8774782ab01","requestId":"2233f1dc-e9ad-4ae4-8221-caa6afade07f"} -``` - -Fusion does not use streaming-json as the primary headless prompt path because it intermittently produces the cancelled/no-text shape documented above. Parser support remains only to keep diagnostics and regression tests concrete if captured streaming output appears in buffered stdout. - -## Other output formats - -`--output-format plain` prints renderable response text, but does not expose `sessionId`, `requestId`, `stopReason`, or `thought`. - -## Model discovery +### Model discovery `grok models` is plain text, not JSON. Observed shape: @@ -160,12 +134,6 @@ Available models: Fusion parses the bullet list conservatively and exposes ids under provider `grok-cli` when the `useGrokCli` toggle is enabled. -## Auth and readiness - -The CLI owns authentication for CLI-routed execution. Fusion's readiness probe uses `grok --version`; a passing probe proves only that a compatible-looking binary exists, not that the prompt path is authenticated or serviceable. The prompt path is proven by a real `grok -p ... --output-format json` run. - -Fusion-visible `GROK_API_KEY` remains relevant for the direct xAI OpenAI-compatible endpoint. For CLI-routed sessions, Fusion does not need to see a key as long as the operator-installed CLI is authenticated by its own supported mechanism. - ## Runtime routing The Grok runtime adapter is reached when: @@ -173,17 +141,16 @@ The Grok runtime adapter is reached when: 1. an agent explicitly sets `runtimeConfig.runtimeHint === "grok"`; or 2. the FN-7753/FN-7758 no-visible-key fallback derives the same runtime hint for a `grok-cli/*` default/fallback provider selection and the bundled Grok Runtime plugin is registered. -The selected `grok-cli/` or `grok/` model is normalized to `` and passed to the CLI as `-m `. The explicit no-model Runtime-mode path keeps `grok/default` and omits `-m`. +The selected `grok-cli/` or `grok/` model is normalized to `` and passed as `grok agent -m stdio`. The explicit no-model Runtime-mode path keeps `grok/default` and omits `-m`. ## Diagnostics and empty-output invariant The adapter preserves the resolve-never-reject runtime contract while surfacing concrete diagnostics: -- spawn failure → `session.state.errorMessage` and diagnostic `onText`. -- non-zero subprocess close with no text → stderr/exit diagnostic. -- code-0 close with no parseable JSON response → wrong-binary/interactive-EOF diagnostic. -- parseable response with no text and `stopReason !== "EndTurn"` → stop-reason diagnostic, e.g. `Grok CLI ended with stopReason Cancelled and produced no assistant text.` -- parseable `EndTurn` response with no assistant text → legitimate silent response, not a diagnostic. -- text emitted before a noisy/non-zero close → keep the assistant text and avoid replacing it with an error. +- ACP create/handshake failure → dead session + diagnostic `onText` (create does not throw to the engine). +- ACP prompt failure → diagnostic `onText` when no assistant text streamed; never reject. +- Abnormal `stopReason` (not `end_turn`) with zero text → stop-reason diagnostic. +- Clean `end_turn` with no assistant text → legitimate silent response, not a diagnostic. +- Partial text before a failed close → keep the assistant text; do not replace it with an error. -This invariant prevents the original blank/no-message symptom while still allowing genuinely empty model turns. +This invariant prevents blank/no-message assistant bubbles while still allowing genuinely empty model turns. diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 214420832d..d52d18aec2 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -11,6 +11,9 @@ export { ALL_STAGED_BUNDLED_IDS }; const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([ "fusion-plugin-openclaw-runtime", "fusion-plugin-droid-runtime", + // 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", ]); const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 50f6defbc6..0f9903e8e8 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -113,12 +113,21 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): if (!options?.chatManager) throw new ApiError(503, "Chat manager not available"); return options.chatManager; } - const projectStore = await getOrCreateProjectStore(projectId); - const chatStore = getOrCreateScopedChatStore(projectStore); + /* + FNXC:GrokAcp 2026-07-11-17:00: + Chat list/create use resolveProjectChatContext, which falls back to the host + default store when no engine is running for the project (nested dashboard / + lockfile-blocked engines). ChatManager must use that same store/chatStore + pair — getOrCreateProjectStore alone pointed at a different fusion dir, so + sessions visible in the UI 404'd on sendMessage ("Chat session not found"). + Prefer the engine plugin runner when available; otherwise the host runner + (e.g. Grok ACP 0.2) so CLI runtimes still resolve. + */ + const { store: scopedStore, chatStore } = await resolveScopedChatStore(projectId); const engine = options?.engineManager?.getEngine(projectId); const projectPluginRunner = engine?.getPluginRunner?.(); const pluginRunner = projectPluginRunner ?? options?.pluginRunner; - return getOrCreateScopedChatManager(projectStore, chatStore, pluginRunner, Boolean(projectPluginRunner)); + return getOrCreateScopedChatManager(scopedStore, chatStore, pluginRunner, Boolean(projectPluginRunner)); } const THINKING_LEVEL_SET = new Set(THINKING_LEVELS); diff --git a/packages/engine/src/__tests__/grok-runtime-routing.test.ts b/packages/engine/src/__tests__/grok-runtime-routing.test.ts index fb173cdd3a..1163a3b2b0 100644 --- a/packages/engine/src/__tests__/grok-runtime-routing.test.ts +++ b/packages/engine/src/__tests__/grok-runtime-routing.test.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRunner } from "../plugin-runner.js"; @@ -17,12 +15,13 @@ Source -> Runtime picker) must resolve the REAL GrokRuntimeAdapter (FN-7722, imported unmodified from the plugin package, not re-implemented/mocked here) through the generic extractRuntimeHint -> resolveRuntime -> resolvePluginRuntime -> plugin factory chain, and driving a prompt through -that resolved session must invoke onText from faked NDJSON `text` lines. -Uses the adapter's own injectable `spawn` seam (runtime-adapter.ts) — no -live `grok` binary, no real subprocess, no real network. Also asserts the -Surface Enumeration invariant: trigger OFF / unset / non-grok hints still -fall back to the default pi runtime unchanged, and an empty/undefined -runtimeConfig does not crash. +that resolved session must invoke onText from streamed ACP updates. + +FNXC:GrokAcp 2026-07-11-12:00: +Prompt transport is now ACP (`grok agent stdio`). Tests inject a fake +AcpRuntimeAdapter via `createAcpAdapter` — no live `grok` binary, no real +subprocess, no real network. Surface Enumeration: trigger OFF / unset / +non-grok hints still fall back to the default pi runtime unchanged. */ const mockCreateFnAgent = vi.hoisted(() => vi.fn()); @@ -47,9 +46,25 @@ function grokRuntimeAdapterModulePath(): string { ); } +type FakeAcpAdapter = { + createSession: (options: { + onText?: (t: string) => void; + onThinking?: (t: string) => void; + defaultModelId?: string; + systemPrompt?: string; + }) => Promise<{ session: Record }>; + promptWithFallback: ( + session: Record, + prompt: string, + options?: unknown, + ) => Promise; + describeModel: (session: unknown) => string; + dispose?: (session: unknown) => Promise; +}; + type GrokRuntimeAdapterCtor = new (options?: { binary?: string; - spawn?: (binary: string, prompt: string, options?: { cwd?: string; model?: string; signal?: AbortSignal }) => unknown; + createAcpAdapter?: (settings: Record) => FakeAcpAdapter; }) => { id: string; name: string; @@ -65,14 +80,53 @@ async function loadGrokRuntimeAdapter(): Promise { return mod.GrokRuntimeAdapter; } -/** Fake `GrokStreamProcess`: an EventEmitter + a writable PassThrough stdout, matching - * the shape runtime-adapter.ts's own fixture tests use (no live subprocess). */ -function makeFakeGrokProcess(): { proc: unknown; stdout: PassThrough; kill: ReturnType } { - const stdout = new PassThrough(); - const emitter = new EventEmitter(); - const kill = vi.fn(); - const proc = Object.assign(emitter, { stdout, kill }); - return { proc, stdout, kill }; +/** + * Fake ACP adapter that simulates streamed session/update callbacks without a + * live `grok agent stdio` process. `promptBehavior` controls the turn outcome. + */ +function makeFakeAcpFactory(options?: { + promptBehavior?: "text" | "empty-json" | "throw"; + settingsOut?: Record[]; +}): (settings: Record) => FakeAcpAdapter { + const promptBehavior = options?.promptBehavior ?? "text"; + const settingsOut = options?.settingsOut; + return (settings) => { + settingsOut?.push(settings); + let capturedOnText: ((t: string) => void) | undefined; + const sessionShell: Record = { + model: String(settings.acpModel ?? "grok/default"), + messages: [], + state: { messages: [] }, + lastModelDescription: `acp/${settings.acpModel ?? "default"}`, + callbacks: {}, + connection: { id: "conn-1" }, + sessionId: "acp-session-1", + dispose: vi.fn(), + }; + return { + createSession: async (opts) => { + capturedOnText = opts.onText; + sessionShell.callbacks = { + onText: opts.onText, + onThinking: opts.onThinking, + }; + sessionShell.systemPrompt = opts.systemPrompt; + return { session: sessionShell }; + }, + promptWithFallback: async () => { + if (promptBehavior === "throw") { + throw new Error("ACP bridge hung up"); + } + if (promptBehavior === "empty-json") { + return { stopReason: "end_turn" }; + } + capturedOnText?.("hi there"); + return { stopReason: "end_turn" }; + }, + describeModel: (session) => `acp/${(session as { model?: string }).model ?? "default"}`, + dispose: async () => undefined, + }; + }; } function createMockPluginRunner(overrides: Partial = {}): PluginRunner { @@ -91,7 +145,7 @@ function createMockPluginRunner(overrides: Partial = {}): PluginRu } async function createGrokRegistration( - spawnFn: ReturnType, + createAcpAdapter: (settings: Record) => FakeAcpAdapter = makeFakeAcpFactory(), ): Promise<{ pluginId: string; runtime: PluginRuntimeRegistration }> { const GrokRuntimeAdapter = await loadGrokRuntimeAdapter(); return { @@ -100,10 +154,10 @@ async function createGrokRegistration( metadata: { runtimeId: "grok", name: "Grok Runtime", - description: "Grok CLI runtime support for Fusion", - version: "0.1.0", + description: "Grok CLI runtime support for Fusion (ACP)", + version: "0.2.0", }, - factory: vi.fn().mockImplementation(async () => new GrokRuntimeAdapter({ spawn: spawnFn })), + factory: vi.fn().mockImplementation(async () => new GrokRuntimeAdapter({ createAcpAdapter })), }, }; } @@ -123,8 +177,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { }); it("resolves the real GrokRuntimeAdapter via resolveRuntime when runtimeHint is 'grok'", async () => { - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -142,10 +195,11 @@ describe("Grok CLI runtime routing (FN-7725)", () => { expect(pluginRunner.getRuntimeById).toHaveBeenCalledWith("grok"); }); - it("createResolvedAgentSession routes an agent's runtimeConfig.runtimeHint through to GrokRuntimeAdapter and streams onText from faked NDJSON", async () => { - const { proc, stdout } = makeFakeGrokProcess(); - const spawn = vi.fn().mockReturnValue(proc); - const grokRegistration = await createGrokRegistration(spawn); + it("createResolvedAgentSession routes an agent's runtimeConfig.runtimeHint through to GrokRuntimeAdapter and streams onText from faked ACP updates", async () => { + const settingsOut: Record[] = []; + const grokRegistration = await createGrokRegistration( + makeFakeAcpFactory({ promptBehavior: "text", settingsOut }), + ); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -169,29 +223,27 @@ describe("Grok CLI runtime routing (FN-7725)", () => { expect(result.runtimeId).toBe("grok"); expect(result.wasConfigured).toBe(true); expect(mockCreateFnAgent).not.toHaveBeenCalled(); + // FNXC:GrokAcp 2026-07-11-14:00 / 15:00: ACP args include --no-auto-update + // (official headless scripting docs), session-scoped --plugin-dir for Fusion + // skills, then stdio (optional -m when a model is set). + const acpArgs = settingsOut[0]?.acpArgs as string[]; + expect(acpArgs).toContain("--no-auto-update"); + expect(acpArgs).toContain("agent"); + expect(acpArgs).toContain("--plugin-dir"); + expect(acpArgs.at(-1)).toBe("stdio"); // Drive the resolved session's promptWithFallback (attached by - // createResolvedAgentSession) and feed a faked single-JSON `grok - // --output-format json` response through the adapter's injected fake stdout - // — no live grok binary involved. FNXC:GrokCli 2026-07-10-12:52: FN-7796 - // replaced the streaming-NDJSON contract with a single JSON object parsed - // once on subprocess close; onText now fires once with the full `text`. + // createResolvedAgentSession). Fake ACP fires onText with streamed text. const session = result.session as { promptWithFallback: (prompt: string) => Promise }; - const promptPromise = session.promptWithFallback("hello grok"); + await session.promptWithFallback("hello grok"); - stdout.write(`${JSON.stringify({ text: "hi there", stopReason: "EndTurn" })}\n`); - (proc as EventEmitter).emit("close", 0, null); - - await promptPromise; - - expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({})); expect(onText.mock.calls.map((c) => c[0])).toEqual(["hi there"]); }); - it("surfaces code-0 zero-NDJSON Grok exits through the shared runtime session seam", async () => { - const { proc, stdout } = makeFakeGrokProcess(); - const spawn = vi.fn().mockReturnValue(proc); - const grokRegistration = await createGrokRegistration(spawn); + it("surfaces ACP prompt failures through the shared runtime session seam without rejecting", async () => { + const grokRegistration = await createGrokRegistration( + makeFakeAcpFactory({ promptBehavior: "throw" }), + ); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -208,16 +260,12 @@ describe("Grok CLI runtime routing (FN-7725)", () => { state?: { errorMessage?: string }; promptWithFallback: (prompt: string) => Promise; }; - const promptPromise = session.promptWithFallback("hello grok"); - stdout.end(); - (proc as EventEmitter).emit("close", 0, null); + await expect(session.promptWithFallback("hello grok")).resolves.toBeUndefined(); - await promptPromise; - - // FNXC:GrokCli 2026-07-10-12:52: FN-7796 renamed the zero-output diagnostic - // from "no NDJSON output" to "no JSON output" (single-object json contract). - expect(session.state?.errorMessage).toContain("Grok CLI produced no JSON output"); - expect(onText).toHaveBeenCalledWith(expect.stringContaining("Grok CLI produced no JSON output")); + // FNXC:GrokAcp 2026-07-11-12:00: ACP path resolves-never-rejects and surfaces + // turn failures as diagnosable onText (same empty-bubble invariant as FN-7779). + expect(session.state?.errorMessage).toContain("Grok ACP turn failed"); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("Grok ACP turn failed")); }); it("falls back to the default pi runtime when the Grok plugin runtime is not registered", async () => { @@ -263,8 +311,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("auto-routes a grok-cli model selection to the Grok runtime when no Fusion-visible key exists", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -302,8 +349,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("keeps grok-cli on the direct pi runtime when a Fusion-visible key exists", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(true); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -349,8 +395,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("auto-routes heartbeat/room responder grok-cli defaults to the Grok runtime when no Fusion-visible key exists", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -382,8 +427,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("auto-routes a grok-cli fallback model to the Grok runtime when no Fusion-visible key exists", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -419,8 +463,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("auto-routes a bare grok-cli fallback model id without adding a provider prefix", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -442,8 +485,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("keeps mock/test-mode provider routing on the mock runtime when grok-cli fallback is configured", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(grokRegistration), }); @@ -467,8 +509,7 @@ describe("Grok CLI runtime routing (FN-7725)", () => { it("honors explicit runtime hints over the no-key grok-cli auto-derivation", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); - const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); - const grokRegistration = await createGrokRegistration(spawn); + const grokRegistration = await createGrokRegistration(); const getRuntimeById = vi.fn((runtimeId: string) => runtimeId === "grok" ? grokRegistration : undefined); const pluginRunner = createMockPluginRunner({ getRuntimeById }); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/authenticate.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/authenticate.test.ts new file mode 100644 index 0000000000..cb7f970deb --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/authenticate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { AcpAuthRequiredError, authenticateAcpConnection } from "../provider.js"; + +describe("authenticateAcpConnection", () => { + it("selects the first preferred method the agent advertised", async () => { + const authenticate = vi.fn().mockResolvedValue({}); + const result = await authenticateAcpConnection( + { + conn: { authenticate } as never, + authMethods: [{ id: "cached_token" }, { id: "grok.com" }], + }, + { + preferMethods: ["xai.api_key", "cached_token"], + meta: { headless: true }, + }, + ); + expect(result).toEqual({ methodId: "cached_token" }); + expect(authenticate).toHaveBeenCalledWith({ + methodId: "cached_token", + _meta: { headless: true }, + }); + }); + + it("prefers xai.api_key when advertised", async () => { + const authenticate = vi.fn().mockResolvedValue({}); + const result = await authenticateAcpConnection( + { + conn: { authenticate } as never, + authMethods: [{ id: "xai.api_key" }, { id: "cached_token" }], + }, + { preferMethods: ["xai.api_key", "cached_token"] }, + ); + expect(result).toEqual({ methodId: "xai.api_key" }); + }); + + it("no-ops when no method matches and require is false", async () => { + const authenticate = vi.fn(); + const result = await authenticateAcpConnection( + { + conn: { authenticate } as never, + authMethods: [{ id: "grok.com" }], + }, + { preferMethods: ["xai.api_key", "cached_token"], require: false }, + ); + expect(result).toBeUndefined(); + expect(authenticate).not.toHaveBeenCalled(); + }); + + it("throws AcpAuthRequiredError when require is true and no method matches", async () => { + await expect( + authenticateAcpConnection( + { + conn: { authenticate: vi.fn() } as never, + authMethods: [{ id: "grok.com" }], + }, + { preferMethods: ["cached_token"], require: true }, + ), + ).rejects.toBeInstanceOf(AcpAuthRequiredError); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts index 79d4372ef5..25b5f029bf 100644 --- a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts @@ -50,6 +50,17 @@ export interface AcpCliSettings { allowUnrestricted: boolean; /** Bundled bridge resolution status when `acpBinaryPath` asks for it. */ binaryResolution?: AcpBinaryResolution; + /** + * FNXC:GrokAcp 2026-07-11-15:00: + * When set, call ACP authenticate after initialize (Grok 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 { @@ -125,6 +136,7 @@ export function resolveCliSettings(settings?: Record): AcpCliSe const fsWrite = asBool(settings?.acpFsWrite); const envAllowList = asStringArray(settings?.acpEnvAllowList) ?? []; const allowUnrestricted = asBool(settings?.acpAllowUnrestricted); + const authenticate = asAuthenticateSettings(settings?.acpAuthenticate); return { binaryPath, args, @@ -135,6 +147,29 @@ export function resolveCliSettings(settings?: Record): AcpCliSe 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, }; } diff --git a/plugins/fusion-plugin-acp-runtime/src/index.ts b/plugins/fusion-plugin-acp-runtime/src/index.ts index e79ff8a5f5..551c7c016f 100644 --- a/plugins/fusion-plugin-acp-runtime/src/index.ts +++ b/plugins/fusion-plugin-acp-runtime/src/index.ts @@ -83,6 +83,7 @@ const plugin: FusionPlugin = definePlugin({ export default plugin; export { AcpRuntimeAdapter }; +export { authenticateAcpConnection, AcpAuthRequiredError } from "./provider.js"; export { checkSetup, setupHooks, setupManifest, validateBundledBridgeIdentity } from "./setup.js"; export { CLAUDE_CODE_CLI_ACP_BINARY, @@ -92,3 +93,10 @@ export { resolveCliSettings, } from "./cli-spawn.js"; export type { AcpBinaryResolution, AcpCliSettings } from "./cli-spawn.js"; +/* +FNXC:GrokAcp 2026-07-11-12:00: +Grok Runtime composes AcpRuntimeAdapter for native `grok agent stdio`. Re-export +the process-registry kill so Grok can register the same exit reaper without a +second subprocess registry or a fragile cross-plugin relative import. +*/ +export { killAllProcesses } from "./process-manager.js"; diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts index 56ebe24fea..793d7120a0 100644 --- a/plugins/fusion-plugin-acp-runtime/src/provider.ts +++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts @@ -216,6 +216,19 @@ export interface ConnectOptions { /** Advertise fs capabilities ONLY where the toggle is true (KTD6). */ advertiseFs: { read: boolean; write: boolean }; initializeTimeoutMs?: number; + /** + * FNXC:GrokAcp 2026-07-11-15:00: + * Optional post-initialize authenticate (xAI Grok 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 { @@ -318,6 +331,25 @@ export async function connect(opts: ConnectOptions): Promise { ? initResult.authMethods.map((m) => ({ id: m.id })) : []; + /* + FNXC:GrokAcp 2026-07-11-15:00: + Official Grok 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, @@ -328,6 +360,59 @@ export async function connect(opts: ConnectOptions): Promise { }; } +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 @@ -353,11 +438,22 @@ export interface NewAcpSessionResult { */ export async function newAcpSession( connection: AcpConnection, - opts: { cwd: string; mcpServers?: AcpMcpServer[] }, + opts: { + cwd: string; + mcpServers?: AcpMcpServer[]; + /** + * FNXC:GrokAcp 2026-07-11-14:00: + * Optional ACP `_meta` bag for agent-specific session setup (Grok 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 ?? [], + 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 diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts index 92753e391d..3070793191 100644 --- a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -73,6 +73,7 @@ export class AcpRuntimeAdapter implements AgentRuntime { // 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 (Grok headless ACP: initialize → authenticate → session/new). const connection = await connect({ binaryPath: this.settings.binaryPath, args: this.settings.args, @@ -80,16 +81,27 @@ export class AcpRuntimeAdapter implements AgentRuntime { 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:GrokAcp 2026-07-11-14:00: + // Callers (Grok 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) { diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts index cb623a49d1..c7dd7a2c15 100644 --- a/plugins/fusion-plugin-acp-runtime/src/types.ts +++ b/plugins/fusion-plugin-acp-runtime/src/types.ts @@ -21,18 +21,38 @@ export interface AcpCallbacks { } /** - * A stdio MCP server forwarded to the agent on `session/new` (U10 — Route A). - * `env` is explicit name/value pairs; inherited `process.env` is NEVER forwarded - * to the untrusted agent. Maps 1:1 onto an ACP `mcpServers` entry and onto what - * `pi-claude-cli`'s `mcp-config.ts` builds for `--mcp-config`. + * 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:GrokAcp 2026-07-11-14:00: + * Widen beyond stdio so Grok ACP can receive Fusion operator MCP servers over + * http/sse (Grok advertises mcpCapabilities.http/sse) as well as the classic + * stdio custom-tools bridge used by Route A. */ -export interface AcpMcpServer { +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"; @@ -111,6 +131,12 @@ export interface AgentRuntimeOptions { * U5 permission floor). Absent/empty preserves Route B's read-only ask posture. */ mcpServers?: AcpMcpServer[]; + /** + * FNXC:GrokAcp 2026-07-11-14:00: + * Opaque ACP `session/new._meta` for agent-specific setup (Grok pluginDirs / + * rules / systemPromptOverride). Ignored by agents that do not read `_meta`. + */ + sessionMeta?: Record; } /** Live ACP session state tracked by the runtime adapter. */ diff --git a/plugins/fusion-plugin-grok-runtime/CHANGELOG.md b/plugins/fusion-plugin-grok-runtime/CHANGELOG.md index 3ef6c3bc14..93866ff928 100644 --- a/plugins/fusion-plugin-grok-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-grok-runtime/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/grok-runtime +## 0.2.0 + +### Minor Changes + +- Drive agent sessions over native ACP (`grok agent stdio`) for realtime streaming, tool visibility, multi-turn session reuse, and Fusion permission-gate integration. Vendors the ACP client under `src/acp/` (no runtime dependency on `fusion-plugin-acp-runtime`). Probe (`grok --version`) and model discovery (`grok models`) unchanged. Retires one-shot `grok -p --output-format json` as the primary prompt transport. +- Load Fusion tools and skills into ACP sessions: operator MCP servers + executable `fusion-custom-tools` bridge for engine `fn_*` customTools; session-scoped `--plugin-dir` / `_meta.pluginDirs` with the bundled Fusion skill and `additionalSkillPaths`. + ## 0.1.0 ### Minor Changes diff --git a/plugins/fusion-plugin-grok-runtime/README.md b/plugins/fusion-plugin-grok-runtime/README.md index d3d9278f23..f3450ea5c9 100644 --- a/plugins/fusion-plugin-grok-runtime/README.md +++ b/plugins/fusion-plugin-grok-runtime/README.md @@ -1,6 +1,6 @@ # fusion-plugin-grok-runtime -Grok CLI-backed provider/runtime plugin for Fusion. +Grok CLI-backed provider/runtime plugin for Fusion. Agent sessions use **native ACP** (`grok agent stdio`) for realtime streaming, tool visibility, and multi-turn reuse. ## Install @@ -9,7 +9,8 @@ built-in runtime plugins. It shells out to an **operator-installed** `grok` binary on PATH — Fusion never downloads or bundles the CLI itself. - Canonical upstream: xAI official Grok CLI / Grok Build TUI (`grok --version` observed as `grok 0.2.93 (f00f96316d4b)`). -- Docs / homepage: https://grok.com/, https://docs.x.ai/, and `grok --help` / `grok agent --help` for exact flags. +- Docs / homepage: https://grok.com/, https://docs.x.ai/, https://docs.x.ai/build/overview, and `grok --help` / `grok agent stdio --help`. +- ACP protocol: https://agentclientprotocol.com - Release / download: operator-installed; Fusion resolves `grok` from PATH or `grokCliBinaryPath`. - Binary name: `grok`. - Checksum: `upstream-pending-verification` because Fusion does not download or pin the operator's binary. @@ -23,31 +24,27 @@ The previously assumed `superagent-ai/grok-cli` contract is a different product - **Auth model — the `grok` CLI owns its own authentication; Fusion does not require a Fusion-visible API key to enable/use it (FN-7716).** Fusion additionally probes the `GROK_API_KEY` env var and `~/.grok/user-settings.json` → `{ "apiKey": "..." }` purely as a **non-blocking informational hint** (`apiKeyDetected`); it never gates Enable or the authenticated state. The direct xAI OpenAI-compatible streaming path (base URL `https://api.x.ai/v1`) still uses `$GROK_API_KEY` when present, independent of the CLI provider. - Model discovery: `grok models` (plain text). The observed xAI shape is `Default model: `, then `Available models:`, then `* (default)` / `- ` bullet rows. -## CLI headless execution path (FN-7790 / FN-7796) +## Agent session path — ACP (primary) -The plugin's `GrokRuntimeAdapter` returns a real Grok response through xAI's reliable single-object CLI output: +`GrokRuntimeAdapter` drives xAI's native ACP server with a **vendored** ACP client +(copied under `src/acp/`, not imported from `fusion-plugin-acp-runtime`): ```bash -grok -p "" --output-format json -# with optional model/cwd: -grok -p "" --output-format json -m "grok-4.5" --cwd "/path/to/project" +grok agent stdio +# optional model: +grok agent -m grok-4.5 stdio ``` -- `-p, --single ` runs a single prompt and exits; it does not require interactive stdin. -- `--output-format json` emits one object with `{ text, stopReason, sessionId, requestId, thought }`. Fusion buffers stdout until subprocess `close`, then bridges `thought` to `onThinking`, `text` to `onText` and persisted assistant content, and stores `sessionId` when present. -- xAI's `--output-format streaming-json` mode is not used for the primary headless path because live `grok 0.2.93` testing found it can intermittently end `stopReason:"Cancelled"` with zero `text` events. A non-`EndTurn` stop reason with empty text now surfaces a concrete diagnostic instead of a blank assistant response; a parseable `EndTurn` with empty text remains a legitimate silent response. -- A wrong-binary/wrong-flag run that emits no parseable JSON surfaces a concrete diagnostic instead of a blank assistant response. -- **Auth implication:** because the `grok` binary resolves its own credentials for this path, a CLI-routed selection needs **no Fusion-visible `GROK_API_KEY`** — unlike the direct xAI OpenAI-compatible streaming path. +- **Realtime streaming** — ACP `session/update` notifications map to Fusion `onText` / `onThinking` / `onToolStart` / `onToolEnd` as chunks arrive (not buffered until process exit). +- **Multi-turn** — one `createSession` keeps the ACP connection; each `promptWithFallback` is a `session/prompt` on the same session. +- **Permissions** — Grok tool calls surface as `session/request_permission` and go through Fusion's per-category action gate. +- **Fusion tools (`fn_*`)** — engine `customTools` are exposed to Grok as MCP server `fusion-custom-tools` (executable bridge, not schema-only). +- **Operator MCP** — configured Fusion MCP servers (stdio/http/sse) are forwarded on ACP `session/new.mcpServers`. +- **Skills** — the bundled Fusion skill plus session `additionalSkillPaths` / requested skill names are staged into a trusted `--plugin-dir` plugin so Grok discovers them like a native plugin. +- **Env** — subprocess env is allow-listed (includes `HOME`/`PATH`/XDG so `~/.grok/auth.json` works, plus optional `XAI_API_KEY`/`GROK_API_KEY`). Full `process.env` is never inherited. +- **Auth implication:** because the `grok` binary resolves its own credentials for this path, a CLI-routed selection needs **no Fusion-visible `GROK_API_KEY`** when a cached grok.com session exists — unlike the direct xAI OpenAI-compatible streaming path. -## Configuration - -| Environment variable | Default | Description | -|---|---|---| -| `GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS` | `120000` | Cold-start / first-stdout-byte kill ceiling for headless `grok` prompts. Blank, non-numeric, zero, or negative values fall back to the default. | - -The first-output guard is separate from the 30-minute inactivity safety net that applies after stdout has begun. - -See `docs/grok-cli-contract.md` for the full contract, live captures, and the reason Fusion no longer uses the old `grok --prompt --format json` / `step_*` schema or the flaky streaming-json prompt path. +See `docs/grok-cli-contract.md` for the full contract, failure history (FN-7790/FN-7796 headless paths), and diagnostics invariants. ## Routing Grok through the CLI runtime (FN-7725 / FN-7753 / FN-7790) @@ -65,13 +62,13 @@ To route a specific agent's execution through the `grok` CLI runtime explicitly: 3. Select **Grok Runtime** from the runtime dropdown (sourced from `GET /api/plugins/runtimes`). 4. Save. The agent's `runtimeConfig.runtimeHint` is now `"grok"`; every session that agent drives resolves through this plugin's `GrokRuntimeAdapter` instead of the default pi runtime. -**Automatic fallback precedence (FN-7753):** explicit runtime hint > Fusion-visible key/direct endpoint > automatic CLI fallback. The fallback is only derived when no explicit runtime hint is set, the provider is `grok-cli`, no Fusion-visible key resolves, and runtime id `"grok"` is registered. The selected model is normalized from `grok-cli/` (or `grok/`) to `` and sent as `-m `. +**Automatic fallback precedence (FN-7753):** explicit runtime hint > Fusion-visible key/direct endpoint > automatic CLI fallback. The fallback is only derived when no explicit runtime hint is set, the provider is `grok-cli`, no Fusion-visible key resolves, and runtime id `"grok"` is registered. The selected model is normalized from `grok-cli/` (or `grok/`) to `` and sent as `grok agent -m stdio`. **Known limitation:** explicit Runtime-mode is still model-agnostic — it does not carry a specific `grok-cli/*` model id through to the adapter, so `GrokRuntimeAdapter.createSession()` falls back to `"grok/default"` and omits `-m`. Built-in Model selections preserve the model either through the direct endpoint (when a key is visible) or through the FN-7753 automatic CLI fallback (when no key is visible). ## Enable via Settings → Authentication -1. Install the `grok` CLI and authenticate it by any method it supports — Fusion does not need to see the key. +1. Install the `grok` CLI and authenticate it (`grok login` or `XAI_API_KEY`) — Fusion does not need to see the key for the CLI path. 2. Open Settings → Authentication in the Fusion dashboard. 3. The "Grok — via Grok CLI" card shows probe status. Click **Enable** once the binary is available; a non-blocking hint appears only if Fusion did not detect a key, noting the direct xAI streaming path uses `GROK_API_KEY` when present. 4. Discovered Grok models (via `grok models`) then merge into the model picker under the `grok-cli` provider id. diff --git a/plugins/fusion-plugin-grok-runtime/manifest.json b/plugins/fusion-plugin-grok-runtime/manifest.json index b09f082ca8..0c129de4e6 100644 --- a/plugins/fusion-plugin-grok-runtime/manifest.json +++ b/plugins/fusion-plugin-grok-runtime/manifest.json @@ -1,6 +1,6 @@ { "id": "fusion-plugin-grok-runtime", "name": "Grok Runtime Plugin", - "version": "0.1.0", - "description": "Provides Grok CLI-backed model provider and runtime integration" + "version": "0.2.0", + "description": "Provides Grok CLI-backed model provider and runtime integration over ACP (agent stdio)" } diff --git a/plugins/fusion-plugin-grok-runtime/package.json b/plugins/fusion-plugin-grok-runtime/package.json index 79e6c0ca2b..0aa457544a 100644 --- a/plugins/fusion-plugin-grok-runtime/package.json +++ b/plugins/fusion-plugin-grok-runtime/package.json @@ -1,11 +1,13 @@ { "name": "@fusion-plugin-examples/grok-runtime", - "version": "0.1.0", + "version": "0.2.0", "type": "module", - "description": "Grok CLI runtime plugin for Fusion", + "description": "Grok CLI runtime plugin for Fusion (ACP agent stdio)", "keywords": [ "fusion-plugin", "grok", + "acp", + "agent-client-protocol", "runtime" ], "exports": { @@ -26,6 +28,8 @@ "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { + "@agentclientprotocol/sdk": "0.24.0", + "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*" }, "peerDependencies": { diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/acp-settings.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/acp-settings.test.ts new file mode 100644 index 0000000000..64b26b8bee --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/acp-settings.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + buildGrokAcpArgs, + buildGrokAcpRuntimeSettings, + GROK_ACP_ENV_ALLOWLIST, + modelForCli, + normalizeGrokCliModel, + resolveGrokAcpAuthPreferMethods, +} from "../acp-settings.js"; + +describe("acp-settings", () => { + it("builds grok agent stdio args without -m when model is absent", () => { + // Official docs: --no-auto-update for automated ACP/headless clients. + expect(buildGrokAcpArgs()).toEqual(["--no-auto-update", "agent", "stdio"]); + expect(buildGrokAcpArgs({})).toEqual(["--no-auto-update", "agent", "stdio"]); + expect(buildGrokAcpArgs({ noAutoUpdate: false })).toEqual(["agent", "stdio"]); + }); + + it("places plugin-dir and -m before the stdio subcommand", () => { + expect(buildGrokAcpArgs({ model: "grok-4.5" })).toEqual([ + "--no-auto-update", + "agent", + "-m", + "grok-4.5", + "stdio", + ]); + expect(buildGrokAcpArgs({ model: "grok-4.5", pluginDirs: ["/tmp/skills-plugin"] })).toEqual([ + "--no-auto-update", + "agent", + "--plugin-dir", + "/tmp/skills-plugin", + "-m", + "grok-4.5", + "stdio", + ]); + }); + + it("prefers xai.api_key when XAI_API_KEY is set", () => { + expect(resolveGrokAcpAuthPreferMethods({ XAI_API_KEY: "xai-test" })).toEqual([ + "xai.api_key", + "cached_token", + ]); + expect(resolveGrokAcpAuthPreferMethods({})).toEqual(["cached_token", "xai.api_key"]); + }); + + it("normalizes provider-qualified model ids", () => { + expect(normalizeGrokCliModel("grok-cli/grok-4.5")).toBe("grok-4.5"); + expect(normalizeGrokCliModel("grok/grok-4.5")).toBe("grok-4.5"); + expect(normalizeGrokCliModel("grok-4.5")).toBe("grok-4.5"); + expect(normalizeGrokCliModel(undefined)).toBeUndefined(); + }); + + it("omits -m for the grok/default fallback", () => { + expect(modelForCli("grok/default")).toBeUndefined(); + expect(modelForCli("default")).toBeUndefined(); + expect(modelForCli("grok-cli/grok-4.5")).toBe("grok-4.5"); + }); + + it("builds AcpRuntimeAdapter settings for Grok ACP", () => { + const settings = buildGrokAcpRuntimeSettings({ binary: "/usr/local/bin/grok", model: "grok-cli/grok-4.5" }); + expect(settings.acpBinaryPath).toBe("/usr/local/bin/grok"); + expect(settings.acpArgs).toEqual(["--no-auto-update", "agent", "-m", "grok-4.5", "stdio"]); + expect(settings.acpEnvAllowList).toEqual([...GROK_ACP_ENV_ALLOWLIST]); + expect(settings.acpFsRead).toBe(false); + expect(settings.acpFsWrite).toBe(false); + expect(settings.acpAllowUnrestricted).toBe(true); + expect(settings.acpEnvAllowList).toEqual(expect.arrayContaining(["HOME", "PATH", "XAI_API_KEY"])); + expect(settings.acpAuthenticate).toEqual( + expect.objectContaining({ + preferMethods: expect.arrayContaining(["cached_token"]), + meta: { headless: true }, + }), + ); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts deleted file mode 100644 index 4941cb2fa7..0000000000 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("node:child_process", () => ({ spawn: vi.fn() })); - -import { spawn } from "node:child_process"; -import { spawnGrokStream } from "../cli-stream.js"; - -function mockPlatform(platform: NodeJS.Platform) { - return vi.spyOn(process, "platform", "get").mockReturnValue(platform); -} - -function createMockChild() { - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough; - stderr: PassThrough; - kill: ReturnType; - }; - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - child.kill = vi.fn(); - vi.mocked(spawn).mockReturnValue(child as never); - return child; -} - -describe("spawnGrokStream", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPlatform("darwin"); - createMockChild(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("passes the selected model and cwd using the real xAI Grok CLI flags", () => { - spawnGrokStream("grok", "hello", { cwd: "/tmp/project", model: "grok-4.5" }); - - expect(spawn).toHaveBeenCalledWith("grok", [ - "-p", - "hello", - "--output-format", - "json", - "-m", - "grok-4.5", - "--cwd", - "/tmp/project", - ], { - cwd: "/tmp/project", - stdio: ["ignore", "pipe", "pipe"], - shell: false, - signal: undefined, - }); - }); - - it("omits -m when no model is provided", () => { - spawnGrokStream("grok", "hello", { cwd: "/tmp/project" }); - - expect(spawn).toHaveBeenCalledWith("grok", [ - "-p", - "hello", - "--output-format", - "json", - "--cwd", - "/tmp/project", - ], expect.objectContaining({ cwd: "/tmp/project" })); - }); -}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/mcp-forwarding.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/mcp-forwarding.test.ts new file mode 100644 index 0000000000..5ab6eb9db5 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/mcp-forwarding.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { toAcpMcpServers } from "../mcp-forwarding.js"; + +describe("toAcpMcpServers", () => { + it("returns empty for missing/empty input", () => { + expect(toAcpMcpServers(undefined)).toEqual([]); + expect(toAcpMcpServers([])).toEqual([]); + }); + + it("converts engine stdio ResolvedMcpServerDefinition", () => { + expect( + toAcpMcpServers([ + { + name: "local-tools", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { API_KEY: "secret" }, + }, + ]), + ).toEqual([ + { + name: "local-tools", + command: "node", + args: ["server.js"], + env: [{ name: "API_KEY", value: "secret" }], + }, + ]); + }); + + it("converts http and sse transports", () => { + expect( + toAcpMcpServers([ + { name: "docs", transport: "streamable-http", url: "https://example.test/mcp", headers: { Authorization: "Bearer x" } }, + { name: "events", transport: "sse", url: "https://example.test/sse" }, + ]), + ).toEqual([ + { + type: "http", + name: "docs", + url: "https://example.test/mcp", + headers: [{ name: "Authorization", value: "Bearer x" }], + }, + { + type: "sse", + name: "events", + url: "https://example.test/sse", + headers: [], + }, + ]); + }); + + it("preserves legacy ACP stdio env pairs", () => { + expect( + toAcpMcpServers([ + { + name: "custom-tools", + command: "node", + args: ["mcp.cjs", "schema.json"], + env: [{ name: "FOO", value: "bar" }], + }, + ]), + ).toEqual([ + { + name: "custom-tools", + command: "node", + args: ["mcp.cjs", "schema.json"], + env: [{ name: "FOO", value: "bar" }], + }, + ]); + }); + + it("skips disabled servers and incomplete definitions", () => { + expect( + toAcpMcpServers([ + { name: "off", enabled: false, transport: "stdio", command: "node" }, + { name: "missing-cmd", transport: "stdio" }, + { name: "missing-url", transport: "http" }, + ]), + ).toEqual([]); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts index 88582909e0..c628b5d300 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts @@ -1,542 +1,253 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { GrokStreamProcess } from "../cli-stream.js"; -import { GrokRuntimeAdapter } from "../runtime-adapter.js"; +import { describe, expect, it, vi } from "vitest"; +import { GrokRuntimeAdapter, type AcpAdapterFactory } from "../runtime-adapter.js"; +import type { AgentSession, AgentSessionResult } from "../types.js"; /* -FNXC:GrokCli 2026-07-10-12:54: -FN-7796: adapter tests are pinned to the reliable xAI Grok Build TUI headless contract (`--output-format json` single object) and the live-captured flaky `streaming-json` cancellation shape. They intentionally avoid a live binary in CI but exercise the same spawn seam and lifecycle diagnostics that previously hid wrong-contract and cancelled-no-text failures behind fake fixtures. +FNXC:GrokAcp 2026-07-11-12:00: +Adapter tests pin the Grok ACP composition seam (settings + resolve-never-reject +diagnostics + message accumulation). They inject a fake AcpRuntimeAdapter so CI +does not require a live `grok` binary; live ACP handshake is covered by the ACP +plugin suite and manual smoke against `grok agent stdio`. */ -function makeFakeProc(): { - proc: GrokStreamProcess; - stdout: PassThrough; - stderr: PassThrough; - kill: ReturnType; -} { - const stdout = new PassThrough(); - const stderr = new PassThrough(); - const emitter = new EventEmitter(); - const kill = vi.fn(); - const proc = Object.assign(emitter, { stdout, stderr, kill }) as unknown as GrokStreamProcess; - return { proc, stdout, stderr, kill }; +function makeFakeAcpAdapter(overrides?: { + createSession?: AcpAdapterFactory extends (s: never) => infer R ? R["createSession"] : never; + promptWithFallback?: ( + session: AgentSession, + prompt: string, + options?: unknown, + ) => Promise; + settingsOut?: Record[]; +}): AcpAdapterFactory { + const settingsOut = overrides?.settingsOut ?? []; + return (settings) => { + settingsOut.push(settings); + const sessionShell: AgentSession & { connection?: { id: string }; dispose: () => void } = { + model: String(settings.acpModel ?? "grok/default"), + messages: [], + state: { messages: [] }, + lastModelDescription: `acp/${settings.acpModel ?? "default"}`, + callbacks: {}, + connection: { id: "conn-1" }, + sessionId: "acp-session-1", + dispose: vi.fn(), + }; + + return { + createSession: + overrides?.createSession ?? + (async (options): Promise => { + sessionShell.callbacks = { + onText: options.onText, + onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, + }; + return { session: sessionShell }; + }), + promptWithFallback: + overrides?.promptWithFallback ?? + (async (session, _prompt) => { + // Simulate ACP bridge streaming through the callbacks captured at create. + const s = session as AgentSession & { callbacks?: { onText?: (t: string) => void; onThinking?: (t: string) => void } }; + s.callbacks?.onThinking?.("thinking"); + s.callbacks?.onText?.("Hello"); + s.callbacks?.onText?.("!"); + return { stopReason: "end_turn" }; + }), + describeModel: (session) => `acp/${(session as AgentSession).model}`, + dispose: async (session) => { + (session as { dispose?: () => void }).dispose?.(); + }, + }; + }; } -function closeProc(proc: GrokStreamProcess, code = 0, signal: NodeJS.Signals | null = null): void { - proc.emit("close", code, signal); -} - -describe("GrokRuntimeAdapter", () => { +describe("GrokRuntimeAdapter (ACP)", () => { it("creates a session with default model fallback", async () => { - const adapter = new GrokRuntimeAdapter(); + const settingsOut: Record[] = []; + const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) }); const result = await adapter.createSession({ systemPrompt: "sys" }); expect(result.session.model).toBe("grok/default"); expect(result.session.systemPrompt).toBe("sys"); + const args = settingsOut[0]?.acpArgs as string[]; + expect(args).toContain("--no-auto-update"); + expect(args).toContain("agent"); + expect(args).toContain("--plugin-dir"); + expect(args.at(-1)).toBe("stdio"); }); - it("passes the normalized selected model to the CLI spawn seam", async () => { - const { proc } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); + it("passes the normalized selected model as grok agent -m before stdio and injects plugin-dir", async () => { + const settingsOut: Record[] = []; + const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) }); const { session } = await adapter.createSession({ defaultModelId: "grok-cli/grok-4.5" }); - const promise = adapter.promptWithFallback(session, "hello grok"); - closeProc(proc); - await promise; - expect(session.model).toBe("grok-4.5"); - expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: "grok-4.5" })); + expect(settingsOut[0]?.acpBinaryPath).toBe("grok"); + const args = settingsOut[0]?.acpArgs as string[]; + expect(args).toContain("--no-auto-update"); + expect(args).toContain("--plugin-dir"); + expect(args).toEqual(expect.arrayContaining(["-m", "grok-4.5", "stdio"])); + // plugin-dir precedes model flag; no-auto-update precedes agent + expect(args.indexOf("--no-auto-update")).toBeLessThan(args.indexOf("agent")); + expect(args.indexOf("--plugin-dir")).toBeLessThan(args.indexOf("-m")); }); - it("omits -m for the no-model grok/default fallback", async () => { - const { proc } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hello grok"); - closeProc(proc); - await promise; - - expect(session.model).toBe("grok/default"); - expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: undefined })); - }); - - - it("bridges the reliable single-object json response and persists assistant content", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const onThinking = vi.fn(); - const { session } = await adapter.createSession({ onText, onThinking }); - - const promise = adapter.promptWithFallback(session, "hello grok"); - stdout.write(JSON.stringify({ text: "Hello", stopReason: "EndTurn", sessionId: "session-json", requestId: "request-json", thought: "Thinking" })); - stdout.end(); - closeProc(proc); - await promise; - - expect(onThinking).toHaveBeenCalledWith("Thinking"); - expect(onText).toHaveBeenCalledWith("Hello"); - expect(session.sessionId).toBe("session-json"); - expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); - }); - - it("surfaces cancelled no-text json object as a diagnostic instead of a silent empty response", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "say hello in one word"); - stdout.write(JSON.stringify({ text: "", stopReason: "Cancelled", sessionId: "session-cancelled" })); - stdout.end(); - closeProc(proc); - await promise; - - expect(session.state.errorMessage).toBe("Grok CLI ended with stopReason Cancelled and produced no assistant text."); - expect(onText).toHaveBeenCalledWith(session.state.errorMessage); - expect(session.state.messages).toContainEqual({ role: "assistant", content: session.state.errorMessage }); - }); - - it("surfaces cancelled no-text streaming-json shape as a diagnostic instead of a silent empty response", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const onThinking = vi.fn(); - const { session } = await adapter.createSession({ onText, onThinking }); - - const promise = adapter.promptWithFallback(session, "say hello in one word"); - stdout.write(`${JSON.stringify({ type: "thought", data: "Thinking" })}\n`); - stdout.write(`${JSON.stringify({ type: "end", stopReason: "Cancelled", sessionId: "session-cancelled", requestId: "request-cancelled" })}\n`); - stdout.end(); - closeProc(proc); - await promise; - - expect(session.state.errorMessage).toBe("Grok CLI ended with stopReason Cancelled and produced no assistant text."); - expect(onText).toHaveBeenCalledWith(session.state.errorMessage); - expect(session.state.messages).toContainEqual({ role: "assistant", content: session.state.errorMessage }); - }); - - it("bridges real xAI thought/text/end events and persists assistant content", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const onThinking = vi.fn(); - const { session } = await adapter.createSession({ onText, onThinking }); - - const promise = adapter.promptWithFallback(session, "hello grok"); - stdout.write(`${JSON.stringify({ type: "thought", data: "Thinking" })}\n`); - stdout.write(`${JSON.stringify({ type: "text", data: "Hel" })}\n`); - stdout.write(`${JSON.stringify({ type: "text", data: "lo" })}\n`); - stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "session-1", requestId: "request-1" })}\n`); - closeProc(proc); - await promise; - - expect(onThinking.mock.calls.map((c) => c[0])).toEqual(["Thinking"]); - expect(onText.mock.calls.map((c) => c[0])).toEqual(["Hello"]); - expect(session.sessionId).toBe("session-1"); - expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); - }); - - it("bridges a single text event without thought events", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "one word"); - stdout.write(`${JSON.stringify({ type: "text", data: "Hello" })}\n`); - stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn" })}\n`); - closeProc(proc); - await promise; - - expect(onText).toHaveBeenCalledWith("Hello"); - expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); - }); - - it("skips malformed, non-JSON, and legacy wrong-product lines without callbacks", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const onThinking = vi.fn(); - const onToolStart = vi.fn(); - const { session } = await adapter.createSession({ onText, onThinking, onToolStart }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write("[SandboxDebug] booting\n"); - stdout.write("{not valid json\n"); - stdout.write(`${JSON.stringify({ type: "tool_use", toolCall: {}, toolResult: {} })}\n`); - stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn" })}\n`); - closeProc(proc); - await promise; - - expect(onText).not.toHaveBeenCalled(); - expect(onThinking).not.toHaveBeenCalled(); - expect(onToolStart).not.toHaveBeenCalled(); - expect(session.state.errorMessage).toBeUndefined(); - }); - - it("resolves (never rejects) when the subprocess emits an error and records the diagnostic", async () => { - const { proc } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - proc.emit("error", new Error("ENOENT")); - - await expect(promise).resolves.toBeUndefined(); - expect(session.state.errorMessage).toBe("Grok CLI process error: ENOENT"); - }); - - it("waits for child close after stdout ends so fatal stderr becomes the chat diagnostic", async () => { - const { proc, stdout, stderr } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - let resolved = false; - void promise.then(() => { - resolved = true; - }); - - stdout.end(); - await Promise.resolve(); - await Promise.resolve(); - expect(resolved).toBe(false); - - stderr.write("error: invalid model 'grok-unknown'\n"); - closeProc(proc, 1); - await promise; - - expect(session.state.errorMessage).toBe("Grok CLI failed (code 1): error: invalid model 'grok-unknown'"); - }); - - it("records a concrete diagnostic for non-zero exits with no stderr", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.end(); - closeProc(proc, 2); - await promise; - - expect(session.state.errorMessage).toBe("Grok CLI failed with code 2 and no stderr output."); - }); - - it("records a concrete diagnostic for code-0 exits with zero JSON output", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.end(); - closeProc(proc, 0); - await promise; - - expect(session.state.errorMessage).toBe( - "Grok CLI produced no JSON output for a headless prompt; this usually means the binary on PATH is not xAI's supported Grok Build TUI headless implementation, did not recognize -p/--output-format json, or exited interactive mode immediately after stdin EOF.", - ); - expect(onText).toHaveBeenCalledWith(session.state.errorMessage); - expect(session.state.messages).toContainEqual({ role: "assistant", content: session.state.errorMessage }); - }); - - it("records a concrete diagnostic for code-0 exits with non-JSON stdout only", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write("Welcome to grok interactive mode\n"); - stdout.end(); - closeProc(proc, 0); - await promise; - - expect(session.state.errorMessage).toBe( - "Grok CLI produced stdout but no parseable JSON response for a headless prompt; first output: Welcome to grok interactive mode", - ); - expect(onText).toHaveBeenCalledWith(session.state.errorMessage); - }); - - it("keeps a clean end event with no assistant text silent", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "thought", data: "No answer needed" })}\n`); - stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "session-empty" })}\n`); - closeProc(proc, 0); - await promise; - - expect(onText).not.toHaveBeenCalled(); - expect(session.state.errorMessage).toBeUndefined(); - expect(session.state.messages).not.toContainEqual(expect.objectContaining({ role: "assistant" })); - expect(session.sessionId).toBe("session-empty"); - }); - - it("does not turn a successful text response into an error when stderr is noisy", async () => { - const { proc, stdout, stderr } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "text", data: "answer" })}\n`); - stderr.write("debug noise\n"); - closeProc(proc, 1); - await promise; - - expect(onText).toHaveBeenCalledWith("answer"); - expect(session.state.errorMessage).toBeUndefined(); - }); - - it("resolves on subprocess close rather than the end event alone", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - let resolved = false; - void promise.then(() => { - resolved = true; - }); - - stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn" })}\n`); - await Promise.resolve(); - await Promise.resolve(); - expect(resolved).toBe(false); - - closeProc(proc, 0); - await promise; - expect(resolved).toBe(true); - }); - - describe("lifecycle timeouts (fake timers)", () => { - beforeEach(() => { - vi.useFakeTimers(); - delete process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS; - }); - afterEach(() => { - delete process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS; - vi.useRealTimers(); - }); - - it("kills the subprocess and resolves if no stdout line arrives within the default cold-start ceiling", async () => { - const { proc, kill } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - await vi.advanceTimersByTimeAsync(119_999); - expect(kill).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - - await promise; - expect(kill).toHaveBeenCalledWith("SIGKILL"); - expect(session.state.errorMessage).toBe( - "Grok CLI produced no stdout within 120000ms for a headless prompt; the process was killed.", - ); - }); - - it("uses GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS when it is a positive integer", async () => { - process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS = "25"; - const { proc, kill } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - await vi.advanceTimersByTimeAsync(24); - expect(kill).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - - await promise; - expect(kill).toHaveBeenCalledWith("SIGKILL"); - expect(session.state.errorMessage).toBe( - "Grok CLI produced no stdout within 25ms for a headless prompt; the process was killed.", - ); - }); - - it.each(["", " ", "nope", "0", "-1", "1.5"])( - "falls back to the default cold-start ceiling for invalid GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS=%j", - async (value) => { - process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS = value; - const { proc, kill } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - await vi.advanceTimersByTimeAsync(119_999); - expect(kill).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - - await promise; - expect(kill).toHaveBeenCalledWith("SIGKILL"); - expect(session.state.errorMessage).toBe( - "Grok CLI produced no stdout within 120000ms for a headless prompt; the process was killed.", - ); + it("forwards operator MCP servers and Fusion custom tools into createSession", async () => { + let captured: Record | undefined; + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: (settings) => { + const base = makeFakeAcpAdapter()(settings); + return { + ...base, + createSession: async (options) => { + captured = options as Record; + return base.createSession(options); + }, + }; }, - ); + }); + + const { session } = await adapter.createSession({ + mcpServers: [ + { + name: "local-tools", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { TOKEN: "x" }, + }, + ], + customTools: [ + { + name: "fn_task_list", + description: "List tasks", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "ok" }), + }, + ], + skills: ["fusion"], + }); + + const mcpServers = captured?.mcpServers as Array>; + expect(mcpServers.some((s) => s.name === "local-tools")).toBe(true); + expect(mcpServers.some((s) => s.name === "fusion-custom-tools")).toBe(true); + const meta = captured?.sessionMeta as { pluginDirs?: string[]; rules?: string }; + expect(meta.pluginDirs?.[0]).toBeTruthy(); + expect(meta.rules).toContain("fusion"); + expect(String(captured?.systemPrompt ?? "")).toContain("Fusion runtime context"); + await adapter.dispose(session); }); - it("resolves without throwing if the injected spawn function throws synchronously and records the diagnostic", async () => { - const spawn = vi.fn().mockImplementation(() => { - throw new Error("spawn ENOENT"); + it("omits -m for the no-model grok/default fallback but still injects plugin-dir", async () => { + const settingsOut: Record[] = []; + const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) }); + await adapter.createSession({}); + const args = settingsOut[0]?.acpArgs as string[]; + expect(args).toContain("--plugin-dir"); + expect(args.at(-1)).toBe("stdio"); + expect(args).not.toContain("-m"); + }); + + it("streams ACP text/thinking through engine callbacks and persists assistant content", async () => { + const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter() }); + const onText = vi.fn(); + const onThinking = vi.fn(); + const { session } = await adapter.createSession({ onText, onThinking }); + + await adapter.promptWithFallback(session, "hello grok"); + + expect(onThinking).toHaveBeenCalledWith("thinking"); + expect(onText.mock.calls.map((c) => c[0])).toEqual(["Hello", "!"]); + expect(session.sessionId).toBe("acp-session-1"); + expect(session.state.messages).toContainEqual({ role: "user", content: "hello grok" }); + expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello!" }); + }); + + it("surfaces abnormal stopReason with no text as a diagnostic", async () => { + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: makeFakeAcpAdapter({ + promptWithFallback: async () => ({ stopReason: "cancelled" }), + }), }); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); + + await adapter.promptWithFallback(session, "say hello"); + + expect(session.state.errorMessage).toBe( + "Grok ACP ended with stopReason cancelled and produced no assistant text.", + ); + expect(onText).toHaveBeenCalledWith(session.state.errorMessage); + }); + + it("keeps a clean end_turn with no assistant text silent", async () => { + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: makeFakeAcpAdapter({ + promptWithFallback: async () => ({ stopReason: "end_turn" }), + }), + }); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); + + await adapter.promptWithFallback(session, "hi"); + + expect(onText).not.toHaveBeenCalled(); + expect(session.state.errorMessage).toBeUndefined(); + }); + + it("resolves (never rejects) when ACP prompt throws and surfaces the diagnostic", async () => { + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: makeFakeAcpAdapter({ + promptWithFallback: async () => { + throw new Error("bridge hung up"); + }, + }), + }); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); await expect(adapter.promptWithFallback(session, "hi")).resolves.toBeUndefined(); - expect(session.state.errorMessage).toBe("Grok CLI spawn failed: spawn ENOENT"); + expect(session.state.errorMessage).toContain("bridge hung up"); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("bridge hung up")); }); - /* - FNXC:GrokCli 2026-07-10-15:10: - FN-7779 root-cause surface enumeration. The reported empty "No message" Grok - bubble was every SILENT failure collapsing into resolve-with-no-output. These - assert the invariant — a run with no renderable content surfaces a visible, - diagnosable reason via onText — across all known silent-failure surfaces: - stderr-only fatal exit, non-zero exit with no stderr, dropped NDJSON `error` - event, and process `error`. The clean content-less exit stays silent so a - legitimately empty response is not decorated with a false error. - */ - describe("FN-7779 silent-failure surfacing", () => { - it("surfaces stderr text when grok exits with no NDJSON (missing key / fatal, pre-JSON failure)", async () => { - const { proc, stderr } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stderr.write("Error: GROK_API_KEY is not set\n"); - proc.emit("close", 1, null); - await promise; - - expect(onText).toHaveBeenCalledTimes(1); - expect(onText.mock.calls[0][0]).toContain("GROK_API_KEY is not set"); + it("resolves createSession when ACP create throws and surfaces a start diagnostic", async () => { + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: makeFakeAcpAdapter({ + createSession: async () => { + throw new Error("ENOENT grok"); + }, + }), }); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); - it("surfaces a non-zero-exit diagnostic when there is no stdout and no stderr", async () => { - const { proc } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); + expect(session.state.errorMessage).toContain("ENOENT grok"); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("ENOENT grok")); - const promise = adapter.promptWithFallback(session, "hi"); - proc.emit("close", 3, null); - await promise; - - expect(onText).toHaveBeenCalledTimes(1); - expect(onText.mock.calls[0][0]).toContain("exited with code 3"); - }); - - it("bridges a well-formed NDJSON `error` event into visible onText", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "error", message: "rate limited", timestamp: 1 })}\n`); - proc.emit("close", 0, null); - await promise; - - expect(onText).toHaveBeenCalledTimes(1); - expect(onText.mock.calls[0][0]).toContain("rate limited"); - }); - - it("surfaces the process error reason instead of an empty result", async () => { - const { proc } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - proc.emit("error", new Error("spawn grok ENOENT")); - await promise; - - expect(onText).toHaveBeenCalledTimes(1); - expect(onText.mock.calls[0][0]).toContain("ENOENT"); - }); - - it("surfaces a reason when the injected spawn throws synchronously", async () => { - const spawn = vi.fn().mockImplementation(() => { - throw new Error("spawn ENOENT"); - }); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - await adapter.promptWithFallback(session, "hi"); - expect(onText).toHaveBeenCalledTimes(1); - expect(onText.mock.calls[0][0]).toContain("ENOENT"); - }); - - it("stays silent on a clean, content-less response (parsed EndTurn, empty text) — no false error text", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - // A genuinely empty grok response is a parsed JSON object with empty - // text and stopReason EndTurn — not zero stdout bytes. It must not be - // decorated with a false error bubble. - stdout.write(JSON.stringify({ text: "", stopReason: "EndTurn", sessionId: "abc" })); - stdout.end(); - proc.emit("close", 0, null); - await promise; - - expect(onText).not.toHaveBeenCalled(); - expect(session.state.errorMessage).toBeUndefined(); - }); - - it("does not append a stderr diagnostic when real text content was streamed", async () => { - const { proc, stdout, stderr } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "text", stepNumber: 1, text: "answer", timestamp: 1 })}\n`); - stderr.write("warning: deprecated flag\n"); - proc.emit("close", 0, null); - await promise; - - expect(onText.mock.calls.map((c) => c[0])).toEqual(["answer"]); - }); + // Second prompt must not duplicate the diagnostic bubble. + onText.mockClear(); + await adapter.promptWithFallback(session, "hi"); + expect(onText).not.toHaveBeenCalled(); }); - it("describeModel formats grok prefix", () => { - const adapter = new GrokRuntimeAdapter(); - expect(adapter.describeModel({ model: "grok/pro" } as never)).toBe("grok/grok/pro"); + it("describeModel formats grok prefix", async () => { + const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter() }); + const { session } = await adapter.createSession({ defaultModelId: "grok-4.5" }); + expect(adapter.describeModel(session)).toBe("grok/grok-4.5"); + }); + + it("disposes via the composed ACP adapter", async () => { + const dispose = vi.fn(async () => undefined); + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: (settings) => { + const base = makeFakeAcpAdapter()(settings); + return { ...base, dispose }; + }, + }); + const { session } = await adapter.createSession({}); + await adapter.dispose(session); + expect(dispose).toHaveBeenCalledWith(session); }); }); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts new file mode 100644 index 0000000000..f46a31311d --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildGrokSkillRules, + extractRequestedSkillNames, + resolveBundledFusionSkillSource, + stageGrokSessionSkills, +} from "../skill-loader.js"; + +const disposers: Array<() => void> = []; +afterEach(() => { + while (disposers.length > 0) disposers.pop()?.(); +}); + +describe("skill-loader", () => { + it("resolves the bundled Fusion skill from the monorepo", () => { + const source = resolveBundledFusionSkillSource(); + expect(source).toBeTruthy(); + expect(existsSync(join(source!, "SKILL.md"))).toBe(true); + }); + + it("stages fusion skill plus additional skill roots into a plugin dir", () => { + const extraRoot = mkdtempSync(join(tmpdir(), "extra-skills-")); + const skillDir = join(extraRoot, "ce-plan"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: ce-plan\n---\n# plan\n"); + + const staged = stageGrokSessionSkills({ + requestedSkillNames: ["fusion", "ce-plan"], + additionalSkillPaths: [extraRoot], + }); + disposers.push(staged.dispose); + + expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true); + expect(existsSync(join(staged.pluginDir, "skills", "ce-plan", "SKILL.md"))).toBe(true); + expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "ce-plan"])); + }); + + it("extracts requested skill names from skills or skillSelection", () => { + expect(extractRequestedSkillNames({ skills: ["a", "b"] })).toEqual(["a", "b"]); + expect( + extractRequestedSkillNames({ skillSelection: { requestedSkillNames: ["fusion", "ce-plan"] } }), + ).toEqual(["fusion", "ce-plan"]); + }); + + it("builds rules mentioning skills and tool counts", () => { + const rules = buildGrokSkillRules({ + skillNames: ["fusion"], + toolMode: "coding", + fusionToolCount: 3, + operatorMcpCount: 1, + }); + expect(rules).toContain("fusion"); + expect(rules).toContain("fusion-custom-tools"); + expect(rules).toContain("Operator MCP servers"); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts deleted file mode 100644 index f113363969..0000000000 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseJsonOutput, parseLine } from "../stream-parser.js"; - -/* -FNXC:GrokCli 2026-07-10-12:53: -FN-7796: fixtures pin the reliable xAI Grok Build TUI headless contract, `--output-format json`, because live `streaming-json` intermittently ended `stopReason:"Cancelled"` without text. Keep one streaming parser regression for the captured cancelled shape so diagnostics stay concrete if the flaky shape appears in buffered output. -*/ - -describe("parseJsonOutput (xAI Grok CLI json)", () => { - it("parses the reliable single-object response", () => { - const output = JSON.stringify({ - text: "Hello", - stopReason: "EndTurn", - sessionId: "session-1", - requestId: "request-1", - thought: "Thinking", - }); - expect(parseJsonOutput(output)).toEqual({ - text: "Hello", - stopReason: "EndTurn", - sessionId: "session-1", - requestId: "request-1", - thought: "Thinking", - }); - }); - - it("tolerates pretty-printed json from the real CLI", () => { - const output = `\n{\n "text": "Hello",\n "stopReason": "EndTurn",\n "sessionId": "session-1",\n "requestId": "request-1",\n "thought": "Thinking"\n}\n`; - expect(parseJsonOutput(output)?.text).toBe("Hello"); - }); - - it("preserves a terminal empty EndTurn object", () => { - expect(parseJsonOutput(JSON.stringify({ text: "", stopReason: "EndTurn" }))).toEqual({ - text: "", - stopReason: "EndTurn", - sessionId: undefined, - requestId: undefined, - thought: undefined, - }); - }); - - it("skips empty, non-JSON, malformed JSON, arrays, and unrelated objects without throwing", () => { - expect(parseJsonOutput("")).toBeNull(); - expect(parseJsonOutput("Welcome to grok interactive mode")).toBeNull(); - expect(() => parseJsonOutput("{not valid json")).not.toThrow(); - expect(parseJsonOutput("{not valid json")).toBeNull(); - expect(parseJsonOutput(JSON.stringify([{ text: "hi" }]))).toBeNull(); - expect(parseJsonOutput(JSON.stringify({ type: "step_start" }))).toBeNull(); - }); -}); - -describe("parseLine (captured flaky streaming-json diagnostics)", () => { - it("parses the cancelled no-text terminal shape", () => { - const line = JSON.stringify({ - type: "end", - stopReason: "Cancelled", - sessionId: "session-1", - requestId: "request-1", - }); - expect(parseLine(line)).toEqual({ - type: "end", - stopReason: "Cancelled", - sessionId: "session-1", - requestId: "request-1", - }); - }); - - it("parses thought/text events for buffered streaming regressions", () => { - expect(parseLine(JSON.stringify({ type: "thought", data: "Thinking" }))).toEqual({ type: "thought", data: "Thinking" }); - expect(parseLine(JSON.stringify({ type: "text", data: "Hello" }))).toEqual({ type: "text", data: "Hello" }); - }); - - it("skips malformed, unknown, and legacy wrong-product lines", () => { - expect(parseLine("")).toBeNull(); - expect(parseLine("[SandboxDebug] booting")).toBeNull(); - expect(parseLine("{not valid json")).toBeNull(); - expect(parseLine(JSON.stringify({ type: "step_start", stepNumber: 1 }))).toBeNull(); - expect(parseLine(JSON.stringify([{ type: "text", data: "hi" }]))).toBeNull(); - }); -}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts new file mode 100644 index 0000000000..e37c2bf9e9 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { startFusionToolBridge, toolsToMcpToolDefs } from "../tool-bridge.js"; + +describe("tool-bridge", () => { + it("filters built-ins and maps tool schemas", () => { + expect( + toolsToMcpToolDefs([ + { name: "read", description: "builtin", parameters: {} }, + { name: "fn_task_list", description: "List tasks", parameters: { type: "object", properties: {} } }, + ]), + ).toEqual([ + { + name: "fn_task_list", + description: "List tasks", + inputSchema: { type: "object", properties: {} }, + }, + ]); + }); + + it("starts a bridge that executes Fusion custom tools over HTTP", async () => { + const bridge = await startFusionToolBridge([ + { + name: "fn_task_list", + description: "List tasks", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "FN-1 todo" }), + }, + ]); + expect(bridge).not.toBeNull(); + expect(bridge!.toolCount).toBe(1); + expect(bridge!.mcpServer.name).toBe("fusion-custom-tools"); + expect(bridge!.mcpServer).toMatchObject({ + command: process.execPath, + env: [expect.objectContaining({ name: "FUSION_GROK_TOOL_BRIDGE_URL" })], + }); + + const env = "env" in bridge!.mcpServer ? bridge!.mcpServer.env : []; + const bridgeUrl = env.find((e) => e.name === "FUSION_GROK_TOOL_BRIDGE_URL")?.value; + expect(bridgeUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + + const res = await fetch(`${bridgeUrl}/tool-call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "fn_task_list", arguments: {} }), + }); + const body = (await res.json()) as { isError?: boolean; content?: Array<{ text?: string }> }; + expect(body.isError).toBe(false); + expect(body.content?.[0]?.text).toContain("FN-1"); + + await bridge!.dispose(); + }); + + it("returns null when there are no custom tools", async () => { + expect(await startFusionToolBridge([])).toBeNull(); + expect(await startFusionToolBridge(undefined)).toBeNull(); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/acp-settings.ts b/plugins/fusion-plugin-grok-runtime/src/acp-settings.ts new file mode 100644 index 0000000000..ca3b3cd65e --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp-settings.ts @@ -0,0 +1,161 @@ +/* +FNXC:GrokAcp 2026-07-11-12:00: +Route Grok CLI through native ACP (`grok agent stdio`) instead of one-shot +`grok -p --output-format json`. ACP gives realtime `session/update` streaming +(agent_message_chunk / agent_thought_chunk / tool_call), multi-turn session +reuse, and Fusion permission-gate integration. Env is still allow-listed +(never full process.env) but must include the vars Grok needs to find +~/.grok/auth.json and optional XAI/GROK API keys — thin {HOME,PATH} starves +auth (see acp-bridge-not-logged-in-thin-env-keychain-isolation learning). +*/ + +/** Env vars forwarded to the `grok agent stdio` subprocess. */ +export const GROK_ACP_ENV_ALLOWLIST = [ + "HOME", + "PATH", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TERMINFO", + "TMPDIR", + "COLORTERM", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + // Official headless/ACP auth: https://docs.x.ai/build/cli/headless-scripting#acp + // Prefer XAI_API_KEY (xai.api_key auth method); GROK_API_KEY kept as legacy alias. + "XAI_API_KEY", + "GROK_API_KEY", + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "XAI_API_BASE_URL", +] as const; + +/** + * Auth methods preferred for Grok ACP, matching the official scripting example: + * use `xai.api_key` when XAI_API_KEY is present, otherwise `cached_token`. + * Interactive `grok.com` is intentionally not preferred for headless Fusion. + */ +export function resolveGrokAcpAuthPreferMethods( + env: NodeJS.ProcessEnv = process.env, +): string[] { + // Prefer API-key method first whenever the env might supply a key (the + // authenticate step only selects it if the agent also advertised it). + if (typeof env.XAI_API_KEY === "string" && env.XAI_API_KEY.trim().length > 0) { + return ["xai.api_key", "cached_token"]; + } + // Some installs still use GROK_API_KEY; Grok may map it or fall through to cached_token. + if (typeof env.GROK_API_KEY === "string" && env.GROK_API_KEY.trim().length > 0) { + return ["xai.api_key", "cached_token"]; + } + return ["cached_token", "xai.api_key"]; +} + +/** + * Build argv for native Grok ACP mode. + * + * FNXC:GrokAcp 2026-07-11-14:00: + * `--plugin-dir` injects session-scoped Fusion skills as a trusted process-local + * plugin so Grok discovers SKILL.md trees without mutating ~/.grok. + * + * FNXC:GrokAcp 2026-07-11-15:00: + * Official headless/ACP scripting docs recommend `--no-auto-update` for CI and + * automated clients (https://docs.x.ai/build/cli/headless-scripting). Place it + * before the `agent` subcommand: `grok --no-auto-update agent … stdio`. + * Model / plugin-dir flags belong on `grok agent` before the transport: + * `grok --no-auto-update agent [--plugin-dir …] [-m ] stdio`. + */ +export function buildGrokAcpArgs(options?: { + model?: string; + pluginDirs?: string[]; + noAutoUpdate?: boolean; +}): string[] { + const args: string[] = []; + // Default ON for Fusion automation; callers can pass noAutoUpdate:false. + if (options?.noAutoUpdate !== false) { + args.push("--no-auto-update"); + } + args.push("agent"); + for (const dir of options?.pluginDirs ?? []) { + const trimmed = dir?.trim(); + if (trimmed) { + args.push("--plugin-dir", trimmed); + } + } + const cliModel = options?.model?.trim(); + if (cliModel) { + args.push("-m", cliModel); + } + args.push("stdio"); + return args; +} + +/** + * Normalize provider-qualified model ids to the bare id the CLI accepts. + * `grok/default` and empty → omit `-m` (CLI default model). + */ +export function normalizeGrokCliModel(model: string | undefined): string | undefined { + const normalized = model?.trim(); + if (!normalized) return undefined; + for (const prefix of ["grok-cli/", "grok/"]) { + if (normalized.startsWith(prefix)) { + const stripped = normalized.slice(prefix.length).trim(); + return stripped.length > 0 ? stripped : undefined; + } + } + return normalized; +} + +/** Concrete model id for `-m`, or undefined to let Grok pick its default. */ +export function modelForCli(model: string | undefined): string | undefined { + const normalized = normalizeGrokCliModel(model); + return normalized && normalized !== "default" ? normalized : undefined; +} + +/** Settings bag accepted by AcpRuntimeAdapter for a Grok ACP session. */ +export function buildGrokAcpRuntimeSettings(options: { + binary: string; + model?: string; + pluginDirs?: string[]; +}): Record { + const cliModel = modelForCli(options.model); + return { + acpBinaryPath: options.binary, + acpArgs: buildGrokAcpArgs({ model: cliModel, pluginDirs: options.pluginDirs }), + acpModel: options.model ?? "grok/default", + acpEnvAllowList: [...GROK_ACP_ENV_ALLOWLIST], + // Grok has native tools; client-side fs capabilities stay off (conservative). + // Official ACP example enables client fs; Fusion keeps protocol fs off and + // relies on Grok-native tools + forwarded MCP. + acpFsRead: false, + acpFsWrite: false, + /* + FNXC:GrokAcp 2026-07-11-12:00: + Grok is an operator-selected first-party CLI (not an arbitrary untrusted + ACP binary). Default Fusion policy is unrestricted; acknowledge that so + sensitive tool kinds under allow-all do not escalate every call to HITL + and hang autonomous executor turns. Non-allow policy categories still + route through the ACP permission floor (require-approval / block). + */ + acpAllowUnrestricted: true, + /* + FNXC:GrokAcp 2026-07-11-15:00: + Match https://docs.x.ai/build/cli/headless-scripting#acp — after initialize, + authenticate with xai.api_key (when XAI_API_KEY is set) or cached_token, + headless meta, before session/new. require:false so a method mismatch + surfaces as a later session error with stderr rather than failing agents + that already inherited login-session auth without advertising methods. + */ + acpAuthenticate: { + preferMethods: resolveGrokAcpAuthPreferMethods(), + meta: { headless: true }, + require: false, + }, + }; +} diff --git a/plugins/fusion-plugin-grok-runtime/src/acp/VENDORED.md b/plugins/fusion-plugin-grok-runtime/src/acp/VENDORED.md new file mode 100644 index 0000000000..3bc75a8514 --- /dev/null +++ b/plugins/fusion-plugin-grok-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-grok-runtime/src/acp/cli-spawn.ts b/plugins/fusion-plugin-grok-runtime/src/acp/cli-spawn.ts new file mode 100644 index 0000000000..37caab521e --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/cli-spawn.ts @@ -0,0 +1,188 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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:GrokAcp 2026-07-11-15:00: + * When set, call ACP authenticate after initialize (Grok 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-grok-runtime/src/acp/control-handler.ts b/plugins/fusion-plugin-grok-runtime/src/acp/control-handler.ts new file mode 100644 index 0000000000..37c1d0ab0b --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/control-handler.ts @@ -0,0 +1,302 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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-grok-runtime/src/acp/event-bridge.ts b/plugins/fusion-plugin-grok-runtime/src/acp/event-bridge.ts new file mode 100644 index 0000000000..8ad8d73fec --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/event-bridge.ts @@ -0,0 +1,308 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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-grok-runtime/src/acp/fs-capabilities.ts b/plugins/fusion-plugin-grok-runtime/src/acp/fs-capabilities.ts new file mode 100644 index 0000000000..f97e2a1cba --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/fs-capabilities.ts @@ -0,0 +1,263 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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-grok-runtime/src/acp/index.ts b/plugins/fusion-plugin-grok-runtime/src/acp/index.ts new file mode 100644 index 0000000000..2075cbbc5d --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/index.ts @@ -0,0 +1,16 @@ +/* +FNXC:GrokAcp 2026-07-11-16:00: +Vendored ACP client implementation for the Grok runtime. Copied from +plugins/fusion-plugin-acp-runtime/src (not imported) so the bundled Grok 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). Grok-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-grok-runtime/src/acp/path-jail.ts b/plugins/fusion-plugin-grok-runtime/src/acp/path-jail.ts new file mode 100644 index 0000000000..9279faf4b9 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/path-jail.ts @@ -0,0 +1,229 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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-grok-runtime/src/acp/process-manager.ts b/plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts new file mode 100644 index 0000000000..7b73a73076 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts @@ -0,0 +1,163 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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(`[grok-acp] ${message}`); +} + +/** Registry of active agent subprocesses for teardown. Self-cleans on exit. */ +const activeProcesses = new Set(); + +/** + * Register a subprocess in the agent process registry. + * Auto-removed from the registry when it exits. + */ +export function registerProcess(child: ChildProcess): void { + activeProcesses.add(child); + child.on("exit", () => activeProcesses.delete(child)); +} + +/** Remove a subprocess from the registry (idempotent). */ +export function unregisterProcess(child: ChildProcess): void { + activeProcesses.delete(child); +} + +/** Number of registered (presumed-live) agent subprocesses — for diagnostics/tests. */ +export function activeProcessCount(): number { + return activeProcesses.size; +} + +/** + * Force-kill a subprocess via SIGKILL. No-op if already dead (killed or exited). + * Cross-platform safe: Node treats SIGKILL as forceful termination on Windows. + */ +export function forceKill(child: ChildProcess): void { + if (child.killed || child.exitCode !== null) return; + try { + child.kill("SIGKILL"); + } catch { + // already gone + } +} + +/** + * Force-kill every registered agent subprocess and clear the registry. + * + * Scoped to agent subprocesses tracked here only — never the dashboard / port + * 4040 / any other process (KTD4 / kill-guard conventions). Safe to call + * repeatedly; no-ops on already-dead processes. + */ +export function killAllProcesses(): void { + for (const child of activeProcesses) { + forceKill(child); + } + activeProcesses.clear(); +} + +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-grok-runtime/src/acp/prompt-builder.ts b/plugins/fusion-plugin-grok-runtime/src/acp/prompt-builder.ts new file mode 100644 index 0000000000..b8ee656b32 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/prompt-builder.ts @@ -0,0 +1,50 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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[]; +} + +/** + * 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-grok-runtime/src/acp/provider.ts b/plugins/fusion-plugin-grok-runtime/src/acp/provider.ts new file mode 100644 index 0000000000..ac820fb19e --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/provider.ts @@ -0,0 +1,540 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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), + ); + }); + }, + }; + + // 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:GrokAcp 2026-07-11-15:00: + * Optional post-initialize authenticate (xAI Grok 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:GrokAcp 2026-07-11-15:00: + Official Grok 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:GrokAcp 2026-07-11-14:00: + * Optional ACP `_meta` bag for agent-specific session setup (Grok 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-grok-runtime/src/acp/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/acp/runtime-adapter.ts new file mode 100644 index 0000000000..a996c8fa8b --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/runtime-adapter.ts @@ -0,0 +1,183 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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 } 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 (Grok 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:GrokAcp 2026-07-11-14:00: + // Callers (Grok 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?.(); + const blocks = buildPromptBlocks(prompt); + // Resolve when the SDK prompt promise resolves — it already drains all + // session/update notifications for the turn before reporting the stopReason. + // The bridging client handler installed at createSession (U4) has already + // surfaced streamed text/thinking/tool updates onto session.callbacks. + /* + 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-grok-runtime/src/acp/sanitize.ts b/plugins/fusion-plugin-grok-runtime/src/acp/sanitize.ts new file mode 100644 index 0000000000..6b5aef34c6 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/sanitize.ts @@ -0,0 +1,81 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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-grok-runtime/src/acp/tool-mapping.ts b/plugins/fusion-plugin-grok-runtime/src/acp/tool-mapping.ts new file mode 100644 index 0000000000..031a08a693 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/tool-mapping.ts @@ -0,0 +1,47 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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-grok-runtime/src/acp/types.ts b/plugins/fusion-plugin-grok-runtime/src/acp/types.ts new file mode 100644 index 0000000000..5bbc006e7c --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/acp/types.ts @@ -0,0 +1,189 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 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:GrokAcp 2026-07-11-14:00: + * Widen beyond stdio so Grok ACP can receive Fusion operator MCP servers over + * http/sse (Grok 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:GrokAcp 2026-07-11-14:00: + * Opaque ACP `session/new._meta` for agent-specific setup (Grok 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-grok-runtime/src/cli-stream.ts b/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts deleted file mode 100644 index 18f443cc17..0000000000 --- a/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { spawn, type ChildProcessByStdio } from "node:child_process"; -import type { Readable } from "node:stream"; - -/* -FNXC:GrokCli 2026-07-10-12:50: -FN-7796: the operator-installed binary is xAI's Grok Build TUI (`grok 0.2.93`). Its `--output-format streaming-json` mode intermittently ends `stopReason:"Cancelled"` with zero `text` events, so Fusion's headless prompt path uses the reliable single-object contract, `grok -p --output-format json [-m ] [--cwd ]`. Keep foreground piped stdio and Windows shell handling so the adapter can buffer stdout, parse the object on close, and surface close/stderr diagnostics without raw detached processes. -*/ - -export type GrokStreamProcess = ChildProcessByStdio; - -export interface SpawnGrokStreamOptions { - cwd?: string; - model?: string; - signal?: AbortSignal; -} - -/** - * Spawn `grok -p --output-format json [-m ] [--cwd ]` - * with piped stdio. The adapter buffers stdout and parses the complete - * single-object response on subprocess close. - */ -export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess { - const args: string[] = ["-p", prompt, "--output-format", "json"]; - const model = options?.model?.trim(); - if (model) { - // FNXC:GrokCliRouting 2026-07-10-10:49: FN-7790 keeps FN-7753's concrete `grok-cli/*` model preservation but uses xAI Grok Build TUI's accepted short flag, `-m `, with the provider prefix stripped by runtime-adapter.ts. - args.push("-m", model); - } - if (options?.cwd) { - args.push("--cwd", options.cwd); - } - - return spawn(binary, args, { - cwd: options?.cwd, - stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", - signal: options?.signal, - }) as GrokStreamProcess; -} - -/** Force-kill a Grok CLI streaming subprocess. Best-effort; never throws. */ -export function forceKillGrokStream(proc: GrokStreamProcess): void { - try { - proc.kill("SIGKILL"); - } catch { - // best effort - } -} diff --git a/plugins/fusion-plugin-grok-runtime/src/index.ts b/plugins/fusion-plugin-grok-runtime/src/index.ts index 328683ca63..7cef661205 100644 --- a/plugins/fusion-plugin-grok-runtime/src/index.ts +++ b/plugins/fusion-plugin-grok-runtime/src/index.ts @@ -1,5 +1,6 @@ import { definePlugin } from "@fusion/plugin-sdk"; import type { FusionPlugin } from "@fusion/plugin-sdk"; +import { killAllProcesses } from "./acp/index.js"; import { probeGrokBinary } from "./probe.js"; import { discoverGrokProviderModels } from "./provider.js"; import { GrokRuntimeAdapter } from "./runtime-adapter.js"; @@ -12,26 +13,47 @@ one contract difference — Grok is API-key auth (GROK_API_KEY env var or `grok status --format json` route; probe/authRoute here surface key-presence auth state instead (see probe.ts). This shells out to an operator-installed `grok` binary on PATH — Fusion does not download or bundle it. + +FNXC:GrokAcp 2026-07-11-12:00: +Prompt transport is now native ACP (`grok agent stdio`) via vendored +AcpRuntimeAdapter under ./acp/ — realtime session/update streaming, tool calls, +and multi-turn session reuse. Probe/model discovery still use the CLI. + +FNXC:GrokAcp 2026-07-11-16:00: +ACP client code is copied into this plugin (src/acp/), not imported from +fusion-plugin-acp-runtime, so bundled Grok does not depend on the experimental +ACP example plugin package. */ + +// Reap Grok ACP agent subprocesses on hard process exit (registry SIGKILL is +// authoritative). Scoped to ACP-tracked agent children only — never port 4040. +process.on("exit", killAllProcesses); + const plugin: FusionPlugin = definePlugin({ manifest: { id: "fusion-plugin-grok-runtime", name: "Grok Runtime Plugin", - version: "0.1.0", - description: "Grok CLI runtime support for Fusion", + version: "0.2.0", + description: "Grok CLI runtime support for Fusion (ACP agent stdio)", runtime: { runtimeId: "grok", name: "Grok Runtime", - version: "0.1.0", + version: "0.2.0", }, }, state: "installed", - hooks: {}, + hooks: { + onLoad: (ctx) => { + ctx.logger.info( + "Grok Runtime Plugin loaded — transport=ACP (grok agent stdio); probe uses grok --version", + ); + }, + }, runtime: { metadata: { runtimeId: "grok", name: "Grok Runtime", - version: "0.1.0", + version: "0.2.0", }, factory: async () => new GrokRuntimeAdapter(), }, @@ -71,4 +93,5 @@ const plugin: FusionPlugin = definePlugin({ export default plugin; export { probeGrokBinary } from "./probe.js"; export { discoverGrokProviderModels } from "./provider.js"; +export { GrokRuntimeAdapter } from "./runtime-adapter.js"; export type { GrokBinaryStatus } from "./types.js"; diff --git a/plugins/fusion-plugin-grok-runtime/src/mcp-forwarding.ts b/plugins/fusion-plugin-grok-runtime/src/mcp-forwarding.ts new file mode 100644 index 0000000000..63a3c0f3f7 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/mcp-forwarding.ts @@ -0,0 +1,114 @@ +/* +FNXC:GrokAcp 2026-07-11-14:00: +Convert engine-resolved MCP server definitions (FN-7022 three-transport shape) +into ACP `session/new.mcpServers` entries so Grok 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 Grok 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-grok-runtime/src/mcp-schema-server.cjs b/plugins/fusion-plugin-grok-runtime/src/mcp-schema-server.cjs new file mode 100644 index 0000000000..8068f77482 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/mcp-schema-server.cjs @@ -0,0 +1,151 @@ +#!/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"); + +const schemaPath = process.argv[2]; +const bridgeUrl = process.env.FUSION_GROK_TOOL_BRIDGE_URL; +if (!schemaPath || !bridgeUrl) { + process.stderr.write("fusion-tools-mcp-server: missing schema path or FUSION_GROK_TOOL_BRIDGE_URL\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), + }, + 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-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index f87abc9dfa..2a52b0f905 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -1,417 +1,395 @@ -import { forceKillGrokStream, spawnGrokStream, type GrokStreamProcess, type SpawnGrokStreamOptions } from "./cli-stream.js"; -import { parseJsonOutput, parseLine } from "./stream-parser.js"; -import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, GrokSession } from "./types.js"; +import { AcpRuntimeAdapter } from "./acp/index.js"; +import { + buildGrokAcpRuntimeSettings, + modelForCli, + normalizeGrokCliModel, +} from "./acp-settings.js"; +import { toAcpMcpServers, type AcpMcpServer } from "./mcp-forwarding.js"; +import { + buildGrokSkillRules, + extractRequestedSkillNames, + stageGrokSessionSkills, +} from "./skill-loader.js"; +import { startFusionToolBridge, type FusionToolBridge, type ToolLike } from "./tool-bridge.js"; +import type { + AgentRuntime, + AgentRuntimeOptions, + AgentSession, + AgentSessionResult, + GrokSession, +} from "./types.js"; /* -FNXC:GrokCli 2026-07-10-12:52: -FN-7796: the production binary is xAI's Grok Build TUI. Its `--output-format streaming-json` path intermittently emits only `thought` events and then `stopReason:"Cancelled"` with no `text`, so the adapter now consumes the reliable `--output-format json` single object on subprocess close. Bridge object `text` to `onText`, object `thought` to `onThinking`, record `sessionId`, and make non-`EndTurn` empty-text terminals diagnosable instead of silent. +FNXC:GrokAcp 2026-07-11-12:00: +Replace the one-shot headless path (`grok -p --output-format json`) with native +ACP transport (`grok 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 +Grok-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:GrokAcp 2026-07-11-16:00: +Do not import `@fusion-plugin-examples/acp-runtime`. Grok is bundled/auto-install; +the generic ACP plugin is experimental. Vendor the client modules under src/acp/. FNXC:GrokCliRouting 2026-07-10-10:54: -FN-7753's auto-derived `grok` runtime routing from a `grok-cli/*` model selection still preserves the concrete model. Normalize provider-qualified ids (`grok-cli/` or `grok/`) at session creation/prompt time and pass only the concrete id to `grok -m`; the no-model Runtime-mode path keeps the historical `grok/default` session fallback and omits `-m`. +FN-7753's auto-derived `grok` runtime routing from a `grok-cli/*` model selection +still preserves the concrete model. Normalize provider-qualified ids +(`grok-cli/` or `grok/`) and pass only the concrete id as `grok agent -m`; +the no-model Runtime-mode path keeps `grok/default` and omits `-m`. + +FNXC:GrokAcp 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 */ -/** - * Cold-start ceiling: if `grok -p --output-format json` produces no stdout - * bytes within this window, treat it as a hung/failed subprocess and resolve - * (never reject — mirrors the Droid adapter's resolve-on-error lifecycle so pi - * always gets a well-formed, if diagnostic, result instead of an unhandled rejection). - * - * FNXC:GrokCli 2026-07-11-00:00: - * FN-7838 raises Grok's cold-start ceiling from 60s to 120s because slow/cold first-token starts were killed prematurely. Operators can override the first-output guard with GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS; invalid values fall back to the default so the guard is never disabled. This mirrors the OpenClaw/Hermes *_CLI_TIMEOUT_MS precedence pattern while keeping the 30-minute inactivity safety net separate. - */ -const DEFAULT_FIRST_OUTPUT_TIMEOUT_MS = 120_000; -const GROK_FIRST_OUTPUT_TIMEOUT_ENV = "GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS"; +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; +}; -function parsePositiveInteger(value: string | undefined): number | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - const parsed = Number(trimmed); - if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined; - return parsed; +export interface GrokRuntimeAdapterOptions { + /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ + binary?: string; + /** + * Injectable ACP adapter factory for tests. Production uses + * `AcpRuntimeAdapter` with Grok ACP settings. + */ + createAcpAdapter?: AcpAdapterFactory; } -function resolveFirstOutputTimeoutMs(): number { - return parsePositiveInteger(process.env[GROK_FIRST_OUTPUT_TIMEOUT_ENV]) ?? DEFAULT_FIRST_OUTPUT_TIMEOUT_MS; +/** Turn-scoped stream accumulators stored on the session for prompt finalization. */ +interface TurnAccum { + text: string; } -/** - * Inactivity safety net: kill the subprocess if no stdout bytes arrive for - * this long after the first chunk. Generous ceiling mirroring the Droid - * adapter's rationale — the caller (Fusion's stuck-task detection / abort - * signal) is the authoritative "this session is stuck" source; this is a - * last-resort guard for a catastrophically hung `grok` process. - */ -const INACTIVITY_TIMEOUT_MS = 30 * 60_000; - -/** - * FNXC:GrokCli 2026-07-10-15:10: - * FN-7779 root-cause helpers. The reported "No message" empty Grok bubble was - * not a legitimate content-empty response — it was every silent grok failure - * (missing/invalid GROK_API_KEY, bad flag, non-zero exit, missing binary) - * collapsing into a resolve-with-no-output. The frontend placeholder (FN-7779 - * UI step) hid the symptom; these helpers cure the cause by turning each - * silent failure into visible, diagnosable text so the operator sees WHY grok - * returned nothing. Retargeted for FN-7796's single-JSON-object contract — - * the schema no longer carries a `tool_use`/`error` NDJSON event, so only the - * spawn/process/exit-code failure surfaces below apply. - */ -function emitFailureText(session: GrokSession, text: string): void { - session.callbacks.onText?.(text); -} - -function describeSpawnFailure(error: unknown): string { - const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); - return `Grok CLI failed to start: ${reason}. Ensure the \`grok\` binary is installed and on PATH, or set GROK_API_KEY to use the direct xAI endpoint.`; -} - -/** - * Build the operator-facing message for a run that finished with NO renderable - * content. Prefer the captured stderr (the channel for fatal, pre-JSON - * failures); otherwise fall back to a non-zero-exit diagnostic. Returns - * undefined for a genuinely clean, content-less exit (code 0, no stderr) so a - * legitimately empty response is not decorated with a false error. - */ -function describeSilentFailure(stderr: string, exitCode: number | null | undefined): string | undefined { - const trimmed = stderr.trim(); - if (trimmed) { - return `Grok CLI returned no content. ${trimmed}`; - } - if (typeof exitCode === "number" && exitCode !== 0) { - return `Grok CLI exited with code ${exitCode} and produced no output. Check that GROK_API_KEY (or the \`grok\` login) is configured and the selected model is valid.`; - } - return undefined; -} - -function normalizeGrokCliModel(model: string | undefined): string | undefined { - const normalized = model?.trim(); - if (!normalized) return undefined; - for (const prefix of ["grok-cli/", "grok/"]) { - if (normalized.startsWith(prefix)) { - const stripped = normalized.slice(prefix.length).trim(); - return stripped.length > 0 ? stripped : undefined; - } - } - return normalized; -} - -function modelForCli(model: string | undefined): string | undefined { - const normalized = normalizeGrokCliModel(model); - return normalized && normalized !== "default" ? normalized : undefined; +interface SessionResources { + toolBridge?: FusionToolBridge | null; + skillStaging?: { dispose: () => void } | null; } function compactDiagnostic(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function formatCloseDiagnostic(code: number | null, signal: NodeJS.Signals | null, stderr: string): string { - const detail = compactDiagnostic(stderr); - const exitDetail = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`; - return detail ? `Grok CLI failed (${exitDetail}): ${detail}` : `Grok CLI failed with ${exitDetail} and no stderr output.`; +function describeCreateFailure(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + return compactDiagnostic( + `Grok ACP failed to start: ${reason}. Ensure the \`grok\` binary is installed and authenticated (` + + `\`grok agent stdio\`), or set XAI_API_KEY / GROK_API_KEY for key-based auth.`, + ); } -function formatNoJsonDiagnostic(firstStdoutChunk: string | undefined): string { - const firstChunk = firstStdoutChunk ? compactDiagnostic(firstStdoutChunk) : ""; - if (firstChunk) { - return `Grok CLI produced stdout but no parseable JSON response for a headless prompt; first output: ${firstChunk}`; - } - return "Grok CLI produced no JSON output for a headless prompt; this usually means the binary on PATH is not xAI's supported Grok Build TUI headless implementation, did not recognize -p/--output-format json, or exited interactive mode immediately after stdin EOF."; -} - -function formatTerminalNoTextDiagnostic(stopReason: string): string { - return `Grok CLI ended with stopReason ${stopReason} and produced no assistant text.`; +function describePromptFailure(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + return compactDiagnostic(`Grok ACP turn failed: ${reason}`); } function appendMessage(session: GrokSession, role: "user" | "assistant", content: string): void { - session.state.messages.push({ role, content }); + const entry = { role, content }; + session.state.messages.push(entry); + if (session.messages !== session.state.messages) { + session.messages.push(entry); + } } -interface ParsedPromptOutput { - text: string; - thought?: string; - stopReason?: string; - sessionId?: string; - parsed: boolean; +const TURN_ACCUM = Symbol("grokTurnAccum"); +const SESSION_RESOURCES = Symbol("grokSessionResources"); + +type SessionWithExtras = GrokSession & { + [TURN_ACCUM]?: TurnAccum; + [SESSION_RESOURCES]?: SessionResources; +}; + +function getTurnAccum(session: GrokSession): TurnAccum { + const s = session as SessionWithExtras; + if (!s[TURN_ACCUM]) { + s[TURN_ACCUM] = { text: "" }; + } + return s[TURN_ACCUM]; } -function parsePromptOutput(stdout: string): ParsedPromptOutput { - const json = parseJsonOutput(stdout); - if (json) { - return { - text: json.text ?? "", - thought: json.thought, - stopReason: json.stopReason, - sessionId: json.sessionId, - parsed: true, - }; +function resetTurnAccum(session: GrokSession): void { + getTurnAccum(session).text = ""; +} + +function collectCustomTools(options: AgentRuntimeOptions): ToolLike[] { + const fromCustom = Array.isArray(options.customTools) ? (options.customTools as ToolLike[]) : []; + // Some call sites pass tools as an array of ToolDefinitions instead of "coding"/"readonly". + const maybeToolsArray = Array.isArray((options as { tools?: unknown }).tools) + ? ((options as { tools: ToolLike[] }).tools) + : []; + return [...fromCustom, ...maybeToolsArray]; +} + +function ensureGrokSessionShape( + session: AgentSession, + model: string, + options: AgentRuntimeOptions, + turnAccum: TurnAccum, + resources: SessionResources, +): GrokSession { + const messages: unknown[] = + Array.isArray((session as GrokSession).messages) ? (session as GrokSession).messages : []; + const existingState = (session as { state?: GrokSession["state"] }).state; + const state: GrokSession["state"] = existingState ?? { messages }; + if (!Array.isArray(state.messages)) { + state.messages = messages; } - let text = ""; - let thought = ""; - let stopReason: string | undefined; - let sessionId: string | undefined; - let parsed = false; - for (const line of stdout.split(/\r?\n/)) { - const event = parseLine(line); - if (!event) continue; - parsed = true; - if (event.type === "text") { - text += event.data; - } else if (event.type === "thought") { - thought += event.data; - } else { - stopReason = event.stopReason; - sessionId = event.sessionId; - } - } + const grok = session as GrokSession; + grok.model = model; + grok.systemPrompt = grok.systemPrompt ?? options.systemPrompt; + grok.messages = state.messages; + grok.state = state; + grok.lastModelDescription = `grok/${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. + grok.callbacks = { + onText: grok.callbacks?.onText ?? options.onText, + onThinking: grok.callbacks?.onThinking ?? options.onThinking, + onToolStart: grok.callbacks?.onToolStart ?? options.onToolStart, + onToolEnd: grok.callbacks?.onToolEnd ?? options.onToolEnd, + }; - return { text, thought: thought || undefined, stopReason, sessionId, parsed }; + const originalDispose = typeof grok.dispose === "function" ? grok.dispose.bind(grok) : () => undefined; + grok.dispose = () => { + void resources.toolBridge?.dispose(); + resources.skillStaging?.dispose(); + originalDispose(); + }; + + (grok as SessionWithExtras)[TURN_ACCUM] = turnAccum; + (grok as SessionWithExtras)[SESSION_RESOURCES] = resources; + return grok; } -export interface GrokRuntimeAdapterOptions { - /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ - binary?: string; - /** Injectable spawn seam for tests — defaults to the real `spawnGrokStream`. */ - spawn?: (binary: string, prompt: string, options?: SpawnGrokStreamOptions) => GrokStreamProcess; +function createDeadSession( + model: string, + options: AgentRuntimeOptions, + diagnostic: string, + resources?: SessionResources, +): GrokSession { + const messages: unknown[] = []; + const session: GrokSession = { + model, + systemPrompt: options.systemPrompt, + messages, + state: { messages, errorMessage: diagnostic }, + sessionId: undefined, + lastModelDescription: `grok/${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 GrokRuntimeAdapter implements AgentRuntime { readonly id = "grok"; readonly name = "Grok Runtime"; private readonly binary: string; - private readonly spawnFn: (binary: string, prompt: string, options?: SpawnGrokStreamOptions) => GrokStreamProcess; + private readonly createAcpAdapter: AcpAdapterFactory; + /** Per-session ACP adapter so model-specific spawn args stay consistent. */ + private readonly adapters = new WeakMap>(); constructor(options?: GrokRuntimeAdapterOptions) { this.binary = options?.binary ?? "grok"; - this.spawnFn = options?.spawn ?? spawnGrokStream; + this.createAcpAdapter = + options?.createAcpAdapter ?? + ((settings) => new AcpRuntimeAdapter(settings)); } async createSession( - options: { - defaultModelId?: string; - systemPrompt?: string; - onText?: (text: string) => void; - onThinking?: (text: string) => void; - onToolStart?: (toolName: string, args?: unknown) => void; - onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; - } = {}, + options: AgentRuntimeOptions = { + cwd: process.cwd(), + systemPrompt: "", + }, ): Promise { const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default"; - const messages: unknown[] = []; - const session: GrokSession = { - model, - systemPrompt: options.systemPrompt, - messages, - state: { messages }, - sessionId: undefined, - lastModelDescription: `grok/${model}`, - callbacks: { - onText: options.onText, - onThinking: options.onThinking, - onToolStart: options.onToolStart, - onToolEnd: options.onToolEnd, + const turnAccum: TurnAccum = { text: "" }; + const resources: SessionResources = {}; + + // ── Skills ──────────────────────────────────────────────────────────── + const requestedSkillNames = extractRequestedSkillNames({ + skills: options.skills, + skillSelection: options.skillSelection, + }); + const skillStaging = stageGrokSessionSkills({ + 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)); + resources.toolBridge = toolBridge; + } catch { + toolBridge = null; + } + + const mcpServers: AcpMcpServer[] = [ + ...operatorMcp, + ...(toolBridge ? [toolBridge.mcpServer] : []), + ]; + + const rules = buildGrokSkillRules({ + 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); }, }; - return { session, sessionFile: undefined }; + + const settings = buildGrokAcpRuntimeSettings({ + binary: this.binary, + model, + pluginDirs: [skillStaging.pluginDir], + }); + const acp = this.createAcpAdapter(settings); + + try { + const result = await acp.createSession(sessionOptions); + const session = ensureGrokSessionShape(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?: AgentRuntimeOptions): Promise { + async promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise { const grokSession = session as GrokSession; - const cwd = options?.cwd; - const signal = options?.signal; appendMessage(grokSession, "user", prompt); - const firstOutputTimeoutMs = resolveFirstOutputTimeoutMs(); + resetTurnAccum(grokSession); - return new Promise((resolve) => { - let proc: GrokStreamProcess; - try { - proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal }); - } catch (spawnError) { - // Spawn threw synchronously (e.g. binary not found without shell - // resolution) — resolve, never reject, matching the CLI-adapter - // contract of always producing a well-formed result while retaining - // the concrete diagnostic for callers that surface session.state, AND - // (FN-7779 root-cause) surfacing the reason as visible text so the - // user sees a diagnosable failure instead of an empty bubble. - const message = spawnError instanceof Error ? spawnError.message : String(spawnError); - const diagnostic = compactDiagnostic(`Grok CLI spawn failed: ${message}`); - grokSession.state.errorMessage = diagnostic; - const failureMessage = describeSpawnFailure(spawnError); - emitFailureText(grokSession, failureMessage); - appendMessage(grokSession, "assistant", failureMessage); - resolve(); + const acp = this.adapters.get(session); + const hasConnection = + acp && "connection" in session && Boolean((session as { connection?: unknown }).connection); + + if (!hasConnection) { + const existing = grokSession.state.errorMessage?.trim(); + if (existing) { return; } + const diagnostic = + "Grok ACP session has no live connection. The `grok agent stdio` process failed to start or was disposed."; + grokSession.state.errorMessage = diagnostic; + grokSession.callbacks.onText?.(diagnostic); + appendMessage(grokSession, "assistant", diagnostic); + return; + } - let settled = false; - let firstOutputReceived = false; - let firstStdoutChunk: string | undefined; - let assistantText = ""; - let diagnosticEmitted = false; - let stderr = ""; - let stdout = ""; - let firstOutputTimer: NodeJS.Timeout | undefined; - let inactivityTimer: NodeJS.Timeout | undefined; - // FNXC:GrokCli 2026-07-10-15:10: FN-7779 root-cause — track whether any - // renderable content (real assistant text) or a fallback diagnostic has - // already been surfaced via onText, so a run that finished with NO - // renderable content gets exactly one visible reason instead of an - // empty "No message" assistant bubble (and never a duplicate - // diagnostic on top of real content). - let contentEmitted = false; - - const setErrorMessage = (message: string) => { - if (message.trim().length === 0) return; - grokSession.state.errorMessage = message; - }; - - const emitDiagnosticText = (message: string | undefined) => { - const diagnostic = message?.trim(); - if (!diagnostic || assistantText || diagnosticEmitted || contentEmitted) return; - diagnosticEmitted = true; - contentEmitted = true; + try { + const result = await acp!.promptWithFallback(session, prompt, options); + const assistantText = getTurnAccum(grokSession).text; + if (assistantText.length > 0) { + appendMessage(grokSession, "assistant", assistantText); + } else if (result && typeof result === "object" && "stopReason" in result) { + const stopReason = result.stopReason; + if (stopReason && stopReason !== "end_turn" && stopReason !== "EndTurn") { + const diagnostic = `Grok ACP ended with stopReason ${stopReason} and produced no assistant text.`; + grokSession.state.errorMessage = diagnostic; + grokSession.callbacks.onText?.(diagnostic); + appendMessage(grokSession, "assistant", diagnostic); + } + } + return result; + } catch (error) { + const assistantText = getTurnAccum(grokSession).text; + if (assistantText.length === 0) { + const diagnostic = describePromptFailure(error); + grokSession.state.errorMessage = diagnostic; grokSession.callbacks.onText?.(diagnostic); appendMessage(grokSession, "assistant", diagnostic); - }; - - const emitParsedOutput = (parsed: ParsedPromptOutput) => { - if (parsed.thought) { - grokSession.callbacks.onThinking?.(parsed.thought); - } - if (parsed.sessionId) { - grokSession.sessionId = parsed.sessionId; - } - if (parsed.text.length > 0) { - assistantText += parsed.text; - contentEmitted = true; - grokSession.callbacks.onText?.(parsed.text); - return; - } - if (parsed.stopReason && parsed.stopReason !== "EndTurn") { - setErrorMessage(formatTerminalNoTextDiagnostic(parsed.stopReason)); - } - }; - - /* - FNXC:GrokCli 2026-07-10-00:00: - A failing headless `grok` run can close stdout before the child `close` event reports its non-zero exit and stderr. Resolving too early made dashboard Chat persist an empty assistant message before the diagnostic existed. Finalize only from subprocess close/error or lifecycle timeouts, and store concrete stderr/parse error details on session.state.errorMessage so shared chat/executor seams can surface the reason without breaking the resolve-never-reject runtime contract. - - FNXC:GrokCli 2026-07-10-12:52: - FN-7796 replaces the streaming-json/NDJSON contract with a single JSON object parsed once on subprocess close (`parsePromptOutput`/`emitParsedOutput`), because streaming-json intermittently emitted only `thought` + `stopReason:"Cancelled"` with no `text`. `stdout` is accumulated in full across `data` chunks rather than parsed line-by-line as it arrives. - - FNXC:GrokCli 2026-07-10-15:10: - FN-7779 root-cause — the above only covered the zero-output shape. A run - that DID exit non-zero (or produced fatal stderr) with no renderable - content still resolved silently once `session.state.errorMessage` had - already been consumed by `emitDiagnosticText` for a different reason (or - not set at all). If nothing was rendered AND no diagnostic has been - emitted yet, fall back to `describeSilentFailure` (stderr-first, then a - non-zero-exit reason) so every silent failure surface gets a visible, - diagnosable `onText` — never a bare empty resolve. - */ - const finish = (exitCode?: number | null) => { - if (settled) return; - settled = true; - if (firstOutputTimer) clearTimeout(firstOutputTimer); - if (inactivityTimer) clearTimeout(inactivityTimer); - if (assistantText) { - appendMessage(grokSession, "assistant", assistantText); - } else { - // FN-7779's stderr/exit-code diagnostic takes priority when the run - // actually failed (non-empty stderr or non-zero exit): it names the - // concrete cause. Only fall back to the FN-7796 parse-shape - // diagnostic (session.state.errorMessage, e.g. "produced no JSON - // output") for the remaining case that describeSilentFailure can't - // describe — a code-0 exit with no stderr that still produced no - // parseable output. - const failure = describeSilentFailure(stderr, exitCode); - if (failure && !contentEmitted) { - contentEmitted = true; - emitFailureText(grokSession, failure); - appendMessage(grokSession, "assistant", failure); - } else { - emitDiagnosticText(grokSession.state.errorMessage); - } - } - resolve(); - }; - - const resetInactivityTimer = () => { - if (inactivityTimer) clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - setErrorMessage( - `Grok CLI stopped producing stdout for ${INACTIVITY_TIMEOUT_MS}ms during a headless prompt; the process was killed.`, - ); - forceKillGrokStream(proc); - finish(); - }, INACTIVITY_TIMEOUT_MS); - }; - - firstOutputTimer = setTimeout(() => { - if (firstOutputReceived) return; - setErrorMessage( - `Grok CLI produced no stdout within ${firstOutputTimeoutMs}ms for a headless prompt; the process was killed.`, - ); - forceKillGrokStream(proc); - finish(); - }, firstOutputTimeoutMs); - - proc.stdout?.on("data", (chunk: Buffer | string) => { - const text = chunk.toString(); - if (!firstOutputReceived) { - firstOutputReceived = true; - firstStdoutChunk = text; - if (firstOutputTimer) clearTimeout(firstOutputTimer); - } - stdout += text; - resetInactivityTimer(); - }); - - proc.stderr?.on("data", (chunk: Buffer | string) => { - // FNXC:GrokCli 2026-07-10-15:10: FN-7779 root-cause — xAI's Grok - // Build TUI writes fatal, pre-JSON failures (missing API key, - // invalid flag, auth error) to stderr with no JSON on stdout. - // Reading stdout alone would lose the entire failure reason, so - // stderr is captured for both the FN-7796 close diagnostic and the - // FN-7779 silent-failure fallback below. Capped to avoid unbounded - // growth on a pathologically chatty process. - if (stderr.length < 8192) stderr += chunk.toString(); - }); - - proc.on("error", (procError) => { - // FNXC:GrokCli 2026-07-10-15:10: FN-7779 root-cause — spawn/runtime - // process error (e.g. ENOENT for a missing `grok` binary) previously - // resolved into an empty bubble; surface the reason both on - // session.state (unchanged historical format) and as visible text - // via finish()'s silent-failure fallback. - const message = procError instanceof Error ? procError.message : String(procError); - if (!assistantText) { - setErrorMessage(compactDiagnostic(`Grok CLI process error: ${message}`)); - } - if (!contentEmitted && !stderr) { - stderr = describeSpawnFailure(procError); - } - finish(); - }); - - proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => { - const parsed = parsePromptOutput(stdout); - if (parsed.parsed) { - emitParsedOutput(parsed); - } - - const failed = typeof code === "number" ? code !== 0 : Boolean(signal); - if (!assistantText && failed) { - setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr)); - } else if (!assistantText && !parsed.parsed && typeof code === "number" && code === 0) { - setErrorMessage(formatNoJsonDiagnostic(firstStdoutChunk)); - } - finish(code); - }); - }); + } else { + appendMessage(grokSession, "assistant", assistantText); + } + return; + } } describeModel(session: AgentSession): string { const grokSession = session as GrokSession; return grokSession.lastModelDescription || `grok/${grokSession.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 grok = session as GrokSession; + grok.dispose?.(); + } } diff --git a/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts b/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts new file mode 100644 index 0000000000..45aaea9650 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts @@ -0,0 +1,218 @@ +/* +FNXC:GrokAcp 2026-07-11-14:00: +Stage Fusion + session skills so Grok ACP discovers them the same way pi does. +Grok 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. +*/ + +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +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 GrokSkillStagingResult { + pluginDir: string; + skillNames: string[]; + dispose: () => void; +} + +function isSkillDir(dir: string): boolean { + return existsSync(join(dir, "SKILL.md")); +} + +export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + const here = fileURLToPath(moduleUrl); + const moduleDir = dirname(here); + return [ + // Monorepo source checkout: packages/cli/skill/fusion + resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME), + // Bundled CLI layout: dist/skill/fusion or sibling skill/ + resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME), + resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME), + resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME), + ]; +} + +export function resolveBundledFusionSkillSource(): string | null { + for (const candidate of getFusionSkillSourceCandidates()) { + 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 StageGrokSkillsOptions { + /** 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 Grok plugin directory with Fusion + requested skills. + */ +export function stageGrokSessionSkills(options: StageGrokSkillsOptions = {}): GrokSkillStagingResult { + const pluginDir = mkdtempSync(join(tmpdir(), "fusion-grok-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 Grok 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 Grok to use + * Fusion tools/MCP when available. + */ +export function buildGrokSkillRules(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-grok-runtime/src/stream-parser.ts b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts deleted file mode 100644 index a26e481aaf..0000000000 --- a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { GrokCliJsonResponse, GrokNdjsonEvent } from "./types.js"; - -/* -FNXC:GrokCli 2026-07-10-12:50: -FN-7796: xAI Grok Build TUI's `--output-format streaming-json` intermittently ends with `stopReason:"Cancelled"` and zero `text` events. The headless path now uses the reliable single-object `--output-format json` response, so parser callers should parse the complete stdout buffer into `{text,stopReason,sessionId,requestId,thought}` and treat invalid/partial buffers as absent output rather than throwing. -*/ - -/** - * Parse the complete stdout buffer from - * `grok -p --output-format json` into the real xAI Grok Build TUI - * response object, or null when the output is empty, non-JSON, a JSON array, - * or an unrelated object with none of the expected response fields. - */ -export function parseJsonOutput(output: string): GrokCliJsonResponse | null { - const trimmed = output.trim(); - if (!trimmed || !trimmed.startsWith("{")) { - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return null; - } - - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - - const candidate = parsed as Record; - const hasKnownField = ["text", "stopReason", "sessionId", "requestId", "thought"].some((key) => key in candidate); - if (!hasKnownField) { - return null; - } - - return { - text: typeof candidate.text === "string" ? candidate.text : undefined, - stopReason: typeof candidate.stopReason === "string" ? candidate.stopReason : undefined, - sessionId: typeof candidate.sessionId === "string" ? candidate.sessionId : undefined, - requestId: typeof candidate.requestId === "string" ? candidate.requestId : undefined, - thought: typeof candidate.thought === "string" ? candidate.thought : undefined, - }; -} - -const STREAMING_EVENT_TYPES = new Set(["thought", "text", "end"]); - -/** - * Parse a single NDJSON line from the legacy/flaky - * `--output-format streaming-json` contract. The runtime no longer relies on - * this as its primary path, but retaining this parser lets deterministic - * regressions model the live-captured cancelled-no-text stream shape and - * produce a concrete diagnostic instead of treating it as arbitrary garbage. - */ -export function parseLine(line: string): GrokNdjsonEvent | null { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("{")) { - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return null; - } - - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - - const candidate = parsed as { type?: unknown }; - if (typeof candidate.type !== "string" || !STREAMING_EVENT_TYPES.has(candidate.type)) { - return null; - } - - return parsed as GrokNdjsonEvent; -} diff --git a/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts b/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts new file mode 100644 index 0000000000..0e208b1941 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts @@ -0,0 +1,178 @@ +/* +FNXC:GrokAcp 2026-07-11-14:00: +Host Fusion custom tools (fn_*) for the Grok ACP agent. ToolDefinition.execute +closures only work in-process, so GrokRuntimeAdapter starts a loopback HTTP +bridge and pairs it with fusion-tools-mcp-server.cjs (stdio MCP) that Grok +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 { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import type { AcpMcpServer } from "./mcp-forwarding.js"; + +const BUILT_IN_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "grep", "find"]); + +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 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 Grok should + * connect to for Fusion custom tools. Returns null when there are no tools. + */ +export async function startFusionToolBridge(tools: ReadonlyArray | undefined): 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-grok-mcp-schemas-${process.pid}-${randomUUID()}.json`); + writeFileSync(schemaPath, JSON.stringify(defs)); + + const server: Server = createServer(async (req, res) => { + if (req.method !== "POST" || req.url !== "/tool-call") { + res.statusCode = 404; + res.end(JSON.stringify({ isError: true, text: "not found" })); + return; + } + let body = ""; + for await (const chunk of req) body += chunk; + let parsed: { name?: string; arguments?: unknown }; + try { + parsed = JSON.parse(body || "{}") as { name?: string; arguments?: unknown }; + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ isError: true, text: "invalid JSON body" })); + return; + } + const name = typeof parsed.name === "string" ? parsed.name : ""; + const tool = byName.get(name); + if (!tool?.execute) { + res.statusCode = 404; + res.end(JSON.stringify({ isError: true, text: `Unknown Fusion tool: ${name}` })); + return; + } + try { + const result = await tool.execute(`grok-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 }], + }, + dispose: async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} diff --git a/plugins/fusion-plugin-grok-runtime/src/types.ts b/plugins/fusion-plugin-grok-runtime/src/types.ts index 4604255278..276a2fc110 100644 --- a/plugins/fusion-plugin-grok-runtime/src/types.ts +++ b/plugins/fusion-plugin-grok-runtime/src/types.ts @@ -1,44 +1,47 @@ /* -FNXC:GrokCli 2026-07-10-12:50: -FN-7796: xAI Grok Build TUI's `--output-format streaming-json` can emit reasoning-only events and then `stopReason:"Cancelled"` with zero assistant text. The primary headless contract is therefore the reliable single `--output-format json` object `{text,stopReason,sessionId,requestId,thought}`; streaming event types remain only for diagnostics/regressions that model the captured flaky shape. +FNXC:GrokAcp 2026-07-11-12:00: +Grok runtime now drives xAI Grok Build TUI over ACP (`grok 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`). */ -export interface GrokCliJsonResponse { - text?: string; - stopReason?: string; - sessionId?: string; - requestId?: string; - thought?: string; +/** Narrow permission gate view (structural copy; no @fusion/engine import). */ +export type GateDisposition = "allow" | "block" | "require-approval"; + +export interface PermissionGate { + permissionPolicy?: { + rules?: Record; + }; + createApprovalRequest?: ( + decision: unknown, + args: Record, + ) => Promise | unknown; + findApprovalByDedupeKey?: ( + dedupeKey: string, + ) => Promise<{ id: string; status: string } | null> | { id: string; status: string } | null; + pauseForApproval?: (info: { + approvalRequestId: string; + decision: unknown; + }) => Promise | void; + markApprovalCompleted?: (approvalRequestId: string) => Promise | void; } - -export interface GrokThoughtEvent { - type: "thought"; - data: string; +export interface AcpMcpServer { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; } -export interface GrokTextEvent { - type: "text"; - data: string; -} - -export interface GrokEndEvent { - type: "end"; - stopReason?: string; - sessionId?: string; - requestId?: string; -} - -export type GrokNdjsonEvent = GrokThoughtEvent | GrokTextEvent | GrokEndEvent; - export interface GrokCallbacks { - /** Streams real assistant text from xAI Grok Build TUI `text.data` events. */ + /** Streams assistant text deltas from ACP `agent_message_chunk` updates. */ onText?: (text: string) => void; - /** Streams reasoning/thinking text from xAI Grok Build TUI `thought.data` events. */ + /** Streams reasoning from ACP `agent_thought_chunk` updates. */ onThinking?: (text: string) => void; - /** Kept for AgentRuntime interface parity; xAI `streaming-json` has no observed tool-use event. */ + /** ACP `tool_call` / start of a tool invocation. */ onToolStart?: (toolName: string, args?: unknown) => void; - /** Kept for AgentRuntime interface parity; xAI `streaming-json` has no observed tool-use event. */ + /** ACP `tool_call_update` terminal status. */ onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; } @@ -50,6 +53,10 @@ export interface GrokSession { sessionId?: string; lastModelDescription: string; callbacks: GrokCallbacks; + /** Live ACP connection when createSession succeeded (composed AcpSession). */ + connection?: unknown; + resetTurn?: () => void; + dispose?: () => void; } export type AgentSession = GrokSession; @@ -57,12 +64,25 @@ export type AgentSession = GrokSession; 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 { @@ -70,11 +90,19 @@ export interface AgentSessionResult { 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; + promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise; describeModel(session: AgentSession): string; dispose?(session: AgentSession): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0fbe00278..c7cf03ecff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,10 +50,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.80.6 - version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: ^0.80.6 - version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -487,10 +487,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@fusion-plugin-examples/droid-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-droid-runtime @@ -975,12 +975,18 @@ importers: plugins/fusion-plugin-grok-runtime: dependencies: + '@agentclientprotocol/sdk': + specifier: 0.24.0 + version: 0.24.0(zod@4.3.6) '@earendil-works/pi-ai': specifier: '*' version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: '*' version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk @@ -7732,10 +7738,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 - '@anthropic-ai/sdk@0.91.1': - dependencies: - json-schema-to-ts: 3.1.1 - '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -8470,20 +8472,6 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8540,20 +8528,6 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8583,30 +8557,10 @@ snapshots: - zod '@earendil-works/pi-ai@0.77.0': - dependencies: - '@anthropic-ai/sdk': 0.91.1 - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.1 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0 - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -8684,31 +8638,10 @@ snapshots: - zod '@earendil-works/pi-ai@0.80.6': - dependencies: - '@anthropic-ai/sdk': 0.91.1 - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) - '@opentelemetry/api': 1.9.0 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0 - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-ai@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -8750,7 +8683,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -8796,35 +8729,6 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-tui': 0.77.0 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - typebox: 1.1.38 - undici: 8.3.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8943,36 +8847,6 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-tui': 0.80.6 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - semver: 7.8.0 - typebox: 1.1.38 - undici: 8.5.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -9364,30 +9238,6 @@ snapshots: '@exodus/bytes@1.15.0': {} - '@google/genai@1.52.0': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.0 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -9916,29 +9766,6 @@ snapshots: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.12(hono@4.12.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.9 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - supports-color - optional: true - '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -10717,7 +10544,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.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)) + 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.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -14008,8 +13835,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.26.0: {} - openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0