diff --git a/.changeset/fn-7714-grok-user-settings-apikey.md b/.changeset/fn-7714-grok-user-settings-apikey.md new file mode 100644 index 0000000000..0955caf601 --- /dev/null +++ b/.changeset/fn-7714-grok-user-settings-apikey.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index df498ca96f..072a06d621 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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/` 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/` 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. diff --git a/packages/core/src/__tests__/grok-provider-user-settings.test.ts b/packages/core/src/__tests__/grok-provider-user-settings.test.ts new file mode 100644 index 0000000000..fe27eca3ed --- /dev/null +++ b/packages/core/src/__tests__/grok-provider-user-settings.test.ts @@ -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(); + 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(); + }); +}); diff --git a/packages/core/src/grok-provider.ts b/packages/core/src/grok-provider.ts index 22902854b3..4de4d59a90 100644 --- a/packages/core/src/grok-provider.ts +++ b/packages/core/src/grok-provider.ts @@ -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) {