FN-7714: honor ~/.grok/user-settings.json apiKey when GROK_API_KEY is unset

Fall back to the Grok CLI's user-settings file for the API key so pi's $GROK_API_KEY provider reference resolves even when the env var isn't exported.

- Add hydrateGrokApiKeyFromUserSettings() in grok-provider.ts, called from registerBuiltInGrokProvider(), which hydrates process.env.GROK_API_KEY from ~/.grok/user-settings.json { apiKey } only when the env var is unset/empty
- Env var always wins; a missing (ENOENT), malformed, or empty-apiKey settings file is fail-soft (no throw, no env mutation), mirroring the grok-runtime probe's fallback behavior
- Add regression tests covering env-precedence, fallback hydration, and fail-soft error paths (grok-provider-user-settings.test.ts)
- Document the fallback in docs/settings-reference.md
- Add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7714-grok-user-settings-apikey.md    |   7 +
 docs/settings-reference.md                         |   2 +-
 .../__tests__/grok-provider-user-settings.test.ts  | 156 +++++++++++++++++++++
 packages/core/src/grok-provider.ts                 |  47 +++++++
 4 files changed, 211 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7714

Fusion-Task-Lineage: 5450b480-3a32-4331-9494-867b84605464

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 00:27:54 -07:00
parent 335dfc3bec
commit b2613b7132
4 changed files with 211 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Grok now uses the key from ~/.grok/user-settings.json when GROK_API_KEY is not set.
category: fix
dev: registerBuiltInGrokProvider (packages/core/src/grok-provider.ts) now hydrates process.env.GROK_API_KEY from ~/.grok/user-settings.json { apiKey } when the env var is unset/empty, so the provider's $GROK_API_KEY reference resolves. Env var always wins; missing/malformed/empty file is fail-soft (no throw, no env mutation). Mirrors the grok-runtime probe's fallback.

View File

@@ -966,7 +966,7 @@ Fusion resolves task models through workflow-backed lane values first, then glob
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.
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).
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.

View File

@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:fs", () => ({ readFileSync: vi.fn() }));
import { readFileSync } from "node:fs";
import {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
hydrateGrokApiKeyFromUserSettings,
registerBuiltInGrokProvider,
} from "../grok-provider.js";
const ORIGINAL_ENV = { ...process.env };
function makeFakeRegistry() {
const registeredProviders = new Map<string, unknown>();
return {
registeredProviders,
registerProvider(providerName: string, config: unknown) {
registeredProviders.set(providerName, config);
},
};
}
/*
FNXC:ProviderAuth 2026-07-09-00:00:
FN-7714 regression coverage for hydrateGrokApiKeyFromUserSettings / registerBuiltInGrokProvider:
mirrors probe.test.ts's fallback/precedence/fail-soft matrix so the $GROK_API_KEY env
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("hydrateGrokApiKeyFromUserSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...ORIGINAL_ENV };
delete process.env.GROK_API_KEY;
});
it("reproduces then resolves the original unresolved-key symptom: env unset + file has apiKey", () => {
// Original symptom: GROK_API_KEY unset means $GROK_API_KEY resolves to nothing.
expect(process.env.GROK_API_KEY).toBeUndefined();
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: "xai-from-file" }));
hydrateGrokApiKeyFromUserSettings();
// Assertion it is gone: process.env.GROK_API_KEY is now populated so $GROK_API_KEY resolves.
expect(process.env.GROK_API_KEY).toBe("xai-from-file");
});
it("env set (non-empty) always wins and the file is never read", () => {
process.env.GROK_API_KEY = "xai-from-env";
hydrateGrokApiKeyFromUserSettings();
expect(process.env.GROK_API_KEY).toBe("xai-from-env");
expect(readFileSync).not.toHaveBeenCalled();
});
it("env set to empty/whitespace is treated as unset, so file fallback applies", () => {
process.env.GROK_API_KEY = " ";
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: "xai-from-file" }));
hydrateGrokApiKeyFromUserSettings();
expect(process.env.GROK_API_KEY).toBe("xai-from-file");
});
it("file missing (ENOENT) is fail-soft: no throw, env stays unset", () => {
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
vi.mocked(readFileSync).mockImplementationOnce(() => {
throw enoent;
});
expect(() => hydrateGrokApiKeyFromUserSettings()).not.toThrow();
expect(process.env.GROK_API_KEY).toBeUndefined();
});
it("malformed JSON is fail-soft: no throw, env stays unset", () => {
vi.mocked(readFileSync).mockReturnValueOnce("not json at all");
expect(() => hydrateGrokApiKeyFromUserSettings()).not.toThrow();
expect(process.env.GROK_API_KEY).toBeUndefined();
});
it("apiKey absent is fail-soft: no throw, env stays unset", () => {
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({}));
expect(() => hydrateGrokApiKeyFromUserSettings()).not.toThrow();
expect(process.env.GROK_API_KEY).toBeUndefined();
});
it("apiKey empty string is fail-soft: no throw, env stays unset", () => {
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: " " }));
expect(() => hydrateGrokApiKeyFromUserSettings()).not.toThrow();
expect(process.env.GROK_API_KEY).toBeUndefined();
});
it("apiKey non-string is fail-soft: no throw, env stays unset", () => {
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: 12345 }));
expect(() => hydrateGrokApiKeyFromUserSettings()).not.toThrow();
expect(process.env.GROK_API_KEY).toBeUndefined();
});
});
describe("registerBuiltInGrokProvider — hydration wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...ORIGINAL_ENV };
delete process.env.GROK_API_KEY;
});
it("hydrates process.env.GROK_API_KEY from the settings file before registering", () => {
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: "xai-from-file" }));
const registry = makeFakeRegistry();
registerBuiltInGrokProvider(registry);
expect(process.env.GROK_API_KEY).toBe("xai-from-file");
const registered = registry.registeredProviders.get(GROK_CLI_PROVIDER_ID) as typeof GROK_PROVIDER_REGISTRATION;
// No displacement: still registers grok-cli with the unchanged $GROK_API_KEY reference.
expect(registered.apiKey).toBe("$GROK_API_KEY");
expect(registered).toMatchObject({
name: "Grok",
baseUrl: "https://api.x.ai/v1",
api: "openai-completions",
});
});
it("is idempotent: a second call does not re-read or overwrite an already-hydrated env value", () => {
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: "xai-from-file" }));
const registry = makeFakeRegistry();
registerBuiltInGrokProvider(registry);
expect(process.env.GROK_API_KEY).toBe("xai-from-file");
expect(readFileSync).toHaveBeenCalledTimes(1);
// Second call: env is now set, so the file must not be read again, and the value must
// not be clobbered even if the (mocked) file would return something else.
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: "xai-should-not-be-used" }));
registerBuiltInGrokProvider(registry);
expect(process.env.GROK_API_KEY).toBe("xai-from-file");
expect(readFileSync).toHaveBeenCalledTimes(1);
});
it("leaves an operator-provided GROK_API_KEY untouched", () => {
process.env.GROK_API_KEY = "xai-operator-provided";
const registry = makeFakeRegistry();
registerBuiltInGrokProvider(registry);
expect(process.env.GROK_API_KEY).toBe("xai-operator-provided");
expect(readFileSync).not.toHaveBeenCalled();
});
});

View File

@@ -20,6 +20,10 @@
* provider's model list wholesale rather than merging).
*/
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export const GROK_CLI_PROVIDER_ID = "grok-cli";
type GrokModelInput = "text" | "image";
@@ -173,10 +177,53 @@ function cloneGrokProviderRegistration(config: GrokProviderRegistration): GrokPr
* registry" hard-fail this task fixes. Always pass a cloned config because pi's
* registerProvider() stores and mutates the config object during later upserts.
*/
/**
* FNXC:ProviderAuth 2026-07-09-00:00:
* FN-7714 honors a Grok API key stored in `~/.grok/user-settings.json` (`{ "apiKey": ... }`)
* when `GROK_API_KEY` is not set in the environment. `GROK_PROVIDER_REGISTRATION.apiKey` is
* the static env reference `"$GROK_API_KEY"`, which pi resolves from `process.env.GROK_API_KEY`
* at request time — so the fix hydrates that env var (never the provider config, which would
* leak the raw key via `/api/models`-style reads and logs) from the settings file, only when
* the env var is currently unset/empty. An already-set `GROK_API_KEY` always wins and the file
* is never read in that case. A missing (ENOENT), malformed, or empty/absent-`apiKey` settings
* file is fail-soft: never throw, never mutate env. This exactly mirrors the precedence and
* fail-soft behavior of `probeGrokApiKeyPresence` in
* `plugins/fusion-plugin-grok-runtime/src/probe.ts` (env-first, `.trim().length > 0`, same
* `~/.grok/user-settings.json` path), kept synchronous here because
* `registerBuiltInGrokProvider` itself is synchronous.
*/
export function hydrateGrokApiKeyFromUserSettings(
logWarning: (message: string) => void = () => {},
): void {
const envKey = process.env.GROK_API_KEY;
if (typeof envKey === "string" && envKey.trim().length > 0) {
// Env var always wins; never read or overwrite from the settings file.
return;
}
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();
}
} catch (error) {
// Fail-soft: a missing (ENOENT), malformed, or unreadable settings file must never throw
// or mutate process.env — only unexpected errors are worth a warning; ENOENT is routine.
const isEnoent = (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
if (!isEnoent) {
const message = error instanceof Error ? error.message : String(error);
logWarning(`Failed to read ~/.grok/user-settings.json for GROK_API_KEY fallback: ${message}`);
}
}
}
export function registerBuiltInGrokProvider(
modelRegistry: GrokModelRegistryLike,
logWarning: (message: string) => void = () => {},
): void {
hydrateGrokApiKeyFromUserSettings(logWarning);
try {
modelRegistry.registerProvider(GROK_CLI_PROVIDER_ID, cloneGrokProviderRegistration(GROK_PROVIDER_REGISTRATION));
} catch (error) {