FN-7761: fix Grok CLI auth to use logged-in CLI instead of requiring API key

Packaged fn serve/daemon/dashboard hosts previously failed with a misleading missing-API-key error for grok-cli agents even though the operator was already logged in via the Grok CLI. This fixes routing so those hosts eagerly ensure the bundled Grok Runtime plugin is installed/loaded before session creation, and no longer silently falls back to the key-requiring direct endpoint when no key is visible.

- Eagerly ensure the bundled fusion-plugin-grok-runtime in serve, daemon, and dashboard commands before loadAllPlugins() so runtime id "grok" is available on fresh installs without manual plugin-settings setup.
- agent-session-helpers.ts: deriveGrokRuntimeHintForNoVisibleKey now throws an actionable error (naming both remediations: install/enable the Grok CLI runtime plugin, or set GROK_API_KEY) instead of silently falling through to the key-requiring pi/openai-completions path when the runtime can't be loaded.
- Update docs/grok-cli-contract.md to document the FN-7761 packaged-host wiring and new no-silent-fallback behavior.
- Add regression tests for the packaged bootstrap behavior and bundled-plugin install path.
- Add changeset for @runfusion/fusion (patch, category: fix).

Files changed:
 .changeset/fn-7761-grok-cli-packaged-routing.md    |  7 +++++
 docs/grok-cli-contract.md                          | 19 +++++++++----
 .../__tests__/grok-runtime-bootstrap.test.ts       | 31 ++++++++++++++++++++++
 packages/cli/src/commands/daemon.ts                | 17 +++++++++++-
 packages/cli/src/commands/dashboard.ts             | 20 +++++++++++++-
 packages/cli/src/commands/serve.ts                 | 19 +++++++++++--
 .../__tests__/bundled-plugin-install.test.ts       | 17 ++++++++++++
 .../src/__tests__/grok-runtime-routing.test.ts     | 17 ++++++------
 packages/engine/src/agent-session-helpers.ts       | 15 +++++++++--
 9 files changed, 142 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-7761
Fusion-Task-Lineage: 3be5f054-965c-4e8a-ad91-6e61d4dc4a42
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 23:43:15 -07:00
parent f6fd6aced4
commit 18841d76a0
9 changed files with 142 additions and 20 deletions

View File

@@ -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.

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, extended by FN-7753/FN-7758)
## Wiring (resolved — FN-7725, extended by FN-7753/FN-7758/FN-7761)
<!--
FNXC:GrokCli 2026-07-09-00:00:
@@ -160,7 +160,7 @@ via the dashboard's agent **Runtime Source → Runtime** picker
plugin runtime, including the bundled Grok Runtime plugin's `runtimeId:
"grok"`, with no Grok-specific code required).
**Automatic no-key fallback (FN-7753/FN-7758):** when `createResolvedAgentSession()` sees
**Automatic no-key fallback (FN-7753/FN-7758/FN-7761):** when `createResolvedAgentSession()` sees
all of the following, it derives the same effective `runtimeHint: "grok"` before
calling `resolveRuntime()`:
@@ -172,6 +172,11 @@ calling `resolveRuntime()`:
`~/.grok/user-settings.json`'s `apiKey` field; and
4. the bundled Grok Runtime plugin has registered runtime id `"grok"`.
<!--
FNXC:GrokCliRouting 2026-07-09-23:05:
FN-7761 closes the packaged-host availability gap. Packaged `fn serve`, `fn daemon`, and `fn dashboard` now eagerly ensure the bundled `fusion-plugin-grok-runtime` before `loadAllPlugins()`, so fresh installs expose runtime id `"grok"` to the shared session seam without requiring the operator to first open plugin settings. If the runtime still cannot be loaded, Fusion fails before pi session creation with a dual-remediation error: install/enable the Grok CLI runtime plugin, or set `GROK_API_KEY` to use the direct xAI endpoint.
-->
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,

View File

@@ -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);
});
}
});

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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<boolean> => {
if (!isBundledPluginId(pluginId)) {
console.warn(`[plugins] ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`);

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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)