diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index caef6227a5..1c1aa2d8b5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -98,7 +98,7 @@ ccx login anthropic # Anthropic Claude (Pro/Max) ccx login kimi # Moonshot Kimi ccx login kiro # import kiro-cli credentials (or token fallback) ccx login google-antigravity -ccx login cursor # standalone Cursor PKCE login +ccx login cursor # standalone Cursor PKCE login (default). Paste a dashboard key in the GUI or config instead. ccx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ccx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) ccx login chatgpt # standalone ChatGPT OAuth login @@ -112,7 +112,7 @@ ccx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K3 (`k3`, 1M context), fixed-window `k3-256k`, compatibility alias `k3[1m]`, and K2.7/K2.6/K2.5 coding models. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing CodexCommander accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Uses the maintained six-model static catalog because CCA does not expose the generic `/models` endpoint. | -| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | +| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental unofficial PKCE login **or** a pasted dashboard user API key. Same unofficial AgentService/Run protocol either way; not a public OpenAI chat-completions API. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), @@ -468,9 +468,16 @@ completions. The precedence is: hard wire pin → your explicit provider-wide adapter. To opt a model without a built-in default (for example `gpt-5.4-nano`) into Responses, set `"modelAdapters": { "gpt-5.4-nano": "openai-responses" }`. -Cursor is tracked separately as an experimental adapter. `adapter: "cursor"` appears in `ccx init` +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. When a Cursor access token is configured, CodexCommander uses Cursor's +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` +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 +**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. +When a Cursor access token is configured, CodexCommander uses Cursor's live HTTP/2 transport. Its bundled fallback seed includes `gpt-5.6-sol` / `terra` / `luna` (1M context), `grok-4.5` / `grok-4.5-fast` (500K), and `kimi-k3` (262K); live discovery decides which remain visible for the account. Cursor serves Kimi K3 only as effort-suffixed wire ids, so @@ -482,8 +489,8 @@ only for trusted local experiments (or via **Providers → Cursor → Edit JSON* See the [Configuration reference](/reference/configuration/#cursor-provider-adapter-cursor) for a full example. MCP, screen recording, and computer-use are available as executor hooks; without a configured local executor, CodexCommander returns typed no-executor results instead of policy-blocking -the request. Cursor OAuth and live model discovery are enabled for this experimental adapter; -Cursor is still not shown in key-login lists. +the request. Cursor OAuth, API-key dual-mode, and live model discovery are enabled for this experimental +adapter. The dashboard still shows the elevated ToS warning for Cursor OAuth. ::: ### Ollama Cloud diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index e60daafa97..011537c49e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -183,7 +183,12 @@ 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:** Cursor OAuth/access token from `provider.apiKey` or the forwarded authorization header. +**Auth:** Dual-mode on the same `cursor` row. 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 +`POST /auth/exchange_user_api_key` when they are not already a valid Run Bearer; a working JWT +Bearer is used as-is. The dashboard still shows the elevated ToS warning. - Uses `runTurn` rather than the ordinary fetch/parse path. Requests, server events, tool arguments, usage checkpoints, and client replies are encoded with `@bufbuild/protobuf` schemas in diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 00f2df8037..acd84aa937 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -250,7 +250,12 @@ so passthrough stays byte-for-byte identical. ## Cursor provider (`adapter: "cursor"`) -The Cursor bridge is experimental. After `ccx login cursor`, add or edit `providers.cursor`. +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`. 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/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index 78cba31d34..659462856a 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -140,6 +140,9 @@ export function AddProviderFormPane({ {t("modal.getApiKey", { label: preset.label })} )} + {form.adapter === "cursor" && ( +

{t("pws.cursorApiKeyHint")}

+ )} onFormChange({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index fdf56514ea..7178483be7 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import { useT } from "../../i18n/shared"; import { IconLock, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; -import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; +import { oauthAccountDisplayLabel, providerAuthSurface, isCursorKeyAuthOverride } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; import { formatOAuthHealthLabel, @@ -78,8 +78,9 @@ export default function ProviderAuthPanel({ }, [accounts]); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); - const isOauth = surface === "oauth-accounts"; - const isKeyAuth = surface === "api-keys"; + const dualMode = isCursorKeyAuthOverride(item); + const isOauth = surface === "oauth-accounts" || dualMode; + const isKeyAuth = surface === "api-keys" || dualMode; if (surface === "codex-accounts") { return ( @@ -97,7 +98,8 @@ export default function ProviderAuthPanel({ ); } - if (!surface || !authHandlers) return null; + if (!surface && !dualMode) return null; + if (!authHandlers) return null; const hintForThis = loginHint?.provider === item.name ? loginHint : null; const deviceCode = hintForThis?.deviceCode ?? ""; @@ -289,6 +291,11 @@ export default function ProviderAuthPanel({ {isKeyAuth && ( <> + {dualMode && ( +

+ {t("pws.cursorApiKeyHint")} +

+ )} {keys.length > 0 && (
    {keys.map(entry => ( diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index b091ab5d00..593e3a4996 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -237,7 +237,11 @@ export function useProviderAccountPools(deps: { }; const oauthCardProviders = useMemo( - () => config ? Object.entries(config.providers).filter(([, p]) => p.authMode === "oauth").map(([n]) => n) : [], + () => config + ? Object.entries(config.providers) + .filter(([name, p]) => p.authMode === "oauth" || name === "cursor" || p.adapter === "cursor") + .map(([n]) => n) + : [], [config], ); useEffect(() => { @@ -255,7 +259,14 @@ export function useProviderAccountPools(deps: { }, [fetchAccountSets, oauthCardProviders]); const keyCardProviders = useMemo( - () => config ? Object.entries(config.providers).filter(([, p]) => p.hasApiKey && p.authMode !== "oauth" && p.authMode !== "forward").map(([n]) => n) : [], + () => config + ? Object.entries(config.providers) + .filter(([name, p]) => + (p.hasApiKey && p.authMode !== "oauth" && p.authMode !== "forward") + || name === "cursor" + || p.adapter === "cursor") + .map(([n]) => n) + : [], [config], ); useEffect(() => { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4acaf79e2a..5f670f645f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1823,6 +1823,7 @@ export const de: Record = { "pws.adapterBaseRequired": "Adapter und Basis-URL sind erforderlich.", "pws.addAccount": "Konto hinzufügen", "pws.addKey": "API-Schlüssel hinzufügen", + "pws.cursorApiKeyHint": "Füge einen Cursor-Dashboard-Benutzer-API-Schlüssel ein. Dieser inoffizielle AgentService-Pfad ist experimentell (erhöhtes ToS-Risiko). Cloud-Agents-Schlüssel von api.cursor.com funktionieren hier nicht.", "pws.apiKeys": "API-Schlüssel", "pws.authMode": "Auth-Modus", "pws.availableAccounts": "Verfügbare Konten", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2f12d23e38..52a7887565 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1278,6 +1278,7 @@ export const en = { "pws.adapterBaseRequired": "Adapter and base URL are required.", "pws.addAccount": "Add account", "pws.addKey": "Add API key", + "pws.cursorApiKeyHint": "Paste a Cursor dashboard user API key. This unofficial AgentService path is experimental (elevated ToS risk). Cloud Agents keys from api.cursor.com will not work here.", "pws.apiKeys": "API Keys", "pws.authMode": "Auth mode", "pws.availableAccounts": "Available accounts", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 508edec1cc..df0529ee3f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1230,6 +1230,7 @@ export const ja: Record = { "pws.adapterBaseRequired": "アダプターとベース URL は必須です。", "pws.addAccount": "アカウントを追加", "pws.addKey": "API キーを追加", + "pws.cursorApiKeyHint": "Cursor ダッシュボードのユーザー API キーを貼り付けてください。この非公式 AgentService 経路は実験的で、利用規約リスクが高めです。api.cursor.com の Cloud Agents キーはここでは使えません。", "pws.apiKeys": "API キー", "pws.authMode": "認証モード", "pws.availableAccounts": "利用可能なアカウント", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1f6d715ebe..18d9e9cb89 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1845,6 +1845,7 @@ export const ko: Record = { "pws.adapterBaseRequired": "어댑터와 기본 URL은 필수입니다.", "pws.addAccount": "계정 추가", "pws.addKey": "API 키 추가", + "pws.cursorApiKeyHint": "Cursor 대시보드 사용자 API 키를 붙여넣으세요. 이 비공식 AgentService 경로는 실험적이며 ToS 위험이 높습니다. api.cursor.com Cloud Agents 키는 여기서 사용할 수 없습니다.", "pws.apiKeys": "API 키", "pws.authMode": "인증 방식", "pws.availableAccounts": "사용 가능한 계정", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c5977b866b..f50f5c16eb 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1272,6 +1272,7 @@ export const ru: Record = { "pws.adapterBaseRequired": "Укажите адаптер и базовый URL.", "pws.addAccount": "Добавить аккаунт", "pws.addKey": "Добавить API-ключ", + "pws.cursorApiKeyHint": "Вставьте пользовательский API-ключ из панели Cursor. Этот неофициальный путь AgentService экспериментальный (повышенный риск ToS). Ключи Cloud Agents с api.cursor.com здесь не работают.", "pws.apiKeys": "API-ключи", "pws.authMode": "Режим аутентификации", "pws.availableAccounts": "Доступные аккаунты", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e5cdb52851..3b00e92dee 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1842,6 +1842,7 @@ export const zh: Record = { "pws.adapterBaseRequired": "适配器和基本 URL 为必填项。", "pws.addAccount": "添加账户", "pws.addKey": "添加 API 密钥", + "pws.cursorApiKeyHint": "粘贴 Cursor 控制台用户 API 密钥。此非官方 AgentService 路径为实验性功能(服务条款风险较高)。api.cursor.com 的 Cloud Agents 密钥在此无效。", "pws.apiKeys": "API 密钥", "pws.authMode": "认证方式", "pws.availableAccounts": "可用账户", diff --git a/gui/src/provider-workspace/auth.ts b/gui/src/provider-workspace/auth.ts index 1e65cdd538..8b646e0df6 100644 --- a/gui/src/provider-workspace/auth.ts +++ b/gui/src/provider-workspace/auth.ts @@ -28,6 +28,11 @@ export function providerAuthSurface(item: WorkspaceItem): ProviderAuthSurface { return "api-keys"; } +/** Cursor is OAuth-default dual-mode: Settings shows accounts and an API-key pool. */ +export function isCursorKeyAuthOverride(item: Pick): boolean { + return item.name.trim().toLowerCase() === "cursor" || item.adapter === "cursor"; +} + /** Human-safe label for OAuth account rows; opaque storage ids stay private. */ export function oauthAccountDisplayLabel( accounts: readonly T[], diff --git a/gui/tests/cursor-apikey-dual-mode.test.tsx b/gui/tests/cursor-apikey-dual-mode.test.tsx new file mode 100644 index 0000000000..1937214740 --- /dev/null +++ b/gui/tests/cursor-apikey-dual-mode.test.tsx @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Root } from "react-dom/client"; +import { AddProviderFormPane } from "../src/components/add-provider-form-pane"; +import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; +import { en } from "../src/i18n/en"; +import { LanguageProvider } from "../src/i18n/provider"; +import { I18nContext, interpolate, type TFn } from "../src/i18n/shared"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; +import type { ProviderAuthHandlers } from "../src/components/provider-workspace/types"; + +const t = ((key, vars) => interpolate(en[key], vars)) as TFn; + +const CURSOR_HINT = en["pws.cursorApiKeyHint"]; + +const HANDLERS: ProviderAuthHandlers = { + onLogin: () => {}, + onLogout: () => {}, + onReauth: () => {}, + onSwitchAccount: () => {}, + onRemoveAccount: () => {}, + onAddApiKey: async () => true, + onSwitchApiKey: () => {}, + onRemoveApiKey: () => {}, + onEditAlias: () => {}, +}; + +function cursorFormPane(authMode: "key" | "oauth") { + return ( + {}, t }}> + undefined} + onFormChange={() => {}} + onEndpointChoiceChange={() => {}} + onSubmit={() => {}} + onUseOauthLogin={() => {}} + onBack={() => {}} + /> + + ); +} + +test("Add Provider key pane for Cursor states unofficial AgentService path and dashboard URL", () => { + const markup = renderToStaticMarkup(cursorFormPane("key")); + expect(markup).toContain(CURSOR_HINT); + expect(markup).toContain("https://cursor.com/dashboard/api"); + expect(markup).toContain(en["modal.useOauthLogin"]); + expect(markup).not.toContain("/v1/chat/completions"); +}); + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + await win.happyDOM?.close?.(); +}); + +test("Settings for Cursor oauth still shows accounts and an API-key pool", async () => { + const item: WorkspaceItem = { + name: "cursor", + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authMode: "oauth", + hasApiKey: false, + }; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + + expect(host.textContent).toContain(en["pws.availableAccounts"]); + expect(host.textContent).toContain(en["pws.notLoggedInTitle"]); + expect(host.textContent).toContain(en["prov.login"]); + expect(host.textContent).toContain(CURSOR_HINT); + expect(host.textContent).toContain(en["pws.addKey"]); +}); diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index c19b62c2e3..e91a4929e9 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -78,6 +78,7 @@ import { terminateBackgroundShellsForSession, type BackgroundShellTerminationReport, } from "./native-exec-shell"; +import { materializeCursorRunBearer } from "./run-bearer"; import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "./types"; import type { CursorTransport, CursorTransportFactoryInput } from "./transport"; import { getCursorLiveSmokeToken } from "./live-smoke-gate"; @@ -537,6 +538,9 @@ class LiveCursorTransport implements CursorTransport { // Advertise MCP tools before the stream opens — the server only calls tools it was told about. await this.prepareMcp(); + // Keep `this.token` as the original secret (`crsr_` dashboard key or OAuth JWT) so a later + // turn can re-exchange. The Run request uses the materialized Bearer only. + const runBearer = await materializeCursorRunBearer(this.token, signal); const activeText = activePromptText(request); this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs); const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice); @@ -586,7 +590,7 @@ class LiveCursorTransport implements CursorTransport { this.releaseBlobRequestScope(); done = true; wake(); - }); + }, runBearer); } catch (error) { this.releaseBlobRequestScope(); throw error; @@ -750,6 +754,7 @@ class LiveCursorTransport implements CursorTransport { push: (message: CursorServerMessage) => void, fail: (error: Error) => void, finish: () => void, + runBearer: string, ): void { this.turnStartedAt = Date.now(); this.framesReceived = 0; @@ -771,7 +776,7 @@ class LiveCursorTransport implements CursorTransport { "content-type": "application/connect+proto", "connect-protocol-version": "1", te: "trailers", - authorization: `Bearer ${this.token}`, + authorization: `Bearer ${runBearer}`, "x-ghost-mode": "true", "x-cursor-client-version": CURSOR_CLIENT_VERSION, "x-cursor-client-type": "cli", diff --git a/src/adapters/cursor/run-bearer.ts b/src/adapters/cursor/run-bearer.ts new file mode 100644 index 0000000000..7798d80b4c --- /dev/null +++ b/src/adapters/cursor/run-bearer.ts @@ -0,0 +1,93 @@ +/** + * Cursor Run Bearer materialization for pasted dashboard keys. + * + * Cursor OAuth refresh already POSTs to `/auth/exchange_user_api_key` with the refresh + * token as Bearer. Dashboard user API keys (`crsr_…` from https://cursor.com/dashboard/api) + * use that same exchange before they are valid AgentService/Run Bearers. + * + * Access JWTs and other opaque secrets are used as-is: GetUsableModels/Run already accept + * a working Bearer, and Cloud Agents keys from `api.cursor.com` are a different product. + */ +import { createHash } from "node:crypto"; +import { getTokenExpiry, refreshCursorToken } from "../../oauth/cursor"; +import { fetchCursorUsableModels } from "./live-models"; + +const CURSOR_USER_API_KEY_PREFIX = "crsr_"; + +interface CachedCursorBearer { + access: string; + expires: number; +} + +const bearerCache = new Map(); + +function cacheId(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +export function cursorCredentialLooksLikeJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(part => part.length > 0); +} + +/** Dashboard user API keys need `/auth/exchange_user_api_key` before Run/GetUsableModels. */ +export function cursorUserApiKeyNeedsExchange(token: string): boolean { + return token.trim().toLowerCase().startsWith(CURSOR_USER_API_KEY_PREFIX); +} + +export function clearCursorRunBearerCache(): void { + bearerCache.clear(); +} + +/** + * Return a Bearer that AgentService/Run and GetUsableModels will accept. + * + * - Unexpired JWTs (OAuth access tokens, already-exchanged session tokens): use as-is. + * - `crsr_` dashboard user API keys: exchange, then cache the access token until JWT expiry. + * - Anything else (including Cloud Agents `key_` secrets): use as-is. Validation/Run fail honestly. + */ +export async function materializeCursorRunBearer(raw: string, signal?: AbortSignal): Promise { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + + const id = cacheId(trimmed); + const cached = bearerCache.get(id); + if (cached && cached.expires > Date.now()) return cached.access; + + if (cursorCredentialLooksLikeJwt(trimmed) && getTokenExpiry(trimmed) > Date.now()) { + bearerCache.set(id, { access: trimmed, expires: getTokenExpiry(trimmed) }); + return trimmed; + } + + if (!cursorUserApiKeyNeedsExchange(trimmed)) return trimmed; + + const creds = await refreshCursorToken(trimmed, signal); + const expires = creds.expires ?? getTokenExpiry(creds.access); + bearerCache.set(id, { access: creds.access, expires }); + return creds.access; +} + +/** + * Best-effort paste validation: probe GetUsableModels with the raw secret, then exchange a + * dashboard `crsr_` key if that probe is an auth failure. Never uses OpenAI-style GET /models. + */ +export async function validateCursorApiKey( + key: string, + baseUrl?: string, +): Promise { + const raw = await fetchCursorUsableModels({ apiKey: key, baseUrl }); + if (raw.ok) return true; + if (raw.error !== "auth") return "unknown"; + try { + const bearer = await materializeCursorRunBearer(key); + if (bearer === key) return false; + const exchanged = await fetchCursorUsableModels({ apiKey: bearer, baseUrl }); + if (exchanged.ok) return true; + if (exchanged.error === "auth") return false; + return "unknown"; + } catch (err) { + const message = err instanceof Error ? err.message : ""; + if (/\b401\b|\b403\b/.test(message)) return false; + return "unknown"; + } +} diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 2d4a9378de..15b75ff2ec 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -35,6 +35,7 @@ import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-co import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { materializeCursorRunBearer } from "../../adapters/cursor/run-bearer"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1003,7 +1004,10 @@ async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name); return cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured; } - const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl }); + 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; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index ffab3872fa..67e4866341 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -870,7 +870,7 @@ export function buildModelsRequest( /** * Add/refresh an OAuth provider's config entry on a config object (does not persist). * - * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be + * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot, cursor) can be * billed through a stored API key instead of the OAuth login (router.ts honors * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool` * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index d4cb46fbcf..9d8bb36d8b 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -1,4 +1,5 @@ import type { CodexCommanderProviderConfig } from "../types"; +import { validateCursorApiKey } from "../adapters/cursor/run-bearer"; import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive"; import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; @@ -93,6 +94,10 @@ export async function validateApiKey( return "unknown"; } + if (provider.adapter === "cursor") { + return validateCursorApiKey(key, provider.baseUrl); + } + if (provider.adapter === "google" && (provider.googleMode ?? "ai-studio") === "ai-studio") { // Generative Language API rejects Bearer-wrapped API keys; probe models.list with the // documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200). diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 497c6c5d6f..6daa1bfa6d 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -92,6 +92,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s return { adapter: def.adapter, baseUrl: baseUrlOverride ?? def.baseUrl, + authMode: "key", apiKey: key, ...(def.apiKeyTransport !== undefined ? { apiKeyTransport: def.apiKeyTransport } : {}), ...(def.defaultModel ? { defaultModel: def.defaultModel } : {}), diff --git a/src/providers/api-keys.ts b/src/providers/api-keys.ts index 4feeb9b873..fcca36d071 100644 --- a/src/providers/api-keys.ts +++ b/src/providers/api-keys.ts @@ -33,9 +33,15 @@ export function apiKeyPoolEntryId(key: string): string { return createHash("sha256").update(key).digest("hex").slice(0, 8); } -/** True for providers whose upstream auth is a configured API key (not oauth/forward). */ +/** + * True for providers whose upstream auth is a configured API key (not oauth/forward). + * Cursor is OAuth-default dual-mode: a pasted dashboard key is stored on the same provider + * row, so the pool APIs must accept `authMode: "oauth"` when the adapter is `cursor`. + */ export function isKeyAuthProvider(provider: CodexCommanderProviderConfig): boolean { - return provider.authMode !== "oauth" && provider.authMode !== "forward"; + if (provider.authMode === "forward") return false; + if (provider.authMode === "oauth") return provider.adapter === "cursor"; + return true; } /** Trim and reject blank / CRLF-bearing secrets. Shared by pool writes and OAuth upsert. */ @@ -107,6 +113,9 @@ export function addProviderApiKey(config: CodexCommanderConfig, name: string, ke } provider.apiKeyPool = pool; provider.apiKey = trimmed; + // Dual-mode OAuth presets (Cursor) keep oauth as default until a key is pasted. + // Routing only honors the key when authMode is explicitly "key". + if (provider.adapter === "cursor") provider.authMode = "key"; saveConfigPreservingClaudeCode(config); return { id }; } diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 5809cec63b..87c56140ee 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -191,10 +191,21 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): CodexCommander }; } +function isKeyLoginRegistryEntry(entry: ProviderRegistryEntry): boolean { + if (entry.authKind === "key") return true; + // OAuth dual-mode appears in key-login lists only when a real API-key dashboard exists. + // GitHub Copilot has allowKeyAuthOverride, but its dashboardUrl is subscription settings, + // not a key-minting page, so it stays OAuth-only in this list. + return entry.authKind === "oauth" + && entry.allowKeyAuthOverride === true + && Boolean(entry.dashboardUrl) + && entry.id === "cursor"; +} + export function deriveKeyLoginMap(): Record { const out: Record = {}; for (const entry of PROVIDER_REGISTRY) { - if (entry.authKind !== "key") continue; + if (!isKeyLoginRegistryEntry(entry)) continue; if (!entry.dashboardUrl) throw new Error(`Registry key provider missing dashboardUrl: ${entry.id}`); out[entry.id] = { label: entry.label, diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 8208d3626e..97f77fbbdf 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -23,6 +23,7 @@ import { markAccountNeedsReauthIfGeneration, } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; +import { materializeCursorRunBearer } from "../adapters/cursor/run-bearer"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry"; @@ -1148,11 +1149,20 @@ async function fetchKimiQuota( : quotaUnavailable("upstream_unavailable"); } -/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ -async function fetchCursorQuota(provider: string): Promise { +/** Cursor included usage via api2.cursor.sh (Bearer from OAuth or a pasted Run key) — unofficial, may change. */ +async function fetchCursorQuota( + provider: string, + config: CodexCommanderProviderConfig, +): Promise { let accessToken: string; try { - accessToken = await getValidAccessToken("cursor"); + if (config.authMode === "key") { + const raw = resolveEnvValue(config.apiKey)?.trim(); + if (!raw) return null; + accessToken = await materializeCursorRunBearer(raw); + } else { + accessToken = await getValidAccessToken("cursor"); + } } catch { return null; } @@ -1614,6 +1624,7 @@ export function supportsProviderQuotaReporting( if (["xai", "anthropic", "cursor", "google-antigravity"].includes(name)) return true; if (name === "kimi" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) return true; } + if (provider.authMode === "key" && name === "cursor" && provider.adapter === "cursor") return true; if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) return true; return (provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl); } @@ -1635,7 +1646,10 @@ async function maybeFetchProviderQuota( } if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name); if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name); - if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name); + if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name, provider); + if (provider.authMode === "key" && name === "cursor" && provider.adapter === "cursor") { + return fetchCursorQuota(name, provider); + } if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider); // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical // host and only for real key auth — forward/local modes carry no credential of ours. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bb48cc0c12..408ef7a06e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -896,9 +896,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "cursor", baseUrl: "https://api2.cursor.sh", authKind: "oauth", + allowKeyAuthOverride: true, featured: false, dashboardPreset: true, - note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ccx login cursor'; native read/write/delete/shell/fetch execution is disabled by default and request text such as Codex sandbox markers never authorizes it. Set \"nativeLocalExec\": \"on\" on providers.cursor in ~/.codexcommander/config.json (dashboard: Providers → Cursor → Edit JSON) only for a trusted local experiment where every data-plane caller is trusted. \"off\" denies all other local execution.", + dashboardUrl: "https://cursor.com/dashboard/api", + note: "Experimental unofficial Cursor bridge (elevated ToS risk). Default is standalone PKCE via 'ccx login cursor'. A pasted dashboard user API key from https://cursor.com/dashboard/api uses the same unofficial AgentService/Run protocol on api2.cursor.sh — not a public OpenAI chat-completions API and not Cursor Cloud Agents at api.cursor.com. Live transport and live model discovery run after OAuth or a working Run Bearer; native read/write/delete/shell/fetch execution is disabled by default and request text such as Codex sandbox markers never authorizes it. Set \"nativeLocalExec\": \"on\" on providers.cursor in ~/.codexcommander/config.json (dashboard: Providers → Cursor → Edit JSON) only for a trusted local experiment where every data-plane caller is trusted. \"off\" denies all other local execution.", models: cursorModelIds(CURSOR_STATIC_MODELS), liveModels: true, defaultModel: "auto", diff --git a/tests/cursor-apikey-dual-mode.test.ts b/tests/cursor-apikey-dual-mode.test.ts new file mode 100644 index 0000000000..137f023fc1 --- /dev/null +++ b/tests/cursor-apikey-dual-mode.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearCursorRunBearerCache, + cursorCredentialLooksLikeJwt, + cursorUserApiKeyNeedsExchange, + materializeCursorRunBearer, + validateCursorApiKey, +} from "../src/adapters/cursor/run-bearer"; +import * as cursorLiveModels from "../src/adapters/cursor/live-models"; +import { KEY_LOGIN_PROVIDERS, listKeyLoginProviders } from "../src/oauth/key-providers"; +import { providerConfigFromKeyLoginProvider } from "../src/oauth/login-cli"; +import { addProviderApiKey, isKeyAuthProvider } from "../src/providers/api-keys"; +import { deriveKeyLoginMap } from "../src/providers/derive"; +import { routeModel } from "../src/router"; +import type { CodexCommanderConfig } from "../src/types"; +import { isCursorKeyAuthOverride, providerAuthSurface } from "../gui/src/provider-workspace/auth"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearCursorRunBearerCache(); + mock.restore(); +}); + +function jwtAccess(expSeconds = Math.floor(Date.now() / 1000) + 3600): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ exp: expSeconds, sub: "cursor-user" })).toString("base64url"); + return `${header}.${payload}.sig`; +} + +function cursorKeyConfig(authMode: "key" | "oauth", apiKey = "cursor-test-key"): CodexCommanderConfig { + return { + port: 10100, + multiAgentGuidanceEnabled: true, + defaultProvider: "cursor", + providers: { + cursor: { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authMode, + apiKey, + apiKeyPool: [{ id: "aaaaaaaa", key: apiKey }], + }, + }, + }; +} + +describe("Cursor API-key dual-mode contract", () => { + test("key-login list includes cursor when allowKeyAuthOverride and dashboardUrl are set", async () => { + expect(KEY_LOGIN_PROVIDERS.cursor).toMatchObject({ + adapter: "cursor", + dashboardUrl: "https://cursor.com/dashboard/api", + liveModels: true, + defaultModel: "auto", + }); + expect(listKeyLoginProviders().some(row => row.id === "cursor")).toBe(true); + expect(deriveKeyLoginMap().cursor?.dashboardUrl).toBe("https://cursor.com/dashboard/api"); + expect(Object.keys(deriveKeyLoginMap())).not.toContain("github-copilot"); + const keyProvidersSource = await Bun.file("src/oauth/key-providers.ts").text(); + expect(keyProvidersSource).toContain("validateCursorApiKey"); + expect(keyProvidersSource).toContain('provider.adapter === "cursor"'); + }); + + test("CLI key-login payload sets authMode key so routing honors the pasted secret", () => { + const provider = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.cursor!, "crsr_dashboard"); + expect(provider).toMatchObject({ + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authMode: "key", + apiKey: "crsr_dashboard", + defaultModel: "auto", + }); + const config: CodexCommanderConfig = { + port: 10100, + defaultProvider: "cursor", + providers: { cursor: provider }, + }; + const routed = routeModel(config, "cursor/auto").provider; + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBe("crsr_dashboard"); + }); + + test("explicit key billing routes; oauth default still wins without a usable key", () => { + expect(routeModel(cursorKeyConfig("key"), "cursor/auto").provider.authMode).toBe("key"); + expect(routeModel(cursorKeyConfig("oauth"), "cursor/auto").provider.authMode).toBe("oauth"); + const missing = cursorKeyConfig("key", "${CCX_TEST_CURSOR_API_KEY_MISSING}"); + const previous = process.env.CCX_TEST_CURSOR_API_KEY_MISSING; + delete process.env.CCX_TEST_CURSOR_API_KEY_MISSING; + try { + const routed = routeModel(missing, "cursor/auto").provider; + expect(routed.authMode).toBe("oauth"); + expect(routed.apiKey).toBeUndefined(); + expect(missing.providers.cursor!.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env.CCX_TEST_CURSOR_API_KEY_MISSING; + else process.env.CCX_TEST_CURSOR_API_KEY_MISSING = previous; + } + }); + + test("API-key pool accepts an oauth Cursor row and flips billing to key", () => { + const previousHome = process.env.CODEXCOMMANDER_HOME; + const testHome = mkdtempSync(join(tmpdir(), "ccx-cursor-apikey-")); + process.env.CODEXCOMMANDER_HOME = testHome; + try { + const config = cursorKeyConfig("oauth"); + delete config.providers.cursor!.apiKey; + delete config.providers.cursor!.apiKeyPool; + expect(isKeyAuthProvider(config.providers.cursor!)).toBe(true); + const result = addProviderApiKey(config, "cursor", "crsr_pasted_from_dashboard"); + expect("id" in result).toBe(true); + expect(config.providers.cursor!.authMode).toBe("key"); + expect(config.providers.cursor!.apiKey).toBe("crsr_pasted_from_dashboard"); + expect(routeModel(config, "cursor/auto").provider.authMode).toBe("key"); + } finally { + if (previousHome === undefined) delete process.env.CODEXCOMMANDER_HOME; + else process.env.CODEXCOMMANDER_HOME = previousHome; + rmSync(testHome, { recursive: true, force: true }); + } + }); + + test("GUI Settings surface keeps OAuth accounts and exposes the API-key pool", () => { + const oauthCursor = { + name: "cursor", + adapter: "cursor" as const, + baseUrl: "https://api2.cursor.sh", + authMode: "oauth" as const, + hasApiKey: false, + }; + const keyCursor = { ...oauthCursor, authMode: "key" as const, hasApiKey: true }; + expect(providerAuthSurface(oauthCursor)).toBe("oauth-accounts"); + expect(providerAuthSurface(keyCursor)).toBe("api-keys"); + expect(isCursorKeyAuthOverride(oauthCursor)).toBe(true); + expect(isCursorKeyAuthOverride(keyCursor)).toBe(true); + expect(isCursorKeyAuthOverride({ name: "xai", adapter: "openai-chat" })).toBe(false); + }); +}); + +describe("Cursor dashboard key exchange vs raw Bearer", () => { + test("classifies JWT access tokens vs crsr_ dashboard keys", () => { + expect(cursorCredentialLooksLikeJwt(jwtAccess())).toBe(true); + expect(cursorUserApiKeyNeedsExchange(jwtAccess())).toBe(false); + expect(cursorUserApiKeyNeedsExchange("crsr_live_user_key")).toBe(true); + expect(cursorUserApiKeyNeedsExchange("CRSR_LIVE_USER_KEY")).toBe(true); + expect(cursorUserApiKeyNeedsExchange("key_cloud_agents")).toBe(false); + expect(cursorUserApiKeyNeedsExchange("test-token")).toBe(false); + }); + + test("uses an unexpired JWT as the Run Bearer without calling exchange", async () => { + const token = jwtAccess(); + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + return new Response("nope", { status: 500 }); + }) as typeof fetch; + expect(await materializeCursorRunBearer(token)).toBe(token); + expect(fetches).toBe(0); + }); + + test("exchanges a crsr_ dashboard key and caches the access token", async () => { + const access = jwtAccess(); + let fetches = 0; + let seenAuth = ""; + let seenUrl = ""; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + fetches++; + seenUrl = String(input); + seenAuth = String((init?.headers as Record | undefined)?.Authorization ?? ""); + return new Response(JSON.stringify({ accessToken: access, refreshToken: "crsr_live_user_key" }), { status: 200 }); + }) as typeof fetch; + expect(await materializeCursorRunBearer("crsr_live_user_key")).toBe(access); + expect(seenUrl).toBe("https://api2.cursor.sh/auth/exchange_user_api_key"); + expect(seenAuth).toBe("Bearer crsr_live_user_key"); + expect(await materializeCursorRunBearer("crsr_live_user_key")).toBe(access); + expect(fetches).toBe(1); + }); + + test("live transport keeps the original secret so a crsr_ key can be re-exchanged", async () => { + const source = await Bun.file("src/adapters/cursor/live-transport.ts").text(); + expect(source).toContain("materializeCursorRunBearer(this.token"); + expect(source).not.toMatch(/this\.token\s*=\s*await materializeCursorRunBearer/); + expect(source).toContain("authorization: `Bearer ${runBearer}`"); + }); + + test("leaves Cloud Agents-style keys untouched so Run/GetUsableModels fail honestly", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + return new Response("nope", { status: 401 }); + }) as typeof fetch; + expect(await materializeCursorRunBearer("key_cloud_agents_secret")).toBe("key_cloud_agents_secret"); + expect(fetches).toBe(0); + }); +}); + +describe("Cursor key validation path", () => { + test("accepts a secret that already works as a GetUsableModels Bearer and never hits GET /models", async () => { + const spy = spyOn(cursorLiveModels, "fetchCursorUsableModels").mockResolvedValue({ ok: true, models: ["auto"] }); + let fetches = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + fetches++; + throw new Error(`unexpected fetch ${String(input)}`); + }) as typeof fetch; + try { + expect(await validateCursorApiKey("working-bearer")).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toMatchObject({ apiKey: "working-bearer" }); + expect(fetches).toBe(0); + } finally { + spy.mockRestore(); + } + }); + + test("exchanges a crsr_ key after GetUsableModels auth-fails, then accepts the access token", async () => { + const access = jwtAccess(); + const spy = spyOn(cursorLiveModels, "fetchCursorUsableModels").mockImplementation(async opts => { + if (opts.apiKey === access) return { ok: true, models: ["auto"] }; + return { ok: false, error: "auth", detail: "HTTP 401" }; + }); + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + expect(String((init?.headers as Record | undefined)?.Authorization)).toBe("Bearer crsr_needs_exchange"); + return new Response(JSON.stringify({ accessToken: access }), { status: 200 }); + }) as typeof fetch; + try { + expect(await validateCursorApiKey("crsr_needs_exchange")).toBe(true); + expect(spy.mock.calls.map(call => call[0]?.apiKey)).toEqual(["crsr_needs_exchange", access]); + } finally { + spy.mockRestore(); + } + }); + + test("rejects a secret that fails GetUsableModels and is not exchangeable", async () => { + const spy = spyOn(cursorLiveModels, "fetchCursorUsableModels").mockResolvedValue({ + ok: false, + error: "auth", + detail: "HTTP 401", + }); + try { + expect(await validateCursorApiKey("key_cloud_agents")).toBe(false); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/cursor-oauth-shell.test.ts b/tests/cursor-oauth-shell.test.ts index 5b0f113029..0b75e81bdb 100644 --- a/tests/cursor-oauth-shell.test.ts +++ b/tests/cursor-oauth-shell.test.ts @@ -28,6 +28,7 @@ describe("Cursor provider routing + standalone OAuth module", () => { adapter: "cursor", auth: "oauth", defaultModel: "auto", + dashboardUrl: "https://cursor.com/dashboard/api", }); }); diff --git a/tests/oauth-upsert-preserves-api-key.test.ts b/tests/oauth-upsert-preserves-api-key.test.ts index 8d104835df..d88f8d638e 100644 --- a/tests/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth-upsert-preserves-api-key.test.ts @@ -11,7 +11,7 @@ import type { CodexCommanderConfig } from "../src/types"; * Regression: `upsertOAuthProvider` used to overwrite the provider entry with the bare preset * on every OAuth login, deleting a stored `apiKey`/`apiKeyPool` and silently flipping an * explicit `authMode: "key"` billing choice back to the subscription. Providers whose registry - * entry sets `allowKeyAuthOverride` (xai, github-copilot) are the ones that can hold both. + * entry sets `allowKeyAuthOverride` (xai, github-copilot, cursor) are the ones that can hold both. */ function configWithKey(provider: string, adapter: string, baseUrl: string): CodexCommanderConfig { return { @@ -48,6 +48,16 @@ describe("upsertOAuthProvider credential preservation", () => { expect(provider.authMode).toBe("key"); }); + test("keeps a stored API key and the explicit key billing mode for cursor", () => { + const config = configWithKey("cursor", "cursor", "https://api2.cursor.sh"); + upsertOAuthProvider(config, "cursor"); + const provider = config.providers.cursor!; + expect(provider.apiKey).toBe("stored-key-sentinel"); + expect(provider.apiKeyPool).toEqual([{ id: "aaaaaaaa", key: "stored-key-sentinel" }]); + expect(provider.authMode).toBe("key"); + expect(routeModel(config, "cursor/auto").provider.authMode).toBe("key"); + }); + test("carries the key over without changing oauth billing when the user did not pick key mode", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.authMode = "oauth"; diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index f8cec3a85e..d48b2e9271 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -30,7 +30,7 @@ function nativeTemplate(): Record { } const EXPECTED_KEY_PROVIDER_IDS = [ - "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", + "cursor", "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", @@ -554,8 +554,10 @@ describe("provider registry parity", () => { id: "cursor", adapter: "cursor", authKind: "oauth", + allowKeyAuthOverride: true, featured: false, dashboardPreset: true, + dashboardUrl: "https://cursor.com/dashboard/api", defaultModel: "auto", liveModels: true, }); @@ -578,12 +580,21 @@ describe("provider registry parity", () => { expect(cursor?.models).toContain("kimi-k2.7-code"); expect(cursor?.models).not.toContain("grok-4.3"); expect(deriveFeaturedProviderIds()).not.toContain("cursor"); - expect(Object.keys(deriveKeyLoginMap())).not.toContain("cursor"); + expect(Object.keys(deriveKeyLoginMap())).toContain("cursor"); + expect(deriveKeyLoginMap().cursor).toMatchObject({ + adapter: "cursor", + dashboardUrl: "https://cursor.com/dashboard/api", + defaultModel: "auto", + liveModels: true, + }); + expect(Object.keys(deriveKeyLoginMap())).not.toContain("github-copilot"); + expect(Object.keys(deriveKeyLoginMap())).not.toContain("xai"); expect(deriveProviderPresets().find(preset => preset.id === "cursor")).toMatchObject({ id: "cursor", adapter: "cursor", auth: "oauth", defaultModel: "auto", + dashboardUrl: "https://cursor.com/dashboard/api", }); const seed = providerConfigSeed(cursor!); expect(seed).toMatchObject({ @@ -709,6 +720,7 @@ describe("provider registry parity", () => { adapter: "cursor", auth: "oauth", defaultModel: "auto", + dashboardUrl: "https://cursor.com/dashboard/api", }); expect(presets.find(p => p.id === "kimi")?.baseUrl).toBe("https://api.kimi.com/coding/v1"); expect(presets.find(p => p.id === "anthropic")?.defaultModel).toBe("claude-sonnet-5"); diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 59866e51f5..696045690d 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -39,6 +39,8 @@ describe("provider workspace auth surface", () => { expect(providerAuthSurface(provider("configured", { hasApiKey: true }))).toBe("api-keys"); expect(providerAuthSurface(provider("free", { keyOptional: true }))).toBeNull(); expect(providerAuthSurface(provider("ollama", { authMode: "local", baseUrl: "http://127.0.0.1:11434/v1" }))).toBeNull(); + expect(providerAuthSurface(provider("cursor", { adapter: "cursor", authMode: "oauth" }))).toBe("oauth-accounts"); + expect(providerAuthSurface(provider("cursor", { adapter: "cursor", authMode: "key", hasApiKey: true }))).toBe("api-keys"); }); }); diff --git a/tests/router.test.ts b/tests/router.test.ts index d86a39fb3a..53ee1b3fea 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -36,7 +36,7 @@ describe("routeModel registry effort defaults", () => { expect(routeModel(xaiKey, "xai/grok-4.5").provider.authMode).toBe("key"); expect(routeModel(xaiDefault, "xai/grok-4.5").provider.authMode).toBe("oauth"); - expect(routeModel(cursorKeyAttempt, "cursor/auto").provider.authMode).toBe("oauth"); + expect(routeModel(cursorKeyAttempt, "cursor/auto").provider.authMode).toBe("key"); }); test("falls back to OAuth routing for allowKeyAuthOverride providers when the active key is unresolved", () => {