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.
This commit is contained in:
7
.changeset/grok-acp-transport.md
Normal file
7
.changeset/grok-acp-transport.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
<!--
|
||||
FNXC:GrokCli 2026-07-10-12:58:
|
||||
FN-7796 supersedes FN-7790's streaming assumption. Operators run xAI's official Grok Build TUI (`grok 0.2.93`); its `--output-format streaming-json` path intermittently ends `stopReason:"Cancelled"` with zero `text` events, so Fusion's reliable headless prompt path invokes `grok -p <prompt> --output-format json` and parses the single `{text,stopReason,sessionId,requestId,thought}` object. A non-`EndTurn` stop reason with empty text is a concrete diagnostic, never a silent no-message response.
|
||||
FNXC:GrokAcp 2026-07-11-12:00:
|
||||
Agent session transport is native ACP (`grok agent stdio`) for realtime
|
||||
session/update streaming, tool visibility, multi-turn session reuse, and Fusion
|
||||
permission-gate integration. The previous one-shot `grok -p --output-format json`
|
||||
path is retired as the primary prompt transport because it buffered until
|
||||
subprocess close and could not surface tool calls. Probe (`grok --version`) and
|
||||
model discovery (`grok models`) are unchanged.
|
||||
-->
|
||||
|
||||
## 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 <text> --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 <session-plugin> -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", "<session-plugin>", …, "stdio"]` (optional `-m <id>`) |
|
||||
| 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
|
||||
|
||||
<!--
|
||||
FNXC:GrokAcp 2026-07-11-14:00:
|
||||
Parity with pi sessions: executor/chat lanes pass customTools + skillSelection +
|
||||
mcpServers through createResolvedAgentSession. Grok ACP must not drop them.
|
||||
-->
|
||||
|
||||
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: <empty>
|
||||
stderr:
|
||||
error: unexpected argument '--prompt' found
|
||||
|
||||
tip: a similar argument exists: '--prompt-file'
|
||||
|
||||
Usage: grok --prompt-file <PATH> [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 "<text>" --output-format json
|
||||
# equivalent long prompt flag:
|
||||
grok --single "<text>" --output-format json
|
||||
grok --version
|
||||
```
|
||||
|
||||
Supported companion flags used by Fusion:
|
||||
|
||||
- `-p, --single <PROMPT>` — run a single prompt, print the response, and exit. This does not require interactive stdin.
|
||||
- `--output-format <plain|json|streaming-json>` — Fusion uses `json` for reliable headless prompts.
|
||||
- `-m, --model <MODEL>` — optional concrete model id. Fusion omits this for the model-less `grok/default` Runtime-mode path.
|
||||
- `--cwd <CWD>` — optional working directory. This replaces the wrong-product `--directory` flag.
|
||||
|
||||
Other observed flags include `--prompt-file <PATH>`, `--prompt-json <JSON>`, `-s/--session-id <UUID>`, `--sandbox <PROFILE>`, `--system-prompt-override <PROMPT>`, and `--max-turns <N>`, 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/<id>` or `grok/<id>` model is normalized to `<id>` and passed to the CLI as `-m <id>`. The explicit no-model Runtime-mode path keeps `grok/default` and omits `-m`.
|
||||
The selected `grok-cli/<id>` or `grok/<id>` model is normalized to `<id>` and passed as `grok agent -m <id> 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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string>(THINKING_LEVELS);
|
||||
|
||||
|
||||
@@ -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<string, unknown> }>;
|
||||
promptWithFallback: (
|
||||
session: Record<string, unknown>,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
) => Promise<void | { stopReason?: string }>;
|
||||
describeModel: (session: unknown) => string;
|
||||
dispose?: (session: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
type GrokRuntimeAdapterCtor = new (options?: {
|
||||
binary?: string;
|
||||
spawn?: (binary: string, prompt: string, options?: { cwd?: string; model?: string; signal?: AbortSignal }) => unknown;
|
||||
createAcpAdapter?: (settings: Record<string, unknown>) => FakeAcpAdapter;
|
||||
}) => {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -65,14 +80,53 @@ async function loadGrokRuntimeAdapter(): Promise<GrokRuntimeAdapterCtor> {
|
||||
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<typeof vi.fn> } {
|
||||
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<string, unknown>[];
|
||||
}): (settings: Record<string, unknown>) => FakeAcpAdapter {
|
||||
const promptBehavior = options?.promptBehavior ?? "text";
|
||||
const settingsOut = options?.settingsOut;
|
||||
return (settings) => {
|
||||
settingsOut?.push(settings);
|
||||
let capturedOnText: ((t: string) => void) | undefined;
|
||||
const sessionShell: Record<string, unknown> = {
|
||||
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> = {}): PluginRunner {
|
||||
@@ -91,7 +145,7 @@ function createMockPluginRunner(overrides: Partial<PluginRunner> = {}): PluginRu
|
||||
}
|
||||
|
||||
async function createGrokRegistration(
|
||||
spawnFn: ReturnType<typeof vi.fn>,
|
||||
createAcpAdapter: (settings: Record<string, unknown>) => 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<string, unknown>[] = [];
|
||||
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<void> };
|
||||
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<void>;
|
||||
};
|
||||
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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
require?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
@@ -125,6 +136,7 @@ export function resolveCliSettings(settings?: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>)
|
||||
: { headless: true };
|
||||
const require = obj.require === true;
|
||||
if (!preferMethods && !methodId && !require) return undefined;
|
||||
return {
|
||||
...(preferMethods ? { preferMethods } : {}),
|
||||
...(methodId ? { methodId } : {}),
|
||||
meta,
|
||||
require,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
require?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => Error): Promise<T> {
|
||||
@@ -318,6 +331,25 @@ export async function connect(opts: ConnectOptions): Promise<AcpConnection> {
|
||||
? 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<AcpConnection> {
|
||||
};
|
||||
}
|
||||
|
||||
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<AcpConnection, "conn" | "authMethods">,
|
||||
opts: {
|
||||
preferMethods?: string[];
|
||||
methodId?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
},
|
||||
): Promise<NewAcpSessionResult> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown> }).sessionMeta
|
||||
: undefined;
|
||||
const opened = await newAcpSession(connection, {
|
||||
cwd: options.cwd,
|
||||
mcpServers: options.mcpServers,
|
||||
meta: sessionMeta,
|
||||
});
|
||||
sessionId = opened.sessionId;
|
||||
} catch (err) {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** Live ACP session state tracked by the runtime adapter. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <id>`, then `Available models:`, then `* <id> (default)` / `- <id>` 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 "<text>" --output-format json
|
||||
# with optional model/cwd:
|
||||
grok -p "<text>" --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 <PROMPT>` 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 <text> --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/<id>` (or `grok/<id>`) to `<id>` and sent as `-m <id>`.
|
||||
**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/<id>` (or `grok/<id>`) to `<id>` and sent as `grok agent -m <id> 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.
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
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" }));
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
} {
|
||||
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<void | { stopReason?: string }>;
|
||||
settingsOut?: Record<string, unknown>[];
|
||||
}): 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<AgentSessionResult> => {
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown> | undefined;
|
||||
const adapter = new GrokRuntimeAdapter({
|
||||
createAcpAdapter: (settings) => {
|
||||
const base = makeFakeAcpAdapter()(settings);
|
||||
return {
|
||||
...base,
|
||||
createSession: async (options) => {
|
||||
captured = options as Record<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>[] = [];
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
161
plugins/fusion-plugin-grok-runtime/src/acp-settings.ts
Normal file
161
plugins/fusion-plugin-grok-runtime/src/acp-settings.ts
Normal file
@@ -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 <dir>…] [-m <model>] 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<string, unknown> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
30
plugins/fusion-plugin-grok-runtime/src/acp/VENDORED.md
Normal file
30
plugins/fusion-plugin-grok-runtime/src/acp/VENDORED.md
Normal file
@@ -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.
|
||||
188
plugins/fusion-plugin-grok-runtime/src/acp/cli-spawn.ts
Normal file
188
plugins/fusion-plugin-grok-runtime/src/acp/cli-spawn.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>)
|
||||
: { 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<string, unknown>): 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"] };
|
||||
}
|
||||
302
plugins/fusion-plugin-grok-runtime/src/acp/control-handler.ts
Normal file
302
plugins/fusion-plugin-grok-runtime/src/acp/control-handler.ts
Normal file
@@ -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<string, unknown>)
|
||||
: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the HITL approval flow for an arbitrary `require-approval` action,
|
||||
* identified by a category + dedupe key (not necessarily an ACP `toolCall`).
|
||||
*
|
||||
* Exported so the fs `writeTextFile` path (U7) routes its `file_write_delete`
|
||||
* gating through the IDENTICAL approval machinery as U5 — register, block on
|
||||
* `pauseForApproval`, re-read the final status, finalize — with the same
|
||||
* default-deny floor when no human channel exists. Never throws, never allows
|
||||
* on failure.
|
||||
*/
|
||||
export async function runApprovalForCategory(
|
||||
gate: PermissionGate,
|
||||
req: {
|
||||
category: FusionCategory;
|
||||
toolName: string;
|
||||
dedupeKey: string;
|
||||
args?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<"allow" | "deny"> {
|
||||
const { category, dedupeKey } = req;
|
||||
if (typeof gate.createApprovalRequest !== "function") {
|
||||
// No human channel available → default-deny.
|
||||
return "deny";
|
||||
}
|
||||
|
||||
const decisionPayload = {
|
||||
disposition: "require-approval" as const,
|
||||
category,
|
||||
toolName: req.toolName,
|
||||
approvalDedupeKey: dedupeKey,
|
||||
};
|
||||
|
||||
const mapStatus = (status: ApprovalStatus | undefined): "allow" | "deny" =>
|
||||
status === "approved" ? "allow" : "deny";
|
||||
|
||||
try {
|
||||
// Reuse a prior decision for an identical call when available.
|
||||
if (typeof gate.findApprovalByDedupeKey === "function") {
|
||||
const prior = await gate.findApprovalByDedupeKey(dedupeKey);
|
||||
if (prior && (prior.status === "approved" || prior.status === "denied")) {
|
||||
return mapStatus(prior.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Default-deny BEFORE creating a request when the HITL round-trip cannot
|
||||
// complete: without `pauseForApproval` we cannot block for a decision, and
|
||||
// without `findApprovalByDedupeKey` we cannot READ the decision after the
|
||||
// pause — a human approval would be silently discarded (mapStatus(undefined)
|
||||
// → deny). Denying upfront never orphans a pending record and never wastes
|
||||
// a human's approval on an outcome that would be denied anyway.
|
||||
if (
|
||||
typeof gate.pauseForApproval !== "function" ||
|
||||
typeof gate.findApprovalByDedupeKey !== "function"
|
||||
) {
|
||||
return "deny";
|
||||
}
|
||||
|
||||
const created = (await gate.createApprovalRequest(
|
||||
decisionPayload,
|
||||
req.args ?? {},
|
||||
)) as { id?: string } | undefined;
|
||||
const approvalRequestId = typeof created?.id === "string" ? created.id : dedupeKey;
|
||||
|
||||
await gate.pauseForApproval({ approvalRequestId, decision: decisionPayload });
|
||||
|
||||
// Re-read the final status after the pause resolves.
|
||||
let finalStatus: ApprovalStatus | undefined;
|
||||
if (typeof gate.findApprovalByDedupeKey === "function") {
|
||||
const resolved = await gate.findApprovalByDedupeKey(dedupeKey);
|
||||
finalStatus = resolved?.status;
|
||||
}
|
||||
|
||||
if (typeof gate.markApprovalCompleted === "function") {
|
||||
await gate.markApprovalCompleted(approvalRequestId);
|
||||
}
|
||||
|
||||
return mapStatus(finalStatus);
|
||||
} catch {
|
||||
// Any HITL failure (timeout/dismiss/store error) → default-deny, no throw.
|
||||
return "deny";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full per-call security floor: classify → read the per-category
|
||||
* disposition → run HITL for `require-approval` → select an `allow_once`-only
|
||||
* option → build the ACP response.
|
||||
*
|
||||
* Default-deny on: missing gate, missing `permissionPolicy`, unmappable kind,
|
||||
* `require-approval` without a resolvable approver, or a missing `allow_once`
|
||||
* option.
|
||||
*/
|
||||
export interface ResolvePermissionOptions {
|
||||
/**
|
||||
* Risk S1 acknowledgement. When false (the safe default), a blanket `allow`
|
||||
* disposition on a *sensitive* category is escalated to `require-approval`
|
||||
* rather than auto-approved — so the shipped `unrestricted` default policy
|
||||
* does not silently green-light an untrusted agent's command/file/network
|
||||
* calls. The user opts out of the escalation by acknowledging the risk.
|
||||
*/
|
||||
allowUnrestricted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category disposition with the Risk S1 acknowledgement escalation applied:
|
||||
* a *sensitive* category the policy would `allow` is upgraded to
|
||||
* `require-approval` unless `allowUnrestricted` is set. `exempt` (read-only)
|
||||
* never escalates. Exported so the fs write path applies the identical rule.
|
||||
*/
|
||||
export function effectiveDisposition(
|
||||
category: FusionCategory | "exempt",
|
||||
gate: PermissionGate,
|
||||
opts?: ResolvePermissionOptions,
|
||||
): GateDisposition {
|
||||
const disposition = dispositionFor(category, gate);
|
||||
if (disposition === "allow" && category !== "exempt" && opts?.allowUnrestricted !== true) {
|
||||
return "require-approval";
|
||||
}
|
||||
return disposition;
|
||||
}
|
||||
|
||||
export async function resolvePermission(
|
||||
toolCall: ToolCallUpdate,
|
||||
options: PermissionOption[],
|
||||
gate: PermissionGate | undefined,
|
||||
opts?: ResolvePermissionOptions,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
// No gate / no policy → default-deny.
|
||||
if (!gate || !gate.permissionPolicy) {
|
||||
return buildResponse(selectOption("deny", options));
|
||||
}
|
||||
|
||||
const category = classifyToolKind(toolCall?.kind);
|
||||
// Unmappable / missing / `other` kind → most-restrictive deny.
|
||||
if (category === DENY) {
|
||||
return buildResponse(selectOption("deny", options));
|
||||
}
|
||||
|
||||
// Per-category disposition + S1 acknowledgement escalation.
|
||||
const disposition = effectiveDisposition(category, gate, opts);
|
||||
|
||||
if (disposition === "allow") {
|
||||
return buildResponse(selectOption("allow", options));
|
||||
}
|
||||
if (disposition === "block") {
|
||||
return buildResponse(selectOption("deny", options));
|
||||
}
|
||||
|
||||
// require-approval → HITL (or default-deny when no human channel exists).
|
||||
const decision = await runApproval(toolCall, category as FusionCategory, gate);
|
||||
return buildResponse(selectOption(decision, options));
|
||||
}
|
||||
308
plugins/fusion-plugin-grok-runtime/src/acp/event-bridge.ts
Normal file
308
plugins/fusion-plugin-grok-runtime/src/acp/event-bridge.ts
Normal file
@@ -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<string, TrackedToolCall>();
|
||||
// Running text/thinking accumulators for delta-space repair across chunks.
|
||||
let textSoFar = "";
|
||||
let thinkingSoFar = "";
|
||||
// Cumulative chars forwarded (text+thinking) this turn (Risk S5).
|
||||
let cumulativeOutputChars = 0;
|
||||
// Whether the per-turn cap was hit and the single flag line already emitted.
|
||||
let outputCapFlagged = false;
|
||||
|
||||
function reset(): void {
|
||||
toolCalls.clear();
|
||||
textSoFar = "";
|
||||
thinkingSoFar = "";
|
||||
cumulativeOutputChars = 0;
|
||||
outputCapFlagged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a bounded toolCallId for use as a Map key, evicting the oldest entry
|
||||
* when the cap is exceeded so a flood of unique ids cannot grow memory without
|
||||
* limit. Returns the normalized id, or `undefined` when the id is empty.
|
||||
*/
|
||||
function setTracked(rawId: string, tracked: TrackedToolCall): string | undefined {
|
||||
const id = boundIdentifier(rawId);
|
||||
if (id === "") return undefined;
|
||||
// Re-insert moves an existing key to the tail (refresh recency); for a new
|
||||
// key, evict the oldest first so size stays bounded.
|
||||
if (!toolCalls.has(id) && toolCalls.size >= TOOL_CALL_MAP_CAP) {
|
||||
const oldest = toolCalls.keys().next().value;
|
||||
if (oldest !== undefined) toolCalls.delete(oldest);
|
||||
}
|
||||
toolCalls.set(id, tracked);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one sanitized + bounded delta through `emit`, honoring the per-turn
|
||||
* cumulative cap. Once the cap is exceeded, forwarding stops and a single
|
||||
* truncation flag line is emitted via `onThinking`.
|
||||
*/
|
||||
function forwardBounded(
|
||||
raw: string,
|
||||
prior: string,
|
||||
emit: (delta: string) => void,
|
||||
): string {
|
||||
if (outputCapFlagged) return prior;
|
||||
if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) {
|
||||
outputCapFlagged = true;
|
||||
callbacks.onThinking?.(
|
||||
"[output truncated: per-turn limit reached — further agent output suppressed]",
|
||||
);
|
||||
return prior;
|
||||
}
|
||||
// Sanitize control/ANSI (Risk S7) and bound the single chunk (Risk S5).
|
||||
const sanitized = boundString(stripControlSequences(raw), PER_CHUNK_CAP_CHARS);
|
||||
if (sanitized === "") return prior;
|
||||
const delta = normalizeStreamingDelta(prior, sanitized);
|
||||
cumulativeOutputChars += delta.length;
|
||||
emit(delta);
|
||||
return prior + delta;
|
||||
}
|
||||
|
||||
function emitText(content: ContentBlock | undefined): void {
|
||||
const raw = extractText(content);
|
||||
if (raw === undefined || raw === "") return;
|
||||
textSoFar = forwardBounded(raw, textSoFar, (delta) => callbacks.onText?.(delta));
|
||||
}
|
||||
|
||||
function emitThinking(content: ContentBlock | undefined): void {
|
||||
const raw = extractText(content);
|
||||
if (raw === undefined || raw === "") return;
|
||||
thinkingSoFar = forwardBounded(raw, thinkingSoFar, (delta) =>
|
||||
callbacks.onThinking?.(delta),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sanitize an agent-supplied tool title before it reaches a callback/log (S7). */
|
||||
function safeTitle(title: string | null | undefined): string | null | undefined {
|
||||
if (typeof title !== "string") return title;
|
||||
return boundString(stripControlSequences(title), PER_CHUNK_CAP_CHARS);
|
||||
}
|
||||
|
||||
function handleToolCall(update: Extract<SessionUpdate, { sessionUpdate: "tool_call" }>): void {
|
||||
if (typeof update.toolCallId !== "string") return;
|
||||
const title = safeTitle(update.title);
|
||||
const id = setTracked(update.toolCallId, { title, kind: update.kind, ended: false });
|
||||
if (id === undefined) return;
|
||||
const name = toolDisplayName({ title, kind: update.kind });
|
||||
callbacks.onToolStart?.(name, normalizeToolArgs(update.rawInput));
|
||||
}
|
||||
|
||||
function handleToolCallUpdate(
|
||||
update: Extract<SessionUpdate, { sessionUpdate: "tool_call_update" }>,
|
||||
): void {
|
||||
if (typeof update.toolCallId !== "string") return;
|
||||
const id = boundIdentifier(update.toolCallId);
|
||||
if (id === "") return;
|
||||
const tracked = toolCalls.get(id) ?? { ended: false };
|
||||
// Carry forward title/kind from the prior `tool_call` when this update omits
|
||||
// them (a partial update may only set status/output).
|
||||
if (update.title != null) tracked.title = safeTitle(update.title);
|
||||
if (update.kind != null) tracked.kind = update.kind;
|
||||
// `id` is already bounded above; setTracked re-keys with the same value.
|
||||
setTracked(id, tracked);
|
||||
|
||||
const status = update.status;
|
||||
if (status !== "completed" && status !== "failed") {
|
||||
// Intermediate (pending/in_progress) — tracking updated, no callback.
|
||||
return;
|
||||
}
|
||||
if (tracked.ended) return; // already fired a terminal callback
|
||||
tracked.ended = true;
|
||||
const name = toolDisplayName({ title: tracked.title, kind: tracked.kind });
|
||||
callbacks.onToolEnd?.(name, status === "failed", update.rawOutput);
|
||||
}
|
||||
|
||||
function handlePlan(entries: PlanEntry[] | undefined): void {
|
||||
// FULL REPLACEMENT: drop any prior snapshot, surface the new one once.
|
||||
// Plan output is charged against the same per-turn budget as text/thinking
|
||||
// (Risk S5): entry SIZE is bounded in formatPlan, but entry COUNT is
|
||||
// agent-controlled — without the cap below, one plan event with thousands
|
||||
// of entries bypasses the per-turn ceiling entirely.
|
||||
if (outputCapFlagged) return;
|
||||
// Enforce the ceiling on the plan path too: without this check a plan-ONLY
|
||||
// stream (no text/thinking ever entering forwardBounded) would keep
|
||||
// emitting forever after crossing the budget.
|
||||
if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) {
|
||||
outputCapFlagged = true;
|
||||
callbacks.onThinking?.(
|
||||
"[output truncated: per-turn limit reached — further agent output suppressed]",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const capped = list.slice(0, MAX_PLAN_ENTRIES);
|
||||
let line = formatPlan(capped);
|
||||
if (list.length > capped.length) {
|
||||
line += `\n- … ${list.length - capped.length} more entries truncated`;
|
||||
}
|
||||
line = boundString(line, PER_CHUNK_CAP_CHARS);
|
||||
cumulativeOutputChars += line.length;
|
||||
callbacks.onThinking?.(line);
|
||||
}
|
||||
|
||||
function handleSessionUpdate(update: SessionUpdate): void {
|
||||
if (!update || typeof update !== "object") return;
|
||||
try {
|
||||
switch (update.sessionUpdate) {
|
||||
case "agent_message_chunk":
|
||||
emitText(update.content);
|
||||
break;
|
||||
case "agent_thought_chunk":
|
||||
emitThinking(update.content);
|
||||
break;
|
||||
case "user_message_chunk":
|
||||
// Echo of user input — ignored in v1.
|
||||
break;
|
||||
case "tool_call":
|
||||
handleToolCall(update);
|
||||
break;
|
||||
case "tool_call_update":
|
||||
handleToolCallUpdate(update);
|
||||
break;
|
||||
case "plan":
|
||||
handlePlan(update.entries);
|
||||
break;
|
||||
case "plan_update":
|
||||
// The (experimental) `PlanUpdate` variant carries a `plan` field, NOT a
|
||||
// top-level `entries` array — so there is nothing here to map to our
|
||||
// entries-based snapshot. v1 treats it as a NO-OP rather than wiping the
|
||||
// prior plan: the full `plan` event remains the source of truth.
|
||||
break;
|
||||
case "plan_removed":
|
||||
// Clearing the plan: surface nothing.
|
||||
break;
|
||||
case "available_commands_update":
|
||||
case "current_mode_update":
|
||||
case "config_option_update":
|
||||
case "session_info_update":
|
||||
case "usage_update":
|
||||
// Stored/ignored in v1 — no callback surface.
|
||||
break;
|
||||
default:
|
||||
// Unknown/forward-compat tag — ignore without throwing.
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Tolerant: a malformed/partial update must never break the stream.
|
||||
}
|
||||
}
|
||||
|
||||
return { handleSessionUpdate, reset };
|
||||
}
|
||||
263
plugins/fusion-plugin-grok-runtime/src/acp/fs-capabilities.ts
Normal file
263
plugins/fusion-plugin-grok-runtime/src/acp/fs-capabilities.ts
Normal file
@@ -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<ReadTextFileResponse>;
|
||||
writeTextFile?: (params: WriteTextFileRequest) => Promise<WriteTextFileResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the `line`/`limit` window AND the hard byte ceiling to file content.
|
||||
*
|
||||
* `line` is 1-based (per the ACP schema). `limit` caps the number of lines. When
|
||||
* `limit` is absent or absurdly large the byte ceiling still bounds the result
|
||||
* so a multi-GB file can't be slurped into memory (S5).
|
||||
*/
|
||||
export function applyReadWindow(
|
||||
content: string,
|
||||
line: number | null | undefined,
|
||||
limit: number | null | undefined,
|
||||
maxBytes: number,
|
||||
): string {
|
||||
let out = content;
|
||||
const hasLine = typeof line === "number" && Number.isFinite(line) && line > 1;
|
||||
const hasLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
|
||||
|
||||
if (hasLine || hasLimit) {
|
||||
const lines = content.split("\n");
|
||||
const start = hasLine ? Math.floor(line as number) - 1 : 0;
|
||||
const end = hasLimit ? start + Math.floor(limit as number) : lines.length;
|
||||
out = lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
// Byte ceiling regardless of line/limit (truncate on a UTF-8 boundary-safe
|
||||
// basis by slicing the buffer then decoding).
|
||||
const buf = Buffer.from(out, "utf8");
|
||||
if (buf.byteLength > maxBytes) {
|
||||
out = buf.subarray(0, maxBytes).toString("utf8");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fs handlers, returning ONLY the ones enabled by settings. The
|
||||
* provider registers these on the `Client` impl iff the matching capability is
|
||||
* advertised (consistency invariant — KTD6).
|
||||
*/
|
||||
export function createFsHandlers(opts: FsHandlerOptions): FsHandlers {
|
||||
const readMaxBytes = opts.readMaxBytes ?? DEFAULT_READ_MAX_BYTES;
|
||||
const writeMaxBytes = opts.writeMaxBytes ?? DEFAULT_WRITE_MAX_BYTES;
|
||||
const handlers: FsHandlers = {};
|
||||
|
||||
if (opts.allowRead) {
|
||||
handlers.readTextFile = async (
|
||||
params: ReadTextFileRequest,
|
||||
): Promise<ReadTextFileResponse> => {
|
||||
const resolved = await assertPathWithinCwd(params.path, opts.cwd);
|
||||
// Secrets that legitimately live inside the worktree are still denied.
|
||||
if (isSecretPath(resolved)) {
|
||||
throw new PathJailError(
|
||||
"denied_secret",
|
||||
`read of secret-pattern file denied: ${resolved}`,
|
||||
);
|
||||
}
|
||||
// Reading git internals is also denied (config/token surface).
|
||||
if (isGitInternal(resolved)) {
|
||||
throw new PathJailError(
|
||||
"denied_git",
|
||||
`read of git-internal file denied: ${resolved}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic, symlink-safe open (TOCTOU defense), then read.
|
||||
const handle = await openWithinCwd(resolved, opts.cwd, fsConstants.O_RDONLY);
|
||||
try {
|
||||
const hasLimit =
|
||||
typeof params.limit === "number" &&
|
||||
Number.isFinite(params.limit) &&
|
||||
params.limit > 0;
|
||||
// DoS guard (FIX 4): a multi-GB file would OOM if we `readFile` the whole
|
||||
// thing before `applyReadWindow` truncates. When the file exceeds the byte
|
||||
// ceiling AND no bounding `limit` was supplied, read at most ceiling+1
|
||||
// bytes so memory stays bounded; the +1 still lets applyReadWindow apply
|
||||
// its truncation marker logic identically to a full read. A `limit` is
|
||||
// line-bounded and read in full (matches prior behavior).
|
||||
const stat = await handle.stat();
|
||||
let content: string;
|
||||
if (!hasLimit && stat.size > readMaxBytes) {
|
||||
const buf = Buffer.alloc(readMaxBytes + 1);
|
||||
const { bytesRead } = await handle.read(buf, 0, readMaxBytes + 1, 0);
|
||||
content = buf.subarray(0, bytesRead).toString("utf8");
|
||||
} else {
|
||||
content = await handle.readFile({ encoding: "utf8" });
|
||||
}
|
||||
return {
|
||||
content: applyReadWindow(content, params.line, params.limit, readMaxBytes),
|
||||
};
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.allowWrite) {
|
||||
handlers.writeTextFile = async (
|
||||
params: WriteTextFileRequest,
|
||||
): Promise<WriteTextFileResponse> => {
|
||||
const content = typeof params.content === "string" ? params.content : "";
|
||||
// Size ceiling BEFORE any filesystem work (S5).
|
||||
if (Buffer.byteLength(content, "utf8") > writeMaxBytes) {
|
||||
throw new FsContentTooLargeError(writeMaxBytes);
|
||||
}
|
||||
|
||||
const resolved = await assertPathWithinCwd(params.path, opts.cwd);
|
||||
|
||||
// HARD-reject writes to git internals (.git/**) — RCE/token surface (S3).
|
||||
if (isGitInternal(resolved)) {
|
||||
throw new PathJailError(
|
||||
"denied_git",
|
||||
`write to git-internal path hard-rejected: ${resolved}`,
|
||||
);
|
||||
}
|
||||
// Never let an agent overwrite a secret either.
|
||||
if (isSecretPath(resolved)) {
|
||||
throw new PathJailError(
|
||||
"denied_secret",
|
||||
`write to secret-pattern file denied: ${resolved}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Route the write through the action gate as `file_write_delete` (U5):
|
||||
// allow → proceed, block → reject, require-approval → HITL (or
|
||||
// default-deny when no human channel). Reuses the U5 helpers so the
|
||||
// security floor stays single-sourced.
|
||||
const gate = opts.gate;
|
||||
const disposition = gate?.permissionPolicy
|
||||
? effectiveDisposition("file_write_delete", gate, {
|
||||
allowUnrestricted: opts.allowUnrestricted,
|
||||
})
|
||||
: "require-approval";
|
||||
|
||||
if (disposition === "block") {
|
||||
throw new FsWriteDeniedError(
|
||||
`file_write_delete is blocked by policy: ${resolved}`,
|
||||
);
|
||||
}
|
||||
if (disposition === "require-approval") {
|
||||
const decision = gate
|
||||
? await runApprovalForCategory(gate, {
|
||||
category: "file_write_delete",
|
||||
toolName: "fs/write_text_file",
|
||||
dedupeKey: `fs_write|${resolved}`,
|
||||
args: { path: resolved },
|
||||
})
|
||||
: "deny";
|
||||
if (decision !== "allow") {
|
||||
throw new FsWriteDeniedError(
|
||||
`file_write_delete write requires approval and was not granted: ${resolved}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// disposition === "allow" → proceed.
|
||||
|
||||
// Atomic, symlink-safe create within cwd. O_NOFOLLOW (in openWithinCwd)
|
||||
// guards ONLY the FINAL component; an intermediate dir swapped to a symlink
|
||||
// is still followed. We therefore must NOT pass O_TRUNC into open(): doing
|
||||
// so would TRUNCATE an escaped target BEFORE openWithinCwd's post-open
|
||||
// realpath re-validation gets to reject it (write-path TOCTOU, FIX 3).
|
||||
// Instead open create+write WITHOUT truncate, let openWithinCwd run its
|
||||
// re-validation, and ONLY truncate (via the fd) AFTER it has proven the
|
||||
// opened inode is still inside the jail.
|
||||
const handle = await openWithinCwd(
|
||||
resolved,
|
||||
opts.cwd,
|
||||
fsConstants.O_WRONLY | fsConstants.O_CREAT,
|
||||
0o644,
|
||||
);
|
||||
try {
|
||||
// Truncate-AFTER-validate: openWithinCwd returned only because the
|
||||
// re-validation passed, so it is now safe to empty the file and write.
|
||||
await handle.truncate(0);
|
||||
await handle.writeFile(content, { encoding: "utf8" });
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
return handlers;
|
||||
}
|
||||
16
plugins/fusion-plugin-grok-runtime/src/acp/index.ts
Normal file
16
plugins/fusion-plugin-grok-runtime/src/acp/index.ts
Normal file
@@ -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";
|
||||
229
plugins/fusion-plugin-grok-runtime/src/acp/path-jail.ts
Normal file
229
plugins/fusion-plugin-grok-runtime/src/acp/path-jail.ts
Normal file
@@ -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<string> {
|
||||
rejectMalformed(requestedPath);
|
||||
|
||||
// Realpath of the confinement root. If cwd itself can't be resolved, nothing
|
||||
// can be confined — treat as invalid.
|
||||
let realCwd: string;
|
||||
try {
|
||||
realCwd = await realpath(cwd);
|
||||
} catch {
|
||||
throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`);
|
||||
}
|
||||
|
||||
// Resolve the requested path lexically against cwd FIRST (handles `../`).
|
||||
const absRequested = path.resolve(realCwd, requestedPath);
|
||||
|
||||
// Try to realpath the target itself (exists case).
|
||||
let resolved: string;
|
||||
let targetExists = true;
|
||||
try {
|
||||
resolved = await realpath(absRequested);
|
||||
} catch {
|
||||
targetExists = false;
|
||||
// Non-existent target: validate the parent dir's realpath, keep the final
|
||||
// component name. The parent MUST exist and resolve inside cwd.
|
||||
const parent = path.dirname(absRequested);
|
||||
let realParent: string;
|
||||
try {
|
||||
realParent = await realpath(parent);
|
||||
} catch {
|
||||
throw new PathJailError(
|
||||
"path_outside_cwd",
|
||||
`parent directory does not resolve: ${parent}`,
|
||||
);
|
||||
}
|
||||
if (!isWithin(realCwd, realParent)) {
|
||||
throw new PathJailError(
|
||||
"path_outside_cwd",
|
||||
`resolved parent escapes cwd: ${realParent}`,
|
||||
);
|
||||
}
|
||||
resolved = path.join(realParent, path.basename(absRequested));
|
||||
}
|
||||
|
||||
if (!isWithin(realCwd, resolved)) {
|
||||
throw new PathJailError(
|
||||
"path_outside_cwd",
|
||||
`resolved path escapes cwd: ${resolved}`,
|
||||
);
|
||||
}
|
||||
|
||||
// For a non-existent target, the final component must not already be a
|
||||
// (dangling) symlink that a later open could follow out of the jail.
|
||||
if (!targetExists) {
|
||||
try {
|
||||
const st = await lstat(resolved);
|
||||
if (st.isSymbolicLink()) {
|
||||
throw new PathJailError(
|
||||
"path_outside_cwd",
|
||||
`final component is a symlink: ${resolved}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof PathJailError) throw err;
|
||||
// ENOENT for a not-yet-created file is expected — fine to proceed.
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a jail-validated path atomically (TOCTOU defense, Risk S3 threat 3).
|
||||
*
|
||||
* `safePath` MUST be the output of `assertPathWithinCwd`. We open with
|
||||
* `O_NOFOLLOW` so the FINAL component is never followed if it was swapped for a
|
||||
* symlink between check and open, then `fstat` + realpath-via-fd re-validate the
|
||||
* actually-opened inode is still inside `realCwd`. On any mismatch we close and
|
||||
* throw rather than operate on an escaped handle.
|
||||
*/
|
||||
export async function openWithinCwd(
|
||||
safePath: string,
|
||||
cwd: string,
|
||||
flags: number,
|
||||
mode?: number,
|
||||
): Promise<FileHandle> {
|
||||
let realCwd: string;
|
||||
try {
|
||||
realCwd = await realpath(cwd);
|
||||
} catch {
|
||||
throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`);
|
||||
}
|
||||
|
||||
const handle = await open(safePath, flags | fsConstants.O_NOFOLLOW, mode);
|
||||
try {
|
||||
// Re-validate the opened inode's real path is still within the jail. On
|
||||
// Linux `/proc/self/fd/<fd>` would work; portably we realpath the safePath
|
||||
// again now that O_NOFOLLOW proved the final component isn't a symlink — any
|
||||
// intermediate swap would change this resolution.
|
||||
const reReal = await realpath(safePath);
|
||||
if (!isWithin(realCwd, reReal)) {
|
||||
throw new PathJailError(
|
||||
"path_outside_cwd",
|
||||
`opened path escapes cwd after open: ${reReal}`,
|
||||
);
|
||||
}
|
||||
return handle;
|
||||
} catch (err) {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
163
plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts
Normal file
163
plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts
Normal file
@@ -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<ChildProcess>();
|
||||
|
||||
/**
|
||||
* Register a subprocess in the agent process registry.
|
||||
* Auto-removed from the registry when it exits.
|
||||
*/
|
||||
export function registerProcess(child: ChildProcess): void {
|
||||
activeProcesses.add(child);
|
||||
child.on("exit", () => activeProcesses.delete(child));
|
||||
}
|
||||
|
||||
/** Remove a subprocess from the registry (idempotent). */
|
||||
export function unregisterProcess(child: ChildProcess): void {
|
||||
activeProcesses.delete(child);
|
||||
}
|
||||
|
||||
/** Number of registered (presumed-live) agent subprocesses — for diagnostics/tests. */
|
||||
export function activeProcessCount(): number {
|
||||
return activeProcesses.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a subprocess via SIGKILL. No-op if already dead (killed or exited).
|
||||
* Cross-platform safe: Node treats SIGKILL as forceful termination on Windows.
|
||||
*/
|
||||
export function forceKill(child: ChildProcess): void {
|
||||
if (child.killed || child.exitCode !== null) return;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill every registered agent subprocess and clear the registry.
|
||||
*
|
||||
* Scoped to agent subprocesses tracked here only — never the dashboard / port
|
||||
* 4040 / any other process (KTD4 / kill-guard conventions). Safe to call
|
||||
* repeatedly; no-ops on already-dead processes.
|
||||
*/
|
||||
export function killAllProcesses(): void {
|
||||
for (const child of activeProcesses) {
|
||||
forceKill(child);
|
||||
}
|
||||
activeProcesses.clear();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
50
plugins/fusion-plugin-grok-runtime/src/acp/prompt-builder.ts
Normal file
50
plugins/fusion-plugin-grok-runtime/src/acp/prompt-builder.ts
Normal file
@@ -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;
|
||||
}
|
||||
540
plugins/fusion-plugin-grok-runtime/src/acp/provider.ts
Normal file
540
plugins/fusion-plugin-grok-runtime/src/acp/provider.ts
Normal file
@@ -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<RequestPermissionResponse> {
|
||||
// A request arriving after cancel is answered cancelled immediately.
|
||||
if (cancelled) return cancelledResponse;
|
||||
|
||||
// Race the real gate resolution against a cancel-drain so an in-flight
|
||||
// request is answered the moment teardown drains it (never deadlocks).
|
||||
return await new Promise<RequestPermissionResponse>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (response: RequestPermissionResponse) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
pending.delete(drain);
|
||||
resolve(response);
|
||||
};
|
||||
const drain = (response: RequestPermissionResponse) => finish(response);
|
||||
pending.add(drain);
|
||||
|
||||
resolvePermission(params.toolCall, params.options, gate, permissionOpts).then(
|
||||
(response) => finish(response),
|
||||
// resolvePermission never rejects, but stay safe: deny-by-cancel.
|
||||
() => finish(cancelledResponse),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Register fs handlers ONLY when enabled, so the advertised capability and the
|
||||
// present handler stay consistent (KTD6). If a capability is disabled the
|
||||
// method is absent → an agent calling it gets a JSON-RPC method-not-found
|
||||
// error (never a silent success).
|
||||
if (fsHandlers.readTextFile) handler.readTextFile = fsHandlers.readTextFile;
|
||||
if (fsHandlers.writeTextFile) handler.writeTextFile = fsHandlers.writeTextFile;
|
||||
|
||||
return { handler, cancelPending, resetTurn: () => bridge.reset() };
|
||||
}
|
||||
|
||||
export interface AcpConnection {
|
||||
/** Live ACP connection — later units drive session/new, prompt, cancel, load. */
|
||||
conn: ClientSideConnection;
|
||||
child: ChildProcess;
|
||||
agentCapabilities?: AgentCapabilities;
|
||||
/** Auth methods the agent advertised; non-empty means auth is required. */
|
||||
authMethods: Array<{ id: string }>;
|
||||
/** Current redacted stderr buffer. */
|
||||
stderr(): string;
|
||||
/** Force-kill the agent via the registry (KTD4a — SIGKILL is authoritative). */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ConnectOptions {
|
||||
binaryPath: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
clientHandler?: Client;
|
||||
/** Advertise fs capabilities ONLY where the toggle is true (KTD6). */
|
||||
advertiseFs: { read: boolean; write: boolean };
|
||||
initializeTimeoutMs?: number;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
require?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => Error): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(onTimeout()), ms);
|
||||
timer.unref?.();
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the agent, establish a `ClientSideConnection` over its stdio, and
|
||||
* complete the `initialize` handshake under a timeout.
|
||||
*
|
||||
* Throws `HandshakeTimeoutError` on timeout, `IncompatibleProtocolError` when
|
||||
* the negotiated integer protocol version mismatches — in both cases the
|
||||
* subprocess is force-killed before throwing (no orphans, KTD4a). On `initialize`
|
||||
* the fs capability flags are gated by `advertiseFs` and never hardcoded (KTD6).
|
||||
*/
|
||||
export async function connect(opts: ConnectOptions): Promise<AcpConnection> {
|
||||
const timeoutMs = opts.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const child = spawnAgent({
|
||||
binaryPath: opts.binaryPath,
|
||||
args: opts.args,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
});
|
||||
const stderr = captureStderr(child);
|
||||
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
forceKill(child);
|
||||
unregisterProcess(child);
|
||||
};
|
||||
|
||||
// If the binary is missing, spawn emits "error" asynchronously. Surface that
|
||||
// as a rejection of the handshake rather than an unhandled event-loop error.
|
||||
let spawnError: Error | undefined;
|
||||
const spawnErrored = new Promise<never>((_resolve, reject) => {
|
||||
child.once("error", (err: Error) => {
|
||||
spawnError = err;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
// Avoid an unhandled rejection if the handshake resolves/throws first.
|
||||
spawnErrored.catch(() => undefined);
|
||||
|
||||
// output = the agent's stdin; input = the agent's stdout.
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
);
|
||||
|
||||
const handler = opts.clientHandler ?? createDefaultClientHandler();
|
||||
const conn = new ClientSideConnection((_agent: Agent) => handler, stream);
|
||||
|
||||
let initResult: Awaited<ReturnType<ClientSideConnection["initialize"]>>;
|
||||
try {
|
||||
initResult = await Promise.race([
|
||||
withTimeout(
|
||||
conn.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: opts.advertiseFs.read === true,
|
||||
writeTextFile: opts.advertiseFs.write === true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
timeoutMs,
|
||||
() => new HandshakeTimeoutError(timeoutMs),
|
||||
),
|
||||
spawnErrored,
|
||||
]);
|
||||
} catch (err) {
|
||||
dispose();
|
||||
if (spawnError && err === spawnError) throw spawnError;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Compare the negotiated integer protocol version; do NOT assume the agent
|
||||
// errors first (KTD2).
|
||||
if (initResult.protocolVersion !== PROTOCOL_VERSION) {
|
||||
dispose();
|
||||
throw new IncompatibleProtocolError(initResult.protocolVersion);
|
||||
}
|
||||
|
||||
const authMethods = Array.isArray(initResult.authMethods)
|
||||
? initResult.authMethods.map((m) => ({ id: m.id }))
|
||||
: [];
|
||||
|
||||
/*
|
||||
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<AcpConnection, "conn" | "authMethods">,
|
||||
opts: {
|
||||
preferMethods?: string[];
|
||||
methodId?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
},
|
||||
): Promise<NewAcpSessionResult> {
|
||||
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<StopReason> {
|
||||
const res = await connection.conn.prompt({ sessionId, prompt: blocks });
|
||||
return res.stopReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cancel of the active turn via the `session/cancel` notification.
|
||||
*
|
||||
* This is fire-and-forget (no ack in the protocol). Errors are swallowed — it
|
||||
* runs during teardown where the registry SIGKILL is the authoritative guarantee
|
||||
* (KTD4a).
|
||||
*/
|
||||
/** Upper bound on how long `cancelAcpSession` waits on the cancel write (FIX 7). */
|
||||
const CANCEL_TIMEOUT_MS = 2_000;
|
||||
|
||||
export async function cancelAcpSession(
|
||||
connection: AcpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
// `conn.cancel` writes to the agent's stdin pipe; a dead or full pipe can
|
||||
// back-pressure and stall teardown (the adapter awaits this BEFORE the
|
||||
// authoritative registry SIGKILL). Bound it so the kill still runs promptly
|
||||
// (FIX 7). Errors are swallowed — this is already best-effort.
|
||||
try {
|
||||
await Promise.race([
|
||||
connection.conn.cancel({ sessionId }),
|
||||
new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, CANCEL_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// fire-and-forget; teardown's SIGKILL is authoritative
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a session. Prefers `session/load` (history replay) when the agent
|
||||
* advertised the `loadSession` capability; otherwise falls back to opening a
|
||||
* fresh `session/new`. There is no separate `resume` method in this SDK build —
|
||||
* `loadSession` IS the resume path.
|
||||
*
|
||||
* NOTE (v1): engine-driven resume wiring is intentionally deferred — the
|
||||
* runtime adapter always opens a fresh session via `newAcpSession`. This helper
|
||||
* exists (and is unit-tested for the id-sanitization invariant) so resume can be
|
||||
* wired in by passing a `sessionId` through `AgentRuntimeOptions` later without
|
||||
* building new resume machinery.
|
||||
*/
|
||||
export async function loadAcpSession(
|
||||
connection: AcpConnection,
|
||||
opts: { sessionId: string; cwd: string },
|
||||
): Promise<NewAcpSessionResult> {
|
||||
if (readsLoadSession(connection)) {
|
||||
// Bound the (agent-originated) resume id before it is used as a protocol /
|
||||
// potential path component (U6/Risk S7).
|
||||
const safeId = boundIdentifier(opts.sessionId);
|
||||
const res = await connection.conn.loadSession({
|
||||
sessionId: safeId,
|
||||
cwd: opts.cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
return { sessionId: safeId, modes: res.modes ?? undefined };
|
||||
}
|
||||
return newAcpSession(connection, { cwd: opts.cwd });
|
||||
}
|
||||
183
plugins/fusion-plugin-grok-runtime/src/acp/runtime-adapter.ts
Normal file
183
plugins/fusion-plugin-grok-runtime/src/acp/runtime-adapter.ts
Normal file
@@ -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<string, unknown>) {
|
||||
this.settings = resolveCliSettings(settings);
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
const model = this.settings.model ?? options.defaultModelId ?? "acp";
|
||||
|
||||
// Bridge streamed `session/update` notifications onto the engine callbacks
|
||||
// (U4) so ACP agents render like existing runtimes.
|
||||
const callbacks = {
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
onToolEnd: options.onToolEnd,
|
||||
};
|
||||
|
||||
// Build the bridging client handler with the per-run permission gate (U5):
|
||||
// its `requestPermission` classifies each call per-category against the live
|
||||
// gate (KTD3a) and selects `allow_once` only (S2). `cancelPending` drains
|
||||
// in-flight permission requests on teardown so the agent never deadlocks.
|
||||
// fs client capabilities (U7) are gated by settings — reads opt-in, writes
|
||||
// default OFF (KTD6) — and confined to the task cwd by the path jail. The
|
||||
// same toggles drive the advertised `fs` capability in connect() below, so
|
||||
// advertisement and registered handlers stay consistent.
|
||||
const { handler: clientHandler, cancelPending, resetTurn } = createBridgingClientHandler(
|
||||
callbacks,
|
||||
options.actionGateContext,
|
||||
{
|
||||
cwd: options.cwd,
|
||||
allowRead: this.settings.fsRead,
|
||||
allowWrite: this.settings.fsWrite,
|
||||
},
|
||||
// Risk S1: unless the user acknowledged the untrusted-agent risk, a blanket
|
||||
// `allow` on a sensitive category is escalated to approval rather than
|
||||
// auto-approved — so the default `unrestricted` policy can't silently
|
||||
// green-light this untrusted subprocess.
|
||||
{ allowUnrestricted: this.settings.allowUnrestricted },
|
||||
);
|
||||
|
||||
// Spawn + initialize (U2). fs capabilities are advertised only where the
|
||||
// resolved settings enable them (KTD6); the subprocess env is built from the
|
||||
// allow-list, never inherited process.env (KTD6b).
|
||||
// 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<string, unknown> }).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<void> {
|
||||
// KTD4a teardown: best-effort cancel of any in-flight turn, then force the
|
||||
// connection down. The process-registry SIGKILL is the authoritative
|
||||
// no-orphan guarantee, not the cancel round-trip. Idempotent.
|
||||
const acp = session as AcpSession;
|
||||
if (acp.connection && acp.sessionId) {
|
||||
await cancelAcpSession(acp.connection, acp.sessionId);
|
||||
}
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
81
plugins/fusion-plugin-grok-runtime/src/acp/sanitize.ts
Normal file
81
plugins/fusion-plugin-grok-runtime/src/acp/sanitize.ts
Normal file
@@ -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 [ ... <final byte>
|
||||
// OSC: ESC ] ... (BEL | ST)
|
||||
// other ESC-prefixed two-char sequences (e.g. ESC ( B)
|
||||
const ANSI_PATTERN =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]/g;
|
||||
|
||||
// Non-printable control chars to drop. C0 = \x00–\x1F, DEL = \x7F, C1 = \x80–\x9F.
|
||||
// We KEEP \n (\x0A) and \t (\x09) — they are legitimate whitespace in agent text.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS_PATTERN = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
|
||||
|
||||
/**
|
||||
* Remove ANSI escape sequences (CSI/SGR/OSC) and non-printable C0/C1 control
|
||||
* characters from an untrusted string. Preserves `\n` and `\t`. Never throws —
|
||||
* a non-string input yields an empty string.
|
||||
*/
|
||||
export function stripControlSequences(text: string): string {
|
||||
if (typeof text !== "string" || text === "") return "";
|
||||
return text.replace(ANSI_PATTERN, "").replace(CONTROL_CHARS_PATTERN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate `text` to at most `max` characters, appending a short truncation
|
||||
* marker when the input is cut. A non-positive `max` yields an empty string; a
|
||||
* non-string input yields an empty string. The returned string is never longer
|
||||
* than `max` (the marker replaces the tail of the budget, it is not added on
|
||||
* top).
|
||||
*/
|
||||
export function boundString(text: string, max: number): string {
|
||||
if (typeof text !== "string" || text === "") return "";
|
||||
if (!Number.isFinite(max) || max <= 0) return "";
|
||||
if (text.length <= max) return text;
|
||||
if (max <= TRUNCATION_MARKER.length) {
|
||||
return text.slice(0, max);
|
||||
}
|
||||
return text.slice(0, max - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound an agent-supplied identifier to a sane length and strip anything that
|
||||
* could let it escape into a filesystem path: path separators (`/`, `\`), NUL
|
||||
* bytes, control chars, and `..` traversal segments are removed. The result is
|
||||
* a flat, length-bounded token safe to use as a Map key or a single path
|
||||
* component. A non-string / empty input yields `""`.
|
||||
*/
|
||||
export function boundIdentifier(id: string, max: number = DEFAULT_IDENTIFIER_MAX): string {
|
||||
if (typeof id !== "string" || id === "") return "";
|
||||
const cap = Number.isFinite(max) && max > 0 ? max : DEFAULT_IDENTIFIER_MAX;
|
||||
// Drop ANSI/control first, then path-dangerous characters, then traversal.
|
||||
let cleaned = stripControlSequences(id)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x00/g, "")
|
||||
.replace(/[/\\]/g, "_");
|
||||
// Collapse any remaining `..` traversal tokens (after separators were removed
|
||||
// a `..` cannot point anywhere, but normalize it away for defense in depth).
|
||||
cleaned = cleaned.replace(/\.\.+/g, "_");
|
||||
return cleaned.slice(0, cap);
|
||||
}
|
||||
47
plugins/fusion-plugin-grok-runtime/src/acp/tool-mapping.ts
Normal file
47
plugins/fusion-plugin-grok-runtime/src/acp/tool-mapping.ts
Normal file
@@ -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<ToolKind, string> = {
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
move: "Move",
|
||||
search: "Search",
|
||||
execute: "Execute",
|
||||
think: "Think",
|
||||
fetch: "Fetch",
|
||||
switch_mode: "Switch Mode",
|
||||
other: "Tool",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a display name for a tool call. Prefers the agent-supplied `title`;
|
||||
* falls back to a label derived from `kind`; final fallback is `"tool"`.
|
||||
*/
|
||||
export function toolDisplayName(toolCall: { title?: string | null; kind?: ToolKind | null }): string {
|
||||
const title = typeof toolCall.title === "string" ? toolCall.title.trim() : "";
|
||||
if (title) return title;
|
||||
const kind = toolCall.kind;
|
||||
if (kind && kind in KIND_LABELS) return KIND_LABELS[kind];
|
||||
return "tool";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a tool call's `rawInput` to a plain object. Returns `{}` when the
|
||||
* input is undefined, null, or any non-object (arrays included) so downstream
|
||||
* code can always treat args as a record.
|
||||
*/
|
||||
export function normalizeToolArgs(rawInput: unknown): Record<string, unknown> {
|
||||
if (rawInput === null || typeof rawInput !== "object" || Array.isArray(rawInput)) {
|
||||
return {};
|
||||
}
|
||||
return rawInput as Record<string, unknown>;
|
||||
}
|
||||
189
plugins/fusion-plugin-grok-runtime/src/acp/types.ts
Normal file
189
plugins/fusion-plugin-grok-runtime/src/acp/types.ts
Normal file
@@ -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<string, GateDisposition>;
|
||||
};
|
||||
/** Register an approval request; returns the created record (with an `id`). */
|
||||
createApprovalRequest?: (
|
||||
decision: unknown,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown> | unknown;
|
||||
/** Look up a prior decision by dedupe key (decision reuse). */
|
||||
findApprovalByDedupeKey?: (
|
||||
dedupeKey: string,
|
||||
) => Promise<{ id: string; status: ApprovalStatus } | null> | { id: string; status: ApprovalStatus } | null;
|
||||
/** Block until the human resolves the referenced approval request. */
|
||||
pauseForApproval?: (info: {
|
||||
approvalRequestId: string;
|
||||
decision: unknown;
|
||||
}) => Promise<void> | void;
|
||||
/** Mark an approval request finalized after the decision is consumed. */
|
||||
markApprovalCompleted?: (approvalRequestId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** Plugin-local copy of the engine's AgentRuntimeOptions (subset this runtime reads). */
|
||||
export interface AgentRuntimeOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
defaultThinkingLevel?: string;
|
||||
/** Per-run permission gate, populated by the engine. See PermissionGate. */
|
||||
actionGateContext?: PermissionGate;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<AgentSessionResult>;
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void | AgentPromptResult>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
}
|
||||
@@ -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 <prompt> --output-format json [-m <model>] [--cwd <dir>]`. 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<null, Readable, Readable>;
|
||||
|
||||
export interface SpawnGrokStreamOptions {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `grok -p <prompt> --output-format json [-m <model>] [--cwd <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 <model>`, 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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
114
plugins/fusion-plugin-grok-runtime/src/mcp-forwarding.ts
Normal file
114
plugins/fusion-plugin-grok-runtime/src/mcp-forwarding.ts
Normal file
@@ -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<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function mapEntries(map: Record<string, string> | 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<string, unknown> => 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<string, string> | 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<string, string> | 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<string, string> | 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<string, string> | undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
151
plugins/fusion-plugin-grok-runtime/src/mcp-schema-server.cjs
Normal file
151
plugins/fusion-plugin-grok-runtime/src/mcp-schema-server.cjs
Normal file
@@ -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}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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/<id>` or `grok/<id>`) 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/<id>` or `grok/<id>`) 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<string, unknown>) => {
|
||||
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
|
||||
promptWithFallback(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
): Promise<void | { stopReason?: string }>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
};
|
||||
|
||||
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<object, ReturnType<AcpAdapterFactory>>();
|
||||
|
||||
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<AgentSessionResult> {
|
||||
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<string, unknown> = {
|
||||
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<void> {
|
||||
async promptWithFallback(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
): Promise<void | { stopReason?: string }> {
|
||||
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<void>((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<void> {
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
|
||||
218
plugins/fusion-plugin-grok-runtime/src/skill-loader.ts
Normal file
218
plugins/fusion-plugin-grok-runtime/src/skill-loader.ts
Normal file
@@ -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<string, string>): 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<string, string>();
|
||||
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));
|
||||
}
|
||||
@@ -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 <prompt> --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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
178
plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts
Normal file
178
plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts
Normal file
@@ -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<string, unknown>;
|
||||
execute?: (
|
||||
toolCallId: string,
|
||||
params: unknown,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: unknown,
|
||||
ctx?: unknown,
|
||||
) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FusionToolBridge {
|
||||
mcpServer: AcpMcpServer;
|
||||
dispose: () => Promise<void>;
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
export function toolsToMcpToolDefs(tools: ReadonlyArray<ToolLike> | 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<ToolLike> | undefined): Promise<FusionToolBridge | null> {
|
||||
const defs = toolsToMcpToolDefs(tools);
|
||||
if (defs.length === 0) return null;
|
||||
|
||||
const byName = new Map<string, ToolLike>();
|
||||
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<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, GateDisposition>;
|
||||
};
|
||||
createApprovalRequest?: (
|
||||
decision: unknown,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown> | unknown;
|
||||
findApprovalByDedupeKey?: (
|
||||
dedupeKey: string,
|
||||
) => Promise<{ id: string; status: string } | null> | { id: string; status: string } | null;
|
||||
pauseForApproval?: (info: {
|
||||
approvalRequestId: string;
|
||||
decision: unknown;
|
||||
}) => Promise<void> | void;
|
||||
markApprovalCompleted?: (approvalRequestId: string) => Promise<void> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<AgentSessionResult>;
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||
promptWithFallback(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
): Promise<void | AgentPromptResult>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
}
|
||||
|
||||
203
pnpm-lock.yaml
generated
203
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user