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:
7
.changeset/fn-7714-grok-user-settings-apikey.md
Normal file
7
.changeset/fn-7714-grok-user-settings-apikey.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
156
packages/core/src/__tests__/grok-provider-user-settings.test.ts
Normal file
156
packages/core/src/__tests__/grok-provider-user-settings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user