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
19 changes: 13 additions & 6 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -112,7 +112,7 @@ ccx logout <provider>
| `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),
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 6 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,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions gui/src/components/add-provider-form-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export function AddProviderFormPane({
<IconKey style={{ width: 14, height: 14 }} />{t("modal.getApiKey", { label: preset.label })}<IconExternal style={{ width: 13, height: 13 }} />
</a>
)}
{form.adapter === "cursor" && (
<p className="muted text-label">{t("pws.cursorApiKeyHint")}</p>
)}
<AddProviderField label={t("modal.apiKey")}>
<input className="input" type="password" value={form.apiKey} onChange={e => onFormChange({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} />
</AddProviderField>
Expand Down
15 changes: 11 additions & 4 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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 ?? "";
Expand Down Expand Up @@ -289,6 +291,11 @@ export default function ProviderAuthPanel({

{isKeyAuth && (
<>
{dualMode && (
<p className="muted text-label" style={{ marginTop: isOauth ? 12 : 0 }}>
{t("pws.cursorApiKeyHint")}
</p>
)}
{keys.length > 0 && (
<ul className="pwi-auth-list">
{keys.map(entry => (
Expand Down
15 changes: 13 additions & 2 deletions gui/src/hooks/useProviderAccountPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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(() => {
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,7 @@ export const de: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,7 @@ export const ja: Record<TKey, string> = {
"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": "利用可能なアカウント",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,7 @@ export const ko: Record<TKey, string> = {
"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": "사용 가능한 계정",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,7 @@ export const ru: Record<TKey, string> = {
"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": "Доступные аккаунты",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,7 @@ export const zh: Record<TKey, string> = {
"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": "可用账户",
Expand Down
5 changes: 5 additions & 0 deletions gui/src/provider-workspace/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceItem, "name" | "adapter">): 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<T extends OAuthAccountIdentity>(
accounts: readonly T[],
Expand Down
140 changes: 140 additions & 0 deletions gui/tests/cursor-apikey-dual-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<I18nContext.Provider value={{ locale: "en", setLocale: () => {}, t }}>
<AddProviderFormPane
preset={{
id: "cursor",
label: "Cursor (experimental)",
adapter: "cursor",
baseUrl: "https://api2.cursor.sh",
auth: "oauth",
dashboardUrl: "https://cursor.com/dashboard/api",
oauthProvider: "cursor",
}}
form={{
name: "cursor",
adapter: "cursor",
baseUrl: "https://api2.cursor.sh",
authMode,
apiKey: "",
defaultModel: "auto",
allowPrivateNetwork: false,
}}
endpointChoice=""
error=""
saving={false}
dup={false}
isCustom={false}
isLocal={false}
isReservedForward={false}
presetDescription={() => undefined}
onFormChange={() => {}}
onEndpointChoiceChange={() => {}}
onSubmit={() => {}}
onUseOauthLogin={() => {}}
onBack={() => {}}
/>
</I18nContext.Provider>
);
}

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(
<LanguageProvider>
<ProviderAuthPanel
item={item}
apiBase=""
oauth={{ loggedIn: false }}
keys={[]}
authHandlers={HANDLERS}
/>
</LanguageProvider>,
);
});

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"]);
});
Loading