diff --git a/.changeset/fn-7761-grok-cli-packaged-routing.md b/.changeset/fn-7761-grok-cli-packaged-routing.md new file mode 100644 index 0000000000..2ccfc718d0 --- /dev/null +++ b/.changeset/fn-7761-grok-cli-packaged-routing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Route Grok CLI models through the logged-in grok CLI in packaged hosts without requiring GROK_API_KEY. +category: fix +dev: Eagerly ensures the bundled Grok runtime in serve/daemon/dashboard and blocks silent direct-endpoint fallback when no key is visible. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index a8083a46e9..4ce0173af2 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -134,7 +134,7 @@ Notes: `GrokCliProviderCard.tsx`) is out of scope for this task and is not modified here. -## Wiring (resolved — FN-7725, extended by FN-7753/FN-7758) +## Wiring (resolved — FN-7725, extended by FN-7753/FN-7758/FN-7761) + FN-7758 also requires dashboard Chat/QuickChat and room responders to forward the configured default provider/model into this same session seam when a send has no explicit model and no bound agent runtime model. That keeps the no-key routing @@ -180,8 +185,12 @@ chat, QuickChat, and room responder surfaces instead of letting model-less chat bypass the auto-derive by omitting `defaultProvider`. 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. +the default. If the Grok runtime is not registered or cannot be loaded in the +no-visible-key `grok-cli` case, Fusion no longer leaves the session on the +existing PI/direct path because that path produces the misleading missing-key +error. Instead it raises an actionable error that names both supported recovery +paths: install/enable the Grok CLI runtime plugin so the logged-in `grok` CLI +owns auth, or set `GROK_API_KEY` so the direct xAI endpoint can authenticate. **Exact seam:** `packages/engine/src/agent-session-helpers.ts`'s `extractRuntimeHint(runtimeConfig)` reads that hint from the assigned agent's @@ -215,7 +224,7 @@ 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. -**Model plumbing (FN-7753/FN-7758):** for the automatic no-key fallback, the selected +**Model plumbing (FN-7753/FN-7758/FN-7761):** for the automatic no-key fallback, the selected `grok-cli/*` model id is preserved through `AgentRuntimeOptions.defaultModelId` (or promoted from `fallbackModelId` when the fallback provider is the grok-cli selection), normalized by stripping a leading `grok-cli/` (or `grok/`) prefix, diff --git a/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts b/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts new file mode 100644 index 0000000000..9fa758039a --- /dev/null +++ b/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const commandsDir = resolve(__dirname, ".."); + +function readCommand(command: "serve" | "daemon" | "dashboard"): string { + return readFileSync(resolve(commandsDir, `${command}.ts`), "utf8"); +} + +/* + * FNXC:GrokCliRouting 2026-07-09-23:05: + * FN-7761 regression guard for packaged host bootstrap. The Grok helper must run before loadAllPlugins() in each long-lived CLI host so the enabled bundled runtime is loaded and getRuntimeById("grok") can resolve before chat/executor sessions try to route grok-cli/no-key messages. + */ +describe("Grok CLI runtime packaged bootstrap", () => { + for (const command of ["serve", "daemon", "dashboard"] as const) { + it(`${command} eagerly ensures the bundled Grok runtime before loading enabled plugins`, () => { + const source = readCommand(command); + const importIndex = source.indexOf("ensureBundledGrokRuntimePluginInstalled"); + const ensureIndex = source.indexOf("ensureBundledGrokRuntimePluginInstalled(pluginStore, pluginLoader)"); + const loadIndex = source.indexOf("pluginLoader.loadAllPlugins()"); + + expect(importIndex).toBeGreaterThanOrEqual(0); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(loadIndex).toBeGreaterThanOrEqual(0); + expect(ensureIndex).toBeLessThan(loadIndex); + }); + } +}); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index c63c0cf18b..845974cdaf 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -76,7 +76,7 @@ import { resolveSelfExtension } from "./self-extension.js"; import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; -import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; +import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled } from "../plugins/bundled-plugin-install.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; @@ -521,6 +521,21 @@ export async function runDaemon(opts: DaemonOptions = {}) { console.warn(`[plugins] Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`); } + /* + * FNXC:GrokCliRouting 2026-07-09-23:05: + * FN-7761: packaged daemon sessions must load the bundled Grok CLI runtime before executors/reviewers create sessions, so grok-cli/no-key routing uses the logged-in `grok` CLI instead of pi's key-requiring direct endpoint. + */ + try { + const installStatus = await ensureBundledGrokRuntimePluginInstalled(pluginStore, pluginLoader); + if (installStatus === "installed") { + console.log("[plugins] Installed bundled Grok CLI runtime plugin"); + } else if (installStatus === "missing-bundle") { + console.warn("[plugins] Bundled Grok CLI runtime plugin was not found in this build"); + } + } catch (err) { + console.warn(`[plugins] Failed to auto-install bundled Grok CLI runtime plugin: ${err instanceof Error ? err.message : err}`); + } + // Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView) // can discover installed runtimes like Hermes and OpenClaw. try { diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index d86adc0e0a..09a24b9d06 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -98,7 +98,7 @@ import { } from "./llama-cpp-extension.js"; import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"; import { resolveSelfExtension } from "./self-extension.js"; -import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; +import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js"; @@ -1320,6 +1320,24 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ); } + /* + * FNXC:GrokCliRouting 2026-07-09-23:05: + * FN-7761: packaged `fn dashboard` must make fusion-plugin-grok-runtime enabled and loadable before chat sends. Without this eager Grok-scoped bootstrap, grok-cli/no-key messages bypass the logged-in `grok` CLI and hit pi's direct endpoint missing-key path. + */ + try { + const installStatus = await ensureBundledGrokRuntimePluginInstalled(pluginStore, pluginLoader); + if (installStatus === "installed") { + logSink.log("Installed bundled Grok CLI runtime plugin", "plugins"); + } else if (installStatus === "missing-bundle") { + logSink.log("Bundled Grok CLI runtime plugin was not found in this build", "plugins"); + } + } catch (err) { + logSink.log( + `Failed to auto-install bundled Grok CLI runtime plugin: ${err instanceof Error ? err.message : err}`, + "plugins", + ); + } + try { const { loaded, errors } = await pluginLoader.loadAllPlugins(); logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins"); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6e8db5f946..8c62c6dbb8 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -78,7 +78,7 @@ import { import { resolveSelfExtension } from "./self-extension.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; -import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; +import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes @@ -546,7 +546,22 @@ export async function runServe( console.warn(`[plugins] Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`); } - // Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip). + /* + * FNXC:GrokCliRouting 2026-07-09-23:05: + * FN-7761: packaged `fn serve` must make the bundled Grok CLI runtime discoverable before any message-sending lane starts. Otherwise grok-cli/no-key selections fall through to the direct xAI endpoint and incorrectly ask for GROK_API_KEY even though the `grok` CLI owns auth. + */ + try { + const installStatus = await ensureBundledGrokRuntimePluginInstalled(pluginStore, pluginLoader); + if (installStatus === "installed") { + console.log("[plugins] Installed bundled Grok CLI runtime plugin"); + } else if (installStatus === "missing-bundle") { + console.warn("[plugins] Bundled Grok CLI runtime plugin was not found in this build"); + } + } catch (err) { + console.warn(`[plugins] Failed to auto-install bundled Grok CLI runtime plugin: ${err instanceof Error ? err.message : err}`); + } + + // Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip/Grok). const ensureBundledPluginInstalledCallback = async (pluginId: string): Promise => { if (!isBundledPluginId(pluginId)) { console.warn(`[plugins] ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`); diff --git a/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts index 5d7fe75151..229ace35fe 100644 --- a/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -38,6 +38,7 @@ import { BUNDLED_PLUGIN_IDS, ensureBundledDependencyGraphPluginInstalled, ensureBundledCursorRuntimePluginInstalled, + ensureBundledGrokRuntimePluginInstalled, ensureBundledPluginInstalled, type BundledPluginDirResolver, } from "../bundled-plugin-install.js"; @@ -47,6 +48,7 @@ import { const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph"; const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime"; const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime"; +const GROK_PLUGIN_ID = "fusion-plugin-grok-runtime"; const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap"; const REPORTS_PLUGIN_ID = "fusion-plugin-reports"; const LINEAR_IMPORT_PLUGIN_ID = "fusion-plugin-linear-import"; @@ -189,6 +191,7 @@ describe("ensureBundledPluginInstalled (host-agnostic shared helper)", () => { expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID); expect(BUNDLED_PLUGIN_IDS).toContain(LINEAR_IMPORT_PLUGIN_ID); expect(BUNDLED_PLUGIN_IDS).toContain(HERMES_PLUGIN_ID); + expect(BUNDLED_PLUGIN_IDS).toContain(GROK_PLUGIN_ID); }); it("fresh install: registers and loads the plugin when not in DB (CLI-shaped resolver)", async () => { @@ -350,6 +353,20 @@ describe("ensureBundledPluginInstalled (host-agnostic shared helper)", () => { ); }); + it("registers and loads the Grok runtime through the dedicated helper", async () => { + setupBundleExists(cliShapedResolver, { id: GROK_PLUGIN_ID }); + const store = makePluginStore(); + const loader = makePluginLoader(); + + const result = await ensureBundledGrokRuntimePluginInstalled(store as never, loader as never, cliShapedResolver); + + expect(result).toBe("installed"); + expect(store.registerPlugin).toHaveBeenCalledWith( + expect.objectContaining({ manifest: expect.objectContaining({ id: GROK_PLUGIN_ID }) }), + ); + expect(loader.loadPlugin).toHaveBeenCalledWith(GROK_PLUGIN_ID); + }); + it("registers Dependency Graph through the deprecated dedicated helper", async () => { setupBundleExists(cliShapedResolver); const store = makePluginStore(); diff --git a/packages/engine/src/__tests__/grok-runtime-routing.test.ts b/packages/engine/src/__tests__/grok-runtime-routing.test.ts index fc21301a3e..ceede6b2be 100644 --- a/packages/engine/src/__tests__/grok-runtime-routing.test.ts +++ b/packages/engine/src/__tests__/grok-runtime-routing.test.ts @@ -295,27 +295,26 @@ describe("Grok CLI runtime routing (FN-7725)", () => { })); }); - it("keeps grok-cli on pi when no key is visible but the Grok runtime is not registered", async () => { + it("surfaces an actionable dual-remediation error instead of falling through to the key-requiring pi runtime when the Grok runtime is unavailable", async () => { vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(undefined), }); - const result = await createResolvedAgentSession({ + /* + * FNXC:GrokCliRouting 2026-07-09-23:05: + * FN-7761 symptom reproduction: packaged hosts previously left fusion-plugin-grok-runtime uninstalled, so getRuntimeById("grok") was undefined and grok-cli/no-key sessions fell through to pi's direct endpoint. The fixed invariant forbids that silent fallback and tells operators to either enable the Grok CLI runtime or set GROK_API_KEY. + */ + await expect(createResolvedAgentSession({ sessionPurpose: "executor", pluginRunner, cwd: "/tmp/project", defaultProvider: "grok-cli", defaultModelId: "grok-4.5", systemPrompt: "no-runtime-registered", - }); + })).rejects.toThrow(/Install and enable the Grok CLI runtime plugin, or set GROK_API_KEY/); - expect(result.runtimeId).toBe("pi"); - expect(result.wasConfigured).toBe(false); - expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ - defaultProvider: "grok-cli", - defaultModelId: "grok-4.5", - })); + expect(mockCreateFnAgent).not.toHaveBeenCalled(); }); it("auto-routes heartbeat/room responder grok-cli defaults to the Grok runtime when no Fusion-visible key exists", async () => { diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index f52790e4a9..081a14a565 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -163,6 +163,13 @@ function stripGrokCliModelProviderPrefix(modelId: string | undefined): string | : normalized; } +function buildMissingGrokRuntimeError(): Error { + return new Error( + "Grok CLI models require the bundled Grok CLI runtime when no Fusion-visible GROK_API_KEY is set. " + + "Install and enable the Grok CLI runtime plugin, or set GROK_API_KEY to use the direct xAI endpoint.", + ); +} + function deriveGrokRuntimeHintForNoVisibleKey( runtimeOptions: AgentRuntimeOptions, pluginRunner: PluginRunner | undefined, @@ -171,10 +178,11 @@ function deriveGrokRuntimeHintForNoVisibleKey( && runtimeOptions.fallbackProvider !== GROK_CLI_PROVIDER_ID) return undefined; if (isGrokApiKeyFusionVisible()) return undefined; try { - return pluginRunner?.getRuntimeById("grok") ? "grok" : undefined; + if (pluginRunner?.getRuntimeById("grok")) return "grok"; } catch { - return undefined; + throw buildMissingGrokRuntimeError(); } + throw buildMissingGrokRuntimeError(); } function applyGrokCliNoKeyRuntimeOptions( @@ -435,6 +443,9 @@ export async function createResolvedAgentSession( FN-7758 extends the no-visible-key invariant to configured fallback models. Pi resolves fallback models during session creation and prompt-time swaps through the key-requiring provider registry, so a grok-cli fallback must select the Grok CLI runtime up front and promote the fallback model into the CLI session. + + FNXC:GrokCliRouting 2026-07-09-23:05: + FN-7761 closes the packaged serve/daemon/dashboard gap: if grok-cli is selected and no Fusion-visible key exists, this seam must never silently fall through to the key-requiring pi/openai-completions runtime when the Grok plugin was not pre-installed. The hosts eagerly install/load the bundled runtime; if that genuinely fails, throw an operator-actionable error naming the two supported remediations. */ const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint ? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner)