FN-7753: route grok-cli execution through the grok CLI when no Fusion-visible GROK_API_KEY resolves

Route grok-cli model selections through the grok CLI runtime when no Fusion-visible GROK_API_KEY is available.

- Add read-only isGrokApiKeyFusionVisible() in packages/core/src/grok-provider.ts, refactored to share user-settings-file reading with hydrateGrokApiKeyFromUserSettings without mutating process.env or logging key material.
- In packages/engine/src/agent-session-helpers.ts, auto-derive the existing "grok" runtimeHint when defaultProvider is grok-cli, no key is Fusion-visible, and the grok plugin runtime is registered; explicit runtime hints and mock/test-mode routing remain unchanged, and the provider-qualified model prefix is stripped before handoff.
- Normalize provider-qualified model ids (grok-cli/<id>, grok/<id>) in the grok-runtime plugin's runtime-adapter and CLI stream spawn so the concrete model reaches `grok --model`, with the historical grok/default fallback preserved for the no-model path.
- Update docs (grok-cli-contract.md, settings-reference.md, plugin README) and add/extend tests covering the new fallback behavior, model normalization, and CLI streaming.
- Add changeset fn-7753-grok-cli-no-key-fallback.md (patch, fix).

Files changed:
 .changeset/fn-7753-grok-cli-no-key-fallback.md     |   7 ++
 docs/grok-cli-contract.md                          |  83 ++++++++++------
 docs/settings-reference.md                         |   6 +-
 .../__tests__/grok-provider-user-settings.test.ts  |  46 +++++++++
 packages/core/src/grok-provider.ts                 |  39 +++++++-
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 .../src/__tests__/grok-runtime-routing.test.ts     | 107 +++++++++++++++++++--
 packages/engine/src/agent-session-helpers.ts       |  52 +++++++++-
 plugins/fusion-plugin-grok-runtime/README.md       |  46 +++++----
 .../src/__tests__/cli-stream.test.ts               |  70 ++++++++++++++
 .../src/__tests__/runtime-adapter.test.ts          |  28 ++++++
 .../fusion-plugin-grok-runtime/src/cli-stream.ts   |   6 ++
 .../src/runtime-adapter.ts                         |  24 ++++-
 14 files changed, 443 insertions(+), 73 deletions(-)

Fusion-Task-Id: FN-7753

Fusion-Task-Lineage: 30ef7265-1ba9-47fd-8c4e-87b02f6a1d78

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 21:36:14 -07:00
parent 28c82331e7
commit f7c6f560c0
14 changed files with 441 additions and 71 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Grok CLI models now run via the grok CLI when no Fusion-visible API key is set.
category: fix
dev: createResolvedAgentSession (packages/engine/src/agent-session-helpers.ts) auto-derives runtimeHint "grok" when defaultProvider is grok-cli, no GROK_API_KEY is Fusion-visible (new read-only isGrokApiKeyFusionVisible in packages/core/src/grok-provider.ts), and the Grok runtime is registered; the selected model is passed to the CLI via a new --model option on spawnGrokStream. Explicit runtime hints and the key-visible direct-endpoint default are unchanged. Closes the deferred FN-7722/FN-7725 follow-up.

View File

@@ -134,7 +134,7 @@ Notes:
`GrokCliProviderCard.tsx`) is out of scope for this task and is not
modified here.
## Wiring (resolved — FN-7725)
## Wiring (resolved — FN-7725, extended by FN-7753)
<!--
FNXC:GrokCli 2026-07-09-00:00:
@@ -148,14 +148,33 @@ default and unchanged.
**Decision: option (a) — formalize, document, and test the existing agent
Runtime-mode picker path. Do NOT add a new settings toggle (option (b)).**
FN-7753 later closed the deferred no-key model-selection fallback without adding
that rejected UI toggle: the session seam derives the same runtime hint
automatically only when the direct endpoint cannot work because no Fusion-visible
GROK_API_KEY resolves.
**Trigger:** an agent's `runtimeConfig.runtimeHint === "grok"`, set today via
the dashboard's agent **Runtime Source → Runtime** picker
**Explicit trigger:** an agent's `runtimeConfig.runtimeHint === "grok"`, set today
via the dashboard's agent **Runtime Source → Runtime** picker
(`NewAgentDialog.tsx` / `AgentDetailView.tsx`), which is populated from
`GET /api/plugins/runtimes` (already generic — surfaces every registered
plugin runtime, including the bundled Grok Runtime plugin's `runtimeId:
"grok"`, with no Grok-specific code required).
**Automatic no-key fallback (FN-7753):** when `createResolvedAgentSession()` sees
all of the following, it derives the same effective `runtimeHint: "grok"` before
calling `resolveRuntime()`:
1. no explicit runtime hint was supplied (explicit hints, including `"pi"`,
always win);
2. the resolved execution provider is `grok-cli`;
3. Fusion cannot see a non-empty `GROK_API_KEY` either in the environment or in
`~/.grok/user-settings.json`'s `apiKey` field; and
4. the bundled Grok Runtime plugin has registered runtime id `"grok"`.
If a Fusion-visible key exists, the direct xAI OpenAI-compatible endpoint remains
the default. If the Grok runtime is not registered, Fusion leaves the session on
the existing PI/direct path rather than inventing a separate routing mode.
**Exact seam:** `packages/engine/src/agent-session-helpers.ts`'s
`extractRuntimeHint(runtimeConfig)` reads that hint from the assigned agent's
`runtimeConfig` and threads it, as `runtimeHint`, into
@@ -188,24 +207,21 @@ additive change," formalizing + testing + documenting the already-working
path is lower risk and closes the actual gap (an *exercised* path, not just
an implemented adapter) without adding new user-facing config surface.
**Known limitation (by design, unchanged by this task):** Runtime-mode is
model-agnostic — `NewAgentDialog.tsx`/`AgentDetailView.tsx` clear the `model`
field when Runtime mode is selected (`model: runtimeMode === "runtime" ? ""
: ...`), so `GrokRuntimeAdapter.createSession()` never receives a
`defaultModelId` from this path and always falls back to `"grok/default"`. A
specific `grok-cli/*` model choice is therefore not preserved when routing
via Runtime-mode. Preserving model selection through the CLI runtime would
require option (b) (or an equivalent); it is filed as a follow-up task only
if genuinely warranted (see Follow-ups below), not implemented here.
**Model plumbing (FN-7753):** for the automatic no-key fallback, the selected
`grok-cli/*` model id is preserved through `AgentRuntimeOptions.defaultModelId`,
normalized by stripping a leading `grok-cli/` (or `grok/`) prefix, and passed to
the CLI as `grok --model <id>` alongside `--prompt` and `--format json`.
Runtime-mode remains model-agnostic when chosen explicitly from the dashboard;
that no-model path still uses the adapter's historical `"grok/default"` session
fallback and omits `--model`.
**Why the direct xAI endpoint stays default:** nothing in this task changes
what a `grok-cli/*` **model** selection does — it continues to route through
the direct xAI OpenAI-compatible endpoint (FN-7711/FN-7714,
`packages/core/src/grok-provider.ts`, `packages/engine/src/pi.ts`), which
this task does not touch. The CLI-routed path is reached *only* by the
separate, explicit agent Runtime-mode choice — an opt-in, additive,
fully-reversible path (nothing sets the hint unless an operator explicitly
picks Runtime mode for that agent).
**Why the direct xAI endpoint stays default:** a `grok-cli/*` **model** selection
continues to route through the direct xAI OpenAI-compatible endpoint
(FN-7711/FN-7714, `packages/core/src/grok-provider.ts`, `packages/engine/src/pi.ts`)
whenever Fusion can see a key. FN-7753 changes only the failing no-visible-key
case, where the direct path would otherwise hard-fail even though the installed
CLI may be authenticated by a source Fusion cannot inspect (project `.env`,
`grok -k`, OAuth/login token store, sandbox secrets, etc.).
## Decision
@@ -228,17 +244,18 @@ Rationale:
tool-call/break-early bridging as a documented follow-up (the Droid
adapter's much larger `provider.ts` is the effort ceiling, not the target
shape).
- It is fully reversible: the adapter is only reachable via
`runtimeHint === "grok"`, which nothing sets today, so landing it carries
no behavioral change to any exercised path.
- It is fully reversible: the adapter is reachable via either an explicit
`runtimeHint === "grok"` or FN-7753's narrow no-visible-key `grok-cli`
fallback. The direct endpoint remains the key-visible default.
## What stays unchanged
- The **direct xAI OpenAI-compatible streaming path** (base URL
`https://api.x.ai/v1`, api type `openai-completions`, `GROK_API_KEY`
sourced per FN-7711/FN-7714) remains the default, exercised Grok execution
path. This task does not touch `packages/core/src/grok-provider.ts` or
`packages/engine/src/pi.ts`.
sourced per FN-7711/FN-7714) remains the default when a Fusion-visible key
exists. FN-7753 adds a read-only key-visibility check in
`packages/core/src/grok-provider.ts` and derives CLI routing only when no
such key is visible and the Grok runtime is registered.
- FN-7716's probe/auth-readiness surface (`probe.ts`,
`register-auth-routes.ts`, `GrokCliProviderCard.tsx`) is untouched by this
task.
@@ -265,8 +282,10 @@ See the task's `fn_task_create` calls (linked from FN-7722) for:
`close`/`error`, unchanged from FN-7722. See
`plugins/fusion-plugin-grok-runtime/README.md`'s "Tool execution
bridging (FN-7724)" section.
3. (Filed by FN-7725, if warranted) Preserving a specific `grok-cli/*` model
selection when routing through the CLI runtime (Runtime-mode is currently
model-agnostic — see "Known limitation" in "Wiring" above). This is the
deferred option (b) shape; only file it if a genuine operator need
surfaces, per the task's Decision guidance.
3. ~~Preserving a specific `grok-cli/*` model selection when routing through
the CLI runtime~~ — **closed by FN-7753** for the automatic no-visible-key
fallback: `createResolvedAgentSession()` derives runtime hint `"grok"` only
when no explicit hint is set, provider is `grok-cli`, no Fusion-visible
`GROK_API_KEY`/user-settings `apiKey` resolves, and runtime id `"grok"` is
registered; the selected model is passed to the CLI via `--model <id>`.
Explicit Runtime-mode remains model-agnostic by design.

View File

@@ -104,7 +104,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. |
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
| `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok is API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`) — there is no OAuth/session login flow. |
| `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok's direct xAI endpoint uses API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`); when a `grok-cli/*` execution model has no Fusion-visible key, Fusion falls back to the `grok` CLI runtime if registered so the CLI can use its own auth store. |
| `grokCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Grok CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `grok` on PATH. Invalid non-empty saves are rejected with bounded diagnostics. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
@@ -969,13 +969,13 @@ When the planning lane has neither `planningFallback*` nor a global `fallback*`
Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available.
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure. If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation).
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below). If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation). When no Fusion-visible key resolves and the Grok Runtime plugin has registered runtime id `grok`, `createResolvedAgentSession()` derives that runtime automatically for `grok-cli` execution and passes the selected model to the CLI via `--model <id>`; explicit runtime hints and key-visible direct-endpoint routing take precedence.
When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and the local `hermes` CLI has configured profiles (`hermes profile list`), those profiles are surfaced additively in `/api/models` under the `hermes` provider — one row per profile, id/name derived from the profile name and its configured model. This surfacing is read-only (Fusion does not create or edit Hermes profiles) and is fetched through a short-TTL, single-flight cache so the model picker never spawns the `hermes` CLI on every request; a missing/failed `hermes` binary simply yields zero Hermes rows without affecting other providers.
When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows.
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok is API-key auth: the Settings card's status text guides operators to `GROK_API_KEY` or `~/.grok/user-settings.json` when the binary is available but no key is configured.
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok direct-endpoint auth is API-key based, but CLI-routed execution lets the `grok` binary use any auth source it supports; the Settings card still surfaces Fusion-visible key detection only as an informational hint.
The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) are additively surfaced under the `openai-codex` provider (FN-7745/FN-7754, mirroring the Anthropic/Z.ai supplemental-merge pattern above) so they appear both in dashboard `/api/models` and the engine/pi `createFnAgent` registry-seeding surface whenever `openai-codex` is configured — deduped against any pinned pi-ai catalog row that already carries one of the ids.

View File

@@ -7,6 +7,7 @@ import {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
hydrateGrokApiKeyFromUserSettings,
isGrokApiKeyFusionVisible,
registerBuiltInGrokProvider,
} from "../grok-provider.js";
@@ -29,6 +30,51 @@ mirrors probe.test.ts's fallback/precedence/fail-soft matrix so the $GROK_API_KE
reference resolves from ~/.grok/user-settings.json when the env var is unset, without ever
overwriting an operator-provided env value or throwing on a missing/malformed file.
*/
describe("isGrokApiKeyFusionVisible", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...ORIGINAL_ENV };
delete process.env.GROK_API_KEY;
});
it("returns true when env is set and does not read the settings file", () => {
process.env.GROK_API_KEY = " xai-from-env ";
expect(isGrokApiKeyFusionVisible()).toBe(true);
expect(readFileSync).not.toHaveBeenCalled();
});
it("returns true when env is unset and user settings has a non-empty apiKey without mutating env", () => {
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: " xai-from-file " }));
expect(isGrokApiKeyFusionVisible()).toBe(true);
expect(process.env.GROK_API_KEY).toBeUndefined();
});
it("returns false for a missing settings file without throwing or mutating env", () => {
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
vi.mocked(readFileSync).mockImplementationOnce(() => {
throw enoent;
});
expect(() => isGrokApiKeyFusionVisible()).not.toThrow();
expect(isGrokApiKeyFusionVisible()).toBe(false);
expect(process.env.GROK_API_KEY).toBeUndefined();
});
it("returns false for empty, malformed, or keyless settings without throwing or mutating env", () => {
vi.mocked(readFileSync)
.mockReturnValueOnce(JSON.stringify({ apiKey: " " }))
.mockReturnValueOnce("not json")
.mockReturnValueOnce(JSON.stringify({}));
expect(isGrokApiKeyFusionVisible()).toBe(false);
expect(() => isGrokApiKeyFusionVisible()).not.toThrow();
expect(isGrokApiKeyFusionVisible()).toBe(false);
expect(process.env.GROK_API_KEY).toBeUndefined();
});
});
describe("hydrateGrokApiKeyFromUserSettings", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -192,6 +192,37 @@ function cloneGrokProviderRegistration(config: GrokProviderRegistration): GrokPr
* `~/.grok/user-settings.json` path), kept synchronous here because
* `registerBuiltInGrokProvider` itself is synchronous.
*/
function readGrokUserSettingsApiKey(): string | undefined {
const settingsPath = join(homedir(), ".grok", "user-settings.json");
const raw = readFileSync(settingsPath, "utf-8");
const parsed = JSON.parse(raw) as { apiKey?: unknown };
return typeof parsed?.apiKey === "string" && parsed.apiKey.trim().length > 0
? parsed.apiKey.trim()
: undefined;
}
/**
* FNXC:ProviderAuth 2026-07-09-00:00:
* FN-7753 needs a read-only, synchronous key-visibility predicate before runtime
* resolution chooses between the direct xAI endpoint and the `grok` CLI runtime.
* Mirror hydrateGrokApiKeyFromUserSettings/probeGrokApiKeyPresence exactly:
* non-empty `GROK_API_KEY` env wins; otherwise inspect `~/.grok/user-settings.json`
* for a non-empty `apiKey`; missing, unreadable, malformed, or keyless files are
* ordinary false results. Never mutate process.env and never log key material.
*/
export function isGrokApiKeyFusionVisible(): boolean {
const envKey = process.env.GROK_API_KEY;
if (typeof envKey === "string" && envKey.trim().length > 0) {
return true;
}
try {
return readGrokUserSettingsApiKey() !== undefined;
} catch {
return false;
}
}
export function hydrateGrokApiKeyFromUserSettings(
logWarning: (message: string) => void = () => {},
): void {
@@ -202,11 +233,9 @@ export function hydrateGrokApiKeyFromUserSettings(
}
try {
const settingsPath = join(homedir(), ".grok", "user-settings.json");
const raw = readFileSync(settingsPath, "utf-8");
const parsed = JSON.parse(raw) as { apiKey?: unknown };
if (typeof parsed?.apiKey === "string" && parsed.apiKey.trim().length > 0) {
process.env.GROK_API_KEY = parsed.apiKey.trim();
const apiKey = readGrokUserSettingsApiKey();
if (apiKey) {
process.env.GROK_API_KEY = apiKey;
}
} catch (error) {
// Fail-soft: a missing (ENOENT), malformed, or unreadable settings file must never throw

View File

@@ -98,6 +98,7 @@ export type { ZaiProviderRegistration } from "./zai-provider.js";
export {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
isGrokApiKeyFusionVisible,
mergeBuiltInGrokProviderModels,
registerBuiltInGrokProvider,
} from "./grok-provider.js";

View File

@@ -65,6 +65,7 @@ export type { ZaiProviderRegistration } from "./zai-provider.js";
export {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
isGrokApiKeyFusionVisible,
mergeBuiltInGrokProviderModels,
registerBuiltInGrokProvider,
} from "./grok-provider.js";

View File

@@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRunner } from "../plugin-runner.js";
import type { PluginRuntimeRegistration } from "@fusion/core";
import * as fusionCore from "@fusion/core";
import { resolveRuntime } from "../runtime-resolution.js";
import { createResolvedAgentSession, extractRuntimeHint } from "../agent-session-helpers.js";
@@ -48,7 +49,7 @@ function grokRuntimeAdapterModulePath(): string {
type GrokRuntimeAdapterCtor = new (options?: {
binary?: string;
spawn?: (binary: string, prompt: string, options?: { cwd?: string; signal?: AbortSignal }) => unknown;
spawn?: (binary: string, prompt: string, options?: { cwd?: string; model?: string; signal?: AbortSignal }) => unknown;
}) => {
id: string;
name: string;
@@ -110,6 +111,7 @@ async function createGrokRegistration(
describe("Grok CLI runtime routing (FN-7725)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(true);
mockCreateFnAgent.mockResolvedValue({
session: { runtime: "pi", prompt: vi.fn() },
sessionFile: "/tmp/pi.session.json",
@@ -229,28 +231,117 @@ describe("Grok CLI runtime routing (FN-7725)", () => {
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled();
});
it("does not route through Grok for a different runtime hint (e.g. a grok-cli/* model selection stays on the default pi runtime)", async () => {
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 pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(undefined),
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
});
const audit = { database: vi.fn().mockResolvedValue(undefined) };
// A grok-cli/* MODEL selection (Built-in Model mode) never sets
// runtimeHint at all -- it is passed as runtimeConfig.model, which
// extractRuntimeHint does not read. Simulate that shape explicitly.
const runtimeHint = extractRuntimeHint({ model: "grok-cli/grok-4" });
const runtimeHint = extractRuntimeHint({ model: "grok-cli/grok-4.5" });
expect(runtimeHint).toBeUndefined();
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint,
pluginRunner,
runAuditor: audit as never,
cwd: "/tmp/project",
defaultProvider: "grok-cli",
defaultModelId: "grok-cli/grok-4.5",
systemPrompt: "model-selection-only",
});
expect(result.runtimeId).toBe("grok");
expect(result.wasConfigured).toBe(true);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(result.session).toMatchObject({ model: "grok-4.5" });
expect(audit.database).toHaveBeenCalledWith(expect.objectContaining({
type: "session:runtime-resolved",
target: "grok",
metadata: expect.objectContaining({
runtimeHint: "grok",
reason: "grok-cli-no-visible-key",
provider: "grok-cli",
modelId: "grok-cli/grok-4.5",
}),
}));
});
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 pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
});
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner,
cwd: "/tmp/project",
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
systemPrompt: "direct-endpoint-default",
});
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled();
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
}));
});
it("keeps grok-cli on pi when no key is visible but the Grok runtime is not registered", async () => {
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
const pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(undefined),
});
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner,
cwd: "/tmp/project",
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
systemPrompt: "no-runtime-registered",
});
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
}));
});
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 getRuntimeById = vi.fn((runtimeId: string) => runtimeId === "grok" ? grokRegistration : undefined);
const pluginRunner = createMockPluginRunner({ getRuntimeById });
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: "pi",
pluginRunner,
cwd: "/tmp/project",
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
systemPrompt: "explicit-pi",
});
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(true);
expect(getRuntimeById).not.toHaveBeenCalledWith("grok");
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
}));
});
it("does not crash and falls back to pi for an empty/undefined runtimeConfig", async () => {

View File

@@ -12,6 +12,8 @@ import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import {
GROK_CLI_PROVIDER_ID,
isGrokApiKeyFusionVisible,
isTestModeActive,
resolveExecutionSettingsModel,
resolveProjectDefaultModel,
@@ -152,6 +154,28 @@ function hasCompleteRuntimeModel(
return Boolean(model.provider && model.modelId);
}
function stripGrokCliModelProviderPrefix(modelId: string | undefined): string | undefined {
const normalized = modelId?.trim();
if (!normalized) return normalized;
const grokCliPrefix = `${GROK_CLI_PROVIDER_ID}/`;
return normalized.startsWith(grokCliPrefix)
? normalized.slice(grokCliPrefix.length)
: normalized;
}
function deriveGrokRuntimeHintForNoVisibleKey(
runtimeOptions: AgentRuntimeOptions,
pluginRunner: PluginRunner | undefined,
): string | undefined {
if (runtimeOptions.defaultProvider !== GROK_CLI_PROVIDER_ID) return undefined;
if (isGrokApiKeyFusionVisible()) return undefined;
try {
return pluginRunner?.getRuntimeById("grok") ? "grok" : undefined;
} catch {
return undefined;
}
}
function pickSettingsThenRuntimeModel(
settingsModel: ResolvedModelSelection,
assignedAgentRuntimeConfig?: Record<string, unknown>,
@@ -375,13 +399,32 @@ export async function createResolvedAgentSession(
}
: runtimeOptions;
/*
FNXC:GrokCliRouting 2026-07-09-00:00:
FN-7753: when Built-in Model mode selects `grok-cli/*` but Fusion cannot see a GROK_API_KEY,
derive the existing Grok plugin runtime hint automatically so the `grok` binary owns auth end-to-end.
Explicit runtime hints always win, visible keys keep the direct xAI endpoint default, and mock/test-mode
provider routing stays on the mock runtime. Strip only the provider-qualified model prefix so the CLI
receives the concrete selected model via GrokRuntimeAdapter without changing non-grok sessions.
*/
const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint
? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner)
: undefined;
const effectiveRuntimeHint = autoGrokRuntimeHint ?? runtimeHint;
const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint
? {
...effectiveRuntimeOptions,
defaultModelId: stripGrokCliModelProviderPrefix(effectiveRuntimeOptions.defaultModelId),
}
: effectiveRuntimeOptions;
const resolved = useMockRuntime
? {
runtime: mockRuntimeSingleton,
runtimeId: mockRuntimeSingleton.id,
wasConfigured: true,
}
: await resolveRuntime(buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint));
: await resolveRuntime(buildRuntimeResolutionContext(sessionPurpose, pluginRunner, effectiveRuntimeHint));
sessionLog.log(
`[${sessionPurpose}] Using runtime "${resolved.runtimeId}" (configured=${resolved.wasConfigured})`,
@@ -399,8 +442,9 @@ export async function createResolvedAgentSession(
modelId: runtimeOptions.defaultModelId ?? null,
mockProviderActive: isMockProviderId(runtimeOptions.defaultProvider),
testModeActive: settings ? isTestModeActive(settings) : false,
...(runtimeHint ? { runtimeHint } : {}),
...("fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}),
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
...(!autoGrokRuntimeHint && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
},
});
} catch (err) {
@@ -411,7 +455,7 @@ export async function createResolvedAgentSession(
// latest sync point (just before LLM session instantiation) rather than
// here, before the runtime's own awaited setup work runs. See
// AgentRuntimeOptions.beforeSpawnSession for the contract.
const result = await resolved.runtime.createSession(effectiveRuntimeOptions);
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
// Attach the resolved runtime's promptWithFallback as a bound method on the
// session object when it is not already present. This is the dispatch hook

View File

@@ -76,18 +76,23 @@ grok --prompt "<text>" --format json
`~/.grok/user-settings.json`), a CLI-routed selection needs **no
Fusion-visible `GROK_API_KEY`** — unlike the direct xAI
OpenAI-compatible streaming path (`https://api.x.ai/v1`), which still
requires one.
- This adapter is only reached when an agent's
`runtimeConfig.runtimeHint === "grok"`. See "Routing Grok through the CLI
runtime (FN-7725)" below for how to set that, and
requires one. When Fusion auto-routes a no-key `grok-cli/*` model selection
through this adapter (FN-7753), the selected model id is passed to the CLI
with `--model <id>`.
- This adapter is reached either when an agent explicitly sets
`runtimeConfig.runtimeHint === "grok"` or when FN-7753's no-visible-key
`grok-cli/*` fallback derives that hint automatically. See "Routing Grok
through the CLI runtime (FN-7725 / FN-7753)" below and
`docs/grok-cli-contract.md` for the full contract and decision record.
## Routing Grok through the CLI runtime (FN-7725)
## Routing Grok through the CLI runtime (FN-7725 / FN-7753)
By default, selecting a `grok-cli/*` **model** for an agent/task still routes
execution through the **direct xAI OpenAI-compatible endpoint**
(`https://api.x.ai/v1`, FN-7711/FN-7714) — this default is unchanged by this
plugin.
By default, selecting a `grok-cli/*` **model** for an agent/task routes through
the **direct xAI OpenAI-compatible endpoint** (`https://api.x.ai/v1`,
FN-7711/FN-7714) whenever Fusion can see a `GROK_API_KEY` (environment or
`~/.grok/user-settings.json` `apiKey`). If no Fusion-visible key resolves and
the Grok Runtime plugin is registered, Fusion automatically routes that session
through the `grok` CLI runtime instead, letting the CLI own auth end-to-end.
To route a specific agent's execution through the `grok` CLI's own
non-interactive streaming mode (`grok --prompt --format json`) instead:
@@ -104,16 +109,19 @@ non-interactive streaming mode (`grok --prompt --format json`) instead:
child agent) resolves through `packages/engine/src/runtime-resolution.ts`
to this plugin's `GrokRuntimeAdapter` instead of the default pi runtime.
**Known limitation:** Runtime-mode is model-agnostic — it does not carry a
specific `grok-cli/*` model id through to the adapter, so
`GrokRuntimeAdapter.createSession()` always falls back to `"grok/default"`.
If you need a specific Grok model honored end-to-end, use the direct xAI
endpoint path (**Built-in Model** → a `grok-cli/*` model) instead — that
path does preserve model selection, just not via the CLI binary.
**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 `--model <id>`.
This routing is opt-in and per-agent; it does not change any other agent's
or task's execution path, and it does not change what a `grok-cli/*` model
selection does under **Built-in Model** mode.
**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
`--model`. 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

View File

@@ -0,0 +1,70 @@
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 to grok --model when provided", () => {
spawnGrokStream("grok", "hello", { cwd: "/tmp/project", model: "grok-4.5" });
expect(spawn).toHaveBeenCalledWith("grok", [
"--prompt",
"hello",
"--format",
"json",
"--model",
"grok-4.5",
"--directory",
"/tmp/project",
], {
cwd: "/tmp/project",
stdio: ["ignore", "pipe", "pipe"],
shell: false,
signal: undefined,
});
});
it("omits --model when no model is provided", () => {
spawnGrokStream("grok", "hello", { cwd: "/tmp/project" });
expect(spawn).toHaveBeenCalledWith("grok", [
"--prompt",
"hello",
"--format",
"json",
"--directory",
"/tmp/project",
], expect.objectContaining({ cwd: "/tmp/project" }));
});
});

View File

@@ -31,6 +31,34 @@ describe("GrokRuntimeAdapter", () => {
expect(result.session.systemPrompt).toBe("sys");
});
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 });
const { session } = await adapter.createSession({ defaultModelId: "grok-cli/grok-4.5" });
const promise = adapter.promptWithFallback(session, "hello grok");
proc.emit("close", 0, null);
await promise;
expect(session.model).toBe("grok-4.5");
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: "grok-4.5" }));
});
it("omits --model 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");
proc.emit("close", 0, null);
await promise;
expect(session.model).toBe("grok/default");
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: undefined }));
});
it("streams onText for each text NDJSON event in order and resolves on close", async () => {
const { proc, stdout } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);

View File

@@ -18,6 +18,7 @@ export type GrokStreamProcess = ChildProcessByStdio<null, Readable, Readable>;
export interface SpawnGrokStreamOptions {
cwd?: string;
model?: string;
signal?: AbortSignal;
}
@@ -30,6 +31,11 @@ export interface SpawnGrokStreamOptions {
*/
export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess {
const args: string[] = ["--prompt", prompt, "--format", "json"];
const model = options?.model?.trim();
if (model) {
// FNXC:GrokCliRouting 2026-07-09-00:00: FN-7753 preserves a selected `grok-cli/*` model when auto-routing through the CLI; upstream verifies `--model <model>` alongside `--prompt`/`--format json`.
args.push("--model", model);
}
if (options?.cwd) {
args.push("--directory", options.cwd);
}

View File

@@ -32,6 +32,9 @@ does, same `streamEnded`-guarded (via the existing `settled` flag)
resolve-never-reject lifecycle as before. This adapter is only reached when
an agent's `runtimeConfig.runtimeHint === "grok"` (wired end-to-end by
FN-7725).
FNXC:GrokCliRouting 2026-07-09-00:00:
FN-7753: auto-derived `grok` runtime routing from a `grok-cli/*` model selection must preserve 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 --model`; the no-model Runtime-mode path keeps the historical `grok/default` session fallback and omits `--model`.
*/
/**
@@ -71,6 +74,23 @@ function parseToolArguments(raw: string | undefined): unknown {
}
}
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;
}
export interface GrokRuntimeAdapterOptions {
/** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */
binary?: string;
@@ -99,7 +119,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
} = {},
): Promise<AgentSessionResult> {
const model = options.defaultModelId ?? "grok/default";
const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default";
const session: GrokSession = {
model,
systemPrompt: options.systemPrompt,
@@ -124,7 +144,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
return new Promise<void>((resolve) => {
let proc: GrokStreamProcess;
try {
proc = this.spawnFn(this.binary, prompt, { cwd, signal });
proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal });
} catch {
// Spawn threw synchronously (e.g. binary not found without shell
// resolution) — resolve, never reject, matching the CLI-adapter