Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions gui/src/hooks/useProviderAccountPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
Expand Down
9 changes: 7 additions & 2 deletions gui/src/provider-workspace/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceItem, "name" | "adapter">): 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. */
Expand Down
32 changes: 32 additions & 0 deletions gui/tests/cursor-apikey-dual-mode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<LanguageProvider>
<ProviderAuthPanel
item={custom}
apiBase=""
oauth={{ loggedIn: false }}
keys={[{ id: "aaaaaaaa", masked: "crsr****key1", active: true }]}
authHandlers={HANDLERS}
/>
</LanguageProvider>,
);
});

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);
});
46 changes: 28 additions & 18 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions tests/cursor-apikey-dual-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
});

Expand Down
85 changes: 84 additions & 1 deletion tests/cursor-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
Expand Down Expand Up @@ -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 };
Expand Down
Loading