From a2c9b0fd526ba67d193cfb458df666677c7ab1d9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 00:28:00 -0700 Subject: [PATCH] FN-7817: Simplify Cursor usage credential to a single documented CURSOR_API_KEY path Consolidates Cursor Admin API key resolution onto one documented env var so the usage/admin credential path is reachable and unambiguous, replacing the prior dual CURSOR_ADMIN_API_KEY/CURSOR_API_KEY and multi-provider-id lookup. - Replace CURSOR_ADMIN_API_KEY (preferred) + CURSOR_API_KEY alias with a single CURSOR_API_KEY env var, mirroring the GROK_API_KEY precedent - Simplify readCursorApiKey to check CURSOR_API_KEY then fall back to the single "cursor" authStorage entry via readConfiguredApiKey (drop the cursor/cursor-cli/cursor-agent provider-id loop) - Export readCursorApiKey and fetchCursorUsage for direct test coverage - Update the no-auth error message and settings-reference.md docs to reference only CURSOR_API_KEY, clarifying cursor-cli OAuth/session auth vs the separate Admin API usage-metering credential - Add changeset (@runfusion/fusion: minor) documenting the credential-path change - Add/adjust usage.test.ts coverage for readCursorApiKey precedence (env over authStorage) and the updated credential-absent error message Files changed: .changeset/fn-7817-cursor-api-key.md | 7 ++++ docs/settings-reference.md | 8 ++-- packages/dashboard/src/__tests__/usage.test.ts | 53 +++++++++++++++++++++++++- packages/dashboard/src/usage.ts | 52 ++++++++----------------- 4 files changed, 77 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-7817 Fusion-Task-Lineage: 86ac3d47-8e80-4159-abee-6c41aae56407 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7817-cursor-api-key.md | 7 +++ docs/settings-reference.md | 8 +-- .../dashboard/src/__tests__/usage.test.ts | 53 ++++++++++++++++++- packages/dashboard/src/usage.ts | 52 ++++++------------ 4 files changed, 77 insertions(+), 43 deletions(-) create mode 100644 .changeset/fn-7817-cursor-api-key.md diff --git a/.changeset/fn-7817-cursor-api-key.md b/.changeset/fn-7817-cursor-api-key.md new file mode 100644 index 0000000000..e84f4576e0 --- /dev/null +++ b/.changeset/fn-7817-cursor-api-key.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a documented CURSOR_API_KEY credential path for Cursor usage metering. +category: feature +dev: usage.ts adds readCursorApiKey (CURSOR_API_KEY env var → cursor authStorage entry, mirroring readGrokApiKey); settings-reference.md documents it and clarifies cursor-cli runtime OAuth vs the usage/admin API key. Unblocks FN-7816. Cursor usage-API specifics confirmed via Cursor Admin API docs: POST /teams/spend with Basic auth using an admin:* API key as the username. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 93f6efbc96..26cbe5930c 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -106,7 +106,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. | | `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. | | `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. | -| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. | +| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. This runtime auth is OAuth/session-based; Cursor usage metering is separate and reads a Cursor Admin API key from the dashboard process `CURSOR_API_KEY` env var. | | `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. | | `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok's direct xAI endpoint uses API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`); when a `grok-cli/*` execution model has no Fusion-visible key, Fusion falls back to the `grok` CLI runtime if registered so the CLI can use its own auth store. | | `grokCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Grok CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `grok` on PATH. Invalid non-empty saves are rejected with bounded diagnostics. | @@ -990,11 +990,11 @@ When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows. - + -The Usage dropdown can show a Cursor card when the dashboard process has a Cursor Admin API key in `CURSOR_ADMIN_API_KEY` (preferred) or `CURSOR_API_KEY` (compatibility alias). Fusion calls Cursor Admin API `POST https://api.cursor.com/teams/spend` with Basic auth (`API_KEY:`) and maps documented team spend fields into the generic usage-window UI. If only the `cursor-agent` session/OAuth login is present, Fusion omits the Cursor usage card because Cursor has not documented a personal/session usage endpoint; expired Admin API keys remain visible as an error card so operators can rotate the environment secret. +The Usage dropdown can show a Cursor card when the dashboard process has a Cursor Admin API key in `CURSOR_API_KEY`. Fusion calls Cursor Admin API `POST https://api.cursor.com/teams/spend` with Basic auth (`API_KEY:`) and maps documented team spend fields into the generic usage-window UI. Cursor documents API key creation at `cursor.com/dashboard` → **API Keys** with `admin:*` scope for Admin API access, but does not document a local Admin API key file or an env-var name; `CURSOR_API_KEY` is Fusion's operator-facing convention. If only the `cursor-agent` session/OAuth login is present, Fusion omits the Cursor usage card because Cursor has not documented a personal/session usage endpoint; expired Admin API keys remain visible as an error card so operators can rotate the environment secret. -When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok direct-endpoint auth is API-key based, but CLI-routed execution lets the `grok` binary use any auth source it supports; the Settings card still surfaces Fusion-visible key detection only as an informational hint. +When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Grok direct-endpoint auth is API-key based (`GROK_API_KEY` or the Grok CLI settings-file fallback), while Cursor has two distinct paths: `cursor-cli` model execution uses OAuth/session auth and Cursor usage metering uses Fusion's `CURSOR_API_KEY` Admin API key env var. CLI-routed Grok execution lets the `grok` binary use any auth source it supports; the Settings card still surfaces Fusion-visible key detection only as an informational hint. The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) are additively surfaced under the `openai-codex` provider (FN-7745/FN-7754/FN-7759, mirroring the Anthropic/Z.ai supplemental-merge pattern above) so they appear both in dashboard `/api/models` and the engine/pi `createFnAgent` registry-seeding surface whenever `openai-codex` is configured — deduped against any pinned pi-ai catalog row that already carries one of the ids. FN-7759 specifically keeps the supplemental registration compatible with the real pi-coding-agent `ModelRegistry` by preserving the OpenAI Codex OAuth provider during dynamic full-provider replacement, so legacy catalogs without native 5.6 rows still survive `getAvailable()` auth filtering and remain executable. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 3c2a6a1ad8..a254439228 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -35,6 +35,8 @@ import { withTimeout, CLAUDE_FETCH_TIMEOUT_MS, _clearRefreshedToken, + readCursorApiKey, + fetchCursorUsage, } from "../usage.js"; // Mock the https module @@ -92,7 +94,6 @@ describe("usage", () => { vi.stubEnv("HOME", "/home/testuser"); vi.stubEnv("CODEX_HOME", ""); vi.stubEnv("GROK_API_KEY", ""); - vi.stubEnv("CURSOR_ADMIN_API_KEY", ""); vi.stubEnv("CURSOR_API_KEY", ""); }); @@ -3255,6 +3256,45 @@ describe("usage", () => { }); }); + describe("readCursorApiKey", () => { + const cursorAuthStorage = (apiKey: string) => ({ + reload: vi.fn(), + hasAuth: vi.fn((provider: string) => provider === "cursor"), + getApiKey: vi.fn((provider: string) => provider === "cursor" ? apiKey : null), + get: vi.fn(), + }); + + it("resolves a trimmed CURSOR_API_KEY without injected authStorage", async () => { + vi.stubEnv("CURSOR_API_KEY", " cursor-env-key "); + + await expect(readCursorApiKey()).resolves.toBe("cursor-env-key"); + }); + + it("returns null when CURSOR_API_KEY is unset or blank and no authStorage key exists", async () => { + mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found"))); + vi.stubEnv("CURSOR_API_KEY", " "); + + await expect(readCursorApiKey()).resolves.toBeNull(); + }); + + it("resolves the documented cursor authStorage api-key entry", async () => { + mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found"))); + const authStorage = cursorAuthStorage("cursor-storage-key"); + + await expect(readCursorApiKey(authStorage)).resolves.toBe("cursor-storage-key"); + expect(authStorage.reload).toHaveBeenCalled(); + expect(authStorage.getApiKey).toHaveBeenCalledWith("cursor"); + }); + + it("prefers CURSOR_API_KEY over authStorage", async () => { + vi.stubEnv("CURSOR_API_KEY", "cursor-env-key"); + const authStorage = cursorAuthStorage("cursor-storage-key"); + + await expect(readCursorApiKey(authStorage)).resolves.toBe("cursor-env-key"); + expect(authStorage.getApiKey).not.toHaveBeenCalled(); + }); + }); + describe("fetchCursorUsage (via fetchAllProviderUsage)", () => { const cursorAuthStorage = (apiKey: string) => ({ reload: vi.fn(), @@ -3312,7 +3352,7 @@ describe("usage", () => { ], }); - vi.stubEnv("CURSOR_ADMIN_API_KEY", "cursor-admin-env-key"); + vi.stubEnv("CURSOR_API_KEY", "cursor-admin-env-key"); const providers = await fetchAllProviderUsage(); const cursor = providers.find((p) => p.name === "Cursor")!; @@ -3366,6 +3406,15 @@ describe("usage", () => { expect(cursor).toBeUndefined(); }); + it("names CURSOR_API_KEY in the credential-absent message", async () => { + mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found"))); + + const cursor = await fetchCursorUsage(); + + expect(cursor.status).toBe("no-auth"); + expect(cursor.error).toBe("No Cursor Admin API key — set CURSOR_API_KEY in the Fusion dashboard environment"); + }); + it("omits Cursor when the spend response has no meterable row", async () => { mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found"))); mockCursorAccount({ email: "developer@company.com" }); diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 092907e818..45d5518338 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1813,8 +1813,8 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise { - for (const envName of CURSOR_ADMIN_API_KEY_ENV_VARS) { - const envKey = process.env[envName]; - if (typeof envKey === "string" && envKey.trim().length > 0) { - return envKey.trim(); - } +/* +FNXC:UsageProviders 2026-07-11-00:00: +Cursor Admin API usage metering needs a real operator-reachable credential path, but Cursor CLI runtime auth remains OAuth/session-based through `cursor-agent status` and is not an Admin API key. Use Fusion's documented `CURSOR_API_KEY` environment variable first, mirroring the `GROK_API_KEY` precedent, then the single `cursor` authStorage fallback for tests/imported credentials. Cursor documents Basic Auth with an API key for the Admin API and `POST /teams/spend`, but does not document a local Admin API key file, so no file fallback is invented here. +*/ +export async function readCursorApiKey(authStorage?: AuthStorageLike): Promise { + const envKey = process.env[CURSOR_API_KEY_ENV_VAR]; + if (typeof envKey === "string" && envKey.trim().length > 0) { + return envKey.trim(); } - try { - authStorage?.reload(); - } catch { - // Reload may fail if no storage - ignore. - } - - for (const providerId of CURSOR_API_KEY_PROVIDER_IDS) { - try { - const apiKey = await authStorage?.getApiKey?.(providerId); - if (apiKey) return apiKey; - } catch { - // Try the next provider id. - } - - try { - const entry = authStorage?.get?.(providerId); - if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) { - return entry.key; - } - } catch { - // Try the next provider id. - } - } - - return null; + return readConfiguredApiKey(CURSOR_API_KEY_PROVIDER_ID, authStorage); } async function readCursorAccountInfo(): Promise { @@ -1951,7 +1929,7 @@ function selectCursorSpendRow(rows: Record[], email?: string): return rows.length === 1 ? rows[0] : null; } -async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise { +export async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise { const usage: ProviderUsage = { name: "Cursor", icon: "🟣", @@ -1961,7 +1939,7 @@ async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise = { page: 1, pageSize: 500 }; if (account.email) body.searchTerm = account.email;