diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 1c1aa2d8b5..2a96b42c83 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -471,9 +471,11 @@ provider-wide adapter. To opt a model without a built-in default (for example Cursor is tracked separately as an experimental unofficial adapter. `adapter: "cursor"` appears in `ccx init` and the dashboard Add Provider picker as an experimental local config entry with Cursor's static fallback model catalog metadata. Default auth is PKCE (`ccx login cursor`). A pasted -[dashboard user API key](https://cursor.com/dashboard/api) is dual-mode on the same `cursor` +[dashboard user API key](https://cursor.com/dashboard/api) is dual-mode on the same canonical `cursor` provider: set `authMode: "key"` (Add Provider → **Use an API key instead**, or the Settings API-key -pool). That key uses the same unofficial `api2.cursor.sh` AgentService/Run protocol as OAuth — it is +pool). OAuth accounts are only for `providers.cursor`; a custom id with `adapter: "cursor"` can still +use a key, but the dashboard will not show Cursor OAuth controls that the backend cannot honor. +That key uses the same unofficial `api2.cursor.sh` AgentService/Run protocol as OAuth — it is **not** a documented OpenAI `/v1/chat/completions` credential, and Cursor Cloud Agents keys from `api.cursor.com` are a different product and will not work here. Dashboard `crsr_` user API keys are exchanged via `POST /auth/exchange_user_api_key` when they are not already a valid Run Bearer. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 011537c49e..9ac97d9f6d 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -183,7 +183,9 @@ advertised effort control on those models as proof of upstream-native reasoning ## `cursor` **Targets:** Cursor's `agent.v1.AgentService/Run` over HTTP/2 Connect streaming at `api2.cursor.sh`. -**Auth:** Dual-mode on the same `cursor` row. Default is PKCE OAuth (`ccx login cursor`). A pasted +**Auth:** Dual-mode on the canonical `providers.cursor` row only — a custom provider that reuses +`adapter: "cursor"` can still paste a dashboard key, but it does not get Cursor OAuth controls +(those endpoints accept provider id `cursor`). Default is PKCE OAuth (`ccx login cursor`). A pasted [dashboard user API key](https://cursor.com/dashboard/api) with `authMode: "key"` uses the same unofficial AgentService/Run protocol — not a public OpenAI `/v1/chat/completions` credential, and not Cursor Cloud Agents keys from `api.cursor.com`. Dashboard `crsr_` keys are exchanged via diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index acd84aa937..ef446ff043 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -252,10 +252,11 @@ so passthrough stays byte-for-byte identical. The Cursor bridge is experimental and unofficial (elevated ToS risk). Default auth is `ccx login cursor` (PKCE). You can instead paste a [dashboard user API key](https://cursor.com/dashboard/api) -with `authMode: "key"` on the same `providers.cursor` row — the unofficial `api2.cursor.sh` -AgentService/Run protocol, not a public OpenAI chat-completions API. Cloud Agents keys from -`api.cursor.com` are a different product and will not work here. After login or a working Run Bearer, -add or edit `providers.cursor`. +with `authMode: "key"` on the same canonical `providers.cursor` row — the unofficial `api2.cursor.sh` +AgentService/Run protocol, not a public OpenAI chat-completions API. OAuth dual-mode is that +canonical id only; a custom provider using `adapter: "cursor"` can still store a dashboard key. +Cloud Agents keys from `api.cursor.com` are a different product and will not work here. After login +or a working Run Bearer, add or edit `providers.cursor`. Cursor Router's optimization ladder is exposed as separate Codex ids because the picker cannot render Cursor-specific model parameters: diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 593e3a4996..4de211ba9c 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObjec import type { AccountLoadState } from "../components/provider-workspace/types"; import { accountNeedsReauth } from "../oauth-health-display"; import type { AccountQuota } from "../codex-quota-utils"; -import { oauthAccountDisplayLabel } from "../provider-workspace/auth"; +import { isCursorKeyAuthOverride, oauthAccountDisplayLabel } from "../provider-workspace/auth"; export interface Config { port: number; @@ -239,7 +239,7 @@ export function useProviderAccountPools(deps: { const oauthCardProviders = useMemo( () => config ? Object.entries(config.providers) - .filter(([name, p]) => p.authMode === "oauth" || name === "cursor" || p.adapter === "cursor") + .filter(([name, p]) => p.authMode === "oauth" || isCursorKeyAuthOverride({ name, adapter: p.adapter })) .map(([n]) => n) : [], [config], diff --git a/gui/src/provider-workspace/auth.ts b/gui/src/provider-workspace/auth.ts index 8b646e0df6..74f3e59f65 100644 --- a/gui/src/provider-workspace/auth.ts +++ b/gui/src/provider-workspace/auth.ts @@ -28,9 +28,14 @@ export function providerAuthSurface(item: WorkspaceItem): ProviderAuthSurface { return "api-keys"; } -/** Cursor is OAuth-default dual-mode: Settings shows accounts and an API-key pool. */ +/** + * Canonical `providers.cursor` is OAuth-default dual-mode: Settings shows accounts and an API-key + * pool. Backend OAuth endpoints only accept the `cursor` provider id, so a custom row that reuses + * `adapter: "cursor"` must not get those OAuth controls. Key-only custom cursor adapters still + * resolve through the normal key surface when the backend accepts keys for them. + */ export function isCursorKeyAuthOverride(item: Pick): boolean { - return item.name.trim().toLowerCase() === "cursor" || item.adapter === "cursor"; + return item.name.trim().toLowerCase() === "cursor" && item.adapter === "cursor"; } /** Human-safe label for OAuth account rows; opaque storage ids stay private. */ diff --git a/gui/tests/cursor-apikey-dual-mode.test.tsx b/gui/tests/cursor-apikey-dual-mode.test.tsx index 1937214740..8e6f1fd6ca 100644 --- a/gui/tests/cursor-apikey-dual-mode.test.tsx +++ b/gui/tests/cursor-apikey-dual-mode.test.tsx @@ -138,3 +138,35 @@ test("Settings for Cursor oauth still shows accounts and an API-key pool", async expect(host.textContent).toContain(CURSOR_HINT); expect(host.textContent).toContain(en["pws.addKey"]); }); + +test("a custom cursor adapter does not get the OAuth dual-mode surface; canonical cursor still does", async () => { + const custom: WorkspaceItem = { + name: "custom-cursor", + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authMode: "key", + hasApiKey: true, + }; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + + expect(host.textContent).toContain(en["pws.apiKeys"]); + expect(host.textContent).toContain(en["pws.addKey"]); + expect(host.textContent).not.toContain(en["pws.availableAccounts"]); + expect(host.textContent).not.toContain(en["pws.notLoggedInTitle"]); + expect(host.textContent).not.toContain(en["prov.login"]); + expect(host.textContent).not.toContain(CURSOR_HINT); +}); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 15b75ff2ec..345f3aa8b4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1004,25 +1004,35 @@ async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name); return cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured; } - const liveResult = await fetchCursorUsableModels({ - apiKey: await materializeCursorRunBearer(apiKey), - baseUrl: prov.baseUrl, - }); - if (liveResult.ok) { - const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); - const result = available.length > 0 ? available : configured; - // Count what discovery actually returned, not the configured rows we fall back to. - markProviderDiscoveryOk(name, liveResult.models.length); - setCached(name, result); - return result; + const degradeCursorDiscovery = (error: string, detail?: string): CatalogModel[] => { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn( + `[codexcommander] Cursor model discovery for "${name}" failed [${error}]${detail ? `: ${detail}` : ""}; using stale/static catalog degradation.`, + ); + const staleCursor = getStaleCached(name); + return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured; + }; + // Dashboard-key exchange (`materializeCursorRunBearer`) and GetUsableModels can throw or + // 5xx. Catalog gather uses Promise.all across providers, so a Cursor-only failure must + // degrade here — never reject the whole catalog. + try { + const liveResult = await fetchCursorUsableModels({ + apiKey: await materializeCursorRunBearer(apiKey), + baseUrl: prov.baseUrl, + }); + if (liveResult.ok) { + const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); + const result = available.length > 0 ? available : configured; + // Count what discovery actually returned, not the configured rows we fall back to. + markProviderDiscoveryOk(name, liveResult.models.length); + setCached(name, result); + return result; + } + return degradeCursorDiscovery(liveResult.error, liveResult.detail); + } catch (error) { + return degradeCursorDiscovery("throw", error instanceof Error ? error.name : undefined); } - markModelsFetchFailure(name); - markProviderDiscoveryFailed(name, { reason: "provider" }); - console.warn( - `[codexcommander] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, - ); - const staleCursor = getStaleCached(name); - return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured; } if (prov.authMode === "oauth" && !apiKey) { // No usable token (logged out, or account marked needsReauth). Still surface the diff --git a/tests/cursor-apikey-dual-mode.test.ts b/tests/cursor-apikey-dual-mode.test.ts index 137f023fc1..9c3c9e270a 100644 --- a/tests/cursor-apikey-dual-mode.test.ts +++ b/tests/cursor-apikey-dual-mode.test.ts @@ -135,7 +135,18 @@ describe("Cursor API-key dual-mode contract", () => { expect(providerAuthSurface(keyCursor)).toBe("api-keys"); expect(isCursorKeyAuthOverride(oauthCursor)).toBe(true); expect(isCursorKeyAuthOverride(keyCursor)).toBe(true); + expect(isCursorKeyAuthOverride({ name: "Cursor", adapter: "cursor" })).toBe(true); expect(isCursorKeyAuthOverride({ name: "xai", adapter: "openai-chat" })).toBe(false); + expect(isCursorKeyAuthOverride({ name: "custom-cursor", adapter: "cursor" })).toBe(false); + expect(isCursorKeyAuthOverride({ name: "cursor", adapter: "openai-chat" })).toBe(false); + }); + + test("dual-mode OAuth surface is canonical providers.cursor only", async () => { + const pools = await Bun.file("gui/src/hooks/useProviderAccountPools.ts").text(); + expect(pools).toContain("isCursorKeyAuthOverride({ name, adapter: p.adapter })"); + const auth = await Bun.file("gui/src/provider-workspace/auth.ts").text(); + expect(auth).toContain('item.name.trim().toLowerCase() === "cursor" && item.adapter === "cursor"'); + expect(auth).not.toContain('item.name.trim().toLowerCase() === "cursor" || item.adapter === "cursor"'); }); }); diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 4de7bc0aba..4e8dcdc8ff 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -7,11 +7,13 @@ import { ModelDetailsSchema, } from "../src/adapters/cursor/gen/agent_pb"; import { encodeConnectFrame } from "../src/adapters/cursor/framing"; +import * as cursorLiveModels from "../src/adapters/cursor/live-models"; import { fetchCursorUsableModels } from "../src/adapters/cursor/live-models"; +import * as cursorRunBearer from "../src/adapters/cursor/run-bearer"; import { armTimeoutDestroyFallback, createLiveCursorTransport, createTerminalSettler } from "../src/adapters/cursor/live-transport"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; import { gatherRoutedModels } from "../src/codex/catalog"; -import { clearModelCache, getProviderDiscoveryStatus } from "../src/codex/model-cache"; +import { clearModelCache, getProviderDiscoveryStatus, setCached } from "../src/codex/model-cache"; import { handleManagementAPI } from "../src/server/management-api"; async function withDiscoveryServer( @@ -270,6 +272,87 @@ describe("Cursor catalog discovery cooldown", () => { }); }); +describe("Cursor catalog gather isolation", () => { + const sibling = { + adapter: "openai-chat" as const, + baseUrl: "https://api.example.test/v1", + apiKey: "sibling-key", + liveModels: false, + models: ["sibling-model"], + }; + + function cursorProvider(apiKey: string, models = ["auto", "composer-2.5"]) { + return { + adapter: "cursor" as const, + baseUrl: "https://api2.cursor.sh", + apiKey, + models, + }; + } + + test("a dashboard-key exchange throw does not reject Promise.all; siblings stay and Cursor degrades to static", async () => { + const cursorName = "cursor-exchange-throw"; + const siblingName = "sibling-after-cursor-exchange"; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const exchange = spyOn(cursorRunBearer, "materializeCursorRunBearer").mockRejectedValue( + new Error("Cursor token refresh failed: 500"), + ); + try { + const models = await gatherRoutedModels({ + providers: { + [cursorName]: cursorProvider("crsr_transient_500"), + [siblingName]: sibling, + }, + }); + const ids = models.map(model => `${model.provider}/${model.id}`); + expect(ids).toContain(`${siblingName}/sibling-model`); + expect(ids).toContain(`${cursorName}/auto`); + expect(ids).toContain(`${cursorName}/composer-2.5`); + expect(exchange).toHaveBeenCalled(); + expect(warning.mock.calls.some(args => String(args[0]).includes( + `Cursor model discovery for "${cursorName}" failed [throw]`, + ))).toBe(true); + } finally { + exchange.mockRestore(); + warning.mockRestore(); + clearModelCache(cursorName); + clearModelCache(siblingName); + } + }); + + test("a GetUsableModels throw/500 does not reject Promise.all; Cursor falls back to stale cache", async () => { + const cursorName = "cursor-discovery-throw"; + const siblingName = "sibling-after-cursor-discovery"; + setCached(cursorName, [{ id: "cached-composer", provider: cursorName }], 0); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const discovery = spyOn(cursorLiveModels, "fetchCursorUsableModels").mockRejectedValue( + new Error("HTTP 500"), + ); + try { + const models = await gatherRoutedModels({ + modelCacheTtlMs: 0, + providers: { + [cursorName]: cursorProvider("hdr.payload.sig"), + [siblingName]: sibling, + }, + }); + const ids = models.map(model => `${model.provider}/${model.id}`); + expect(ids).toContain(`${siblingName}/sibling-model`); + expect(ids).toContain(`${cursorName}/cached-composer`); + expect(ids).not.toContain(`${cursorName}/auto`); + expect(discovery).toHaveBeenCalled(); + expect(warning.mock.calls.some(args => String(args[0]).includes( + `Cursor model discovery for "${cursorName}" failed [throw]`, + ))).toBe(true); + } finally { + discovery.mockRestore(); + warning.mockRestore(); + clearModelCache(cursorName); + clearModelCache(siblingName); + } + }); +}); + describe("Cursor terminal settler", () => { function harness() { const calls = { fail: 0, finish: 0, clear: 0, lastError: undefined as Error | undefined };