From c130f2aa1ccc7aaddd7a7685bd6a0e08136dccf1 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:28:01 -0600 Subject: [PATCH] feat(providers): Cursor PKCE login with Bearer quota, auto router, and empty-turn errors (#9909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⭐5 — Cursor PKCE login com Bearer quota, auto router e empty-turn errors. Feature completa e testada (11 arquivos de teste, 133 testes focados, todos verdes). **Validação (worktree combinado `.claude/worktrees/fix-9909`, board sobre `origin/release/v3.8.50`):** - 3 conflitos reais resolvidos: `config/quality/eslint-suppressions.json` (aditivo), `open-sse/config/providers/registry/cursor/index.ts` (dedup de 208 entradas de catálogo, 0 IDs duplicados verificado), `open-sse/executors/cursor.ts` (imports aditivos). - `npm run typecheck:core`: limpo. - `check-changelog-integrity`, `check-file-size`, `check-complexity` (2615/2774), `check-cognitive-complexity` (1175/1223), `check-dead-code` (410/416): todos OK. - `check-public-creds`: 1 entrada obsoleta pré-existente na allowlist (`copilot-m365-web.ts:330`), já presente no tip da release — não é desta PR. - `npm run lint`: 0 errors (5 warnings pré-existentes). - Testes focados (`cursor-agent-cli-version`, `cursor-available-models`, `cursor-catalog-combo-compat`, `cursor-errors-classify`, `cursor-login-pkce`, `cursor-model-effort-suffix-7289`, `cursor-streaming`, `cursor-token-extractor`, `cursor-token-refresh-wiring`, `cursor-usage-fetcher`, `empty-stream-no-content-8649`): 133/133 verdes. - Corrigido durante a validação: 1 teste novo da própria PR (`cursor-model-effort-suffix-7289.test.ts`, "splits effort off legacy grok- ids") colidia com `CURSOR_MODEL_ALIASES` já mesclado na release (mapeia `grok-4.5-high` → `cursor-grok-4.5-high` antes do fallback legado rodar); ajustado para usar um id não-aliasado (`grok-3-high`) que de fato exercita o fallback — commit `68b58ed`. Obrigado pela contribuição, @yansigit — feature robusta com boa cobertura de testes. --- docs/guides/USER_GUIDE.md | 6 + docs/providers/CURSOR-DOCKER.md | 108 ++++- .../config/providers/registry/cursor/index.ts | 321 +++++++++----- open-sse/executors/cursor.ts | 155 +++++-- open-sse/executors/cursor/cursorErrors.ts | 269 +++++++++++ open-sse/services/tokenRefresh.ts | 9 + .../services/tokenRefresh/providers/cursor.ts | 115 +++++ open-sse/services/usage/cursor.ts | 419 +++++++++++++----- open-sse/utils/cursorAgentCliVersion.ts | 172 ++++++- open-sse/utils/cursorAgentProtobuf.ts | 50 +++ open-sse/utils/streamHandler.ts | 6 +- open-sse/utils/streamReadiness.ts | 70 ++- scripts/ad-hoc/sync-cursor-models.mjs | 19 +- src/app/api/oauth/cursor/auto-import/route.ts | 1 + src/app/api/oauth/cursor/import/route.ts | 80 ++-- .../api/oauth/cursor/login/cancel/route.ts | 47 ++ src/app/api/oauth/cursor/login/poll/route.ts | 112 +++++ src/app/api/oauth/cursor/login/start/route.ts | 37 ++ src/app/api/providers/[id]/models/route.ts | 33 +- src/i18n/messages/ar.json | 17 +- src/i18n/messages/az.json | 17 +- src/i18n/messages/bg.json | 17 +- src/i18n/messages/bn.json | 17 +- src/i18n/messages/cs.json | 17 +- src/i18n/messages/da.json | 17 +- src/i18n/messages/de.json | 17 +- src/i18n/messages/en.json | 21 +- src/i18n/messages/es.json | 17 +- src/i18n/messages/fa.json | 17 +- src/i18n/messages/fi.json | 17 +- src/i18n/messages/fr.json | 17 +- src/i18n/messages/gu.json | 17 +- src/i18n/messages/he.json | 17 +- src/i18n/messages/hi.json | 17 +- src/i18n/messages/hu.json | 17 +- src/i18n/messages/id.json | 17 +- src/i18n/messages/in.json | 17 +- src/i18n/messages/it.json | 17 +- src/i18n/messages/ja.json | 17 +- src/i18n/messages/ko.json | 17 +- src/i18n/messages/mr.json | 17 +- src/i18n/messages/ms.json | 17 +- src/i18n/messages/nl.json | 17 +- src/i18n/messages/no.json | 17 +- src/i18n/messages/phi.json | 17 +- src/i18n/messages/pl.json | 17 +- src/i18n/messages/pt-BR.json | 17 +- src/i18n/messages/pt.json | 17 +- src/i18n/messages/ro.json | 17 +- src/i18n/messages/ru.json | 17 +- src/i18n/messages/sk.json | 17 +- src/i18n/messages/sv.json | 17 +- src/i18n/messages/sw.json | 17 +- src/i18n/messages/ta.json | 17 +- src/i18n/messages/te.json | 17 +- src/i18n/messages/th.json | 17 +- src/i18n/messages/tr.json | 17 +- src/i18n/messages/uk-UA.json | 17 +- src/i18n/messages/ur.json | 17 +- src/i18n/messages/vi.json | 17 +- src/i18n/messages/zh-CN.json | 17 +- src/i18n/messages/zh-TW.json | 17 +- src/lib/cursor/tokenExtractor.ts | 28 +- src/lib/oauth/constants/oauth.ts | 18 +- src/lib/oauth/providers/cursor.ts | 12 +- src/lib/oauth/services/cursorLogin.ts | 245 ++++++++++ .../oauth/services/persistCursorConnection.ts | 76 ++++ .../providerModels/cursorAvailableModels.ts | 188 ++++++++ src/lib/tokenHealthCheck.ts | 2 + src/shared/components/CursorAuthModal.tsx | 297 ++++++++++--- src/shared/validation/schemas/auth.ts | 1 + tests/unit/cursor-agent-cli-version.test.ts | 113 ++++- tests/unit/cursor-available-models.test.ts | 101 +++++ .../unit/cursor-catalog-combo-compat.test.ts | 48 ++ tests/unit/cursor-errors-classify.test.ts | 78 ++++ tests/unit/cursor-login-pkce.test.ts | 197 ++++++++ .../cursor-model-effort-suffix-7289.test.ts | 32 ++ tests/unit/cursor-streaming.test.ts | 35 +- tests/unit/cursor-token-extractor.test.ts | 11 + .../unit/cursor-token-refresh-wiring.test.ts | 26 ++ tests/unit/cursor-usage-fetcher.test.ts | 376 +++++++++++----- .../unit/empty-stream-no-content-8649.test.ts | 64 +++ 82 files changed, 4144 insertions(+), 568 deletions(-) create mode 100644 open-sse/executors/cursor/cursorErrors.ts create mode 100644 open-sse/services/tokenRefresh/providers/cursor.ts create mode 100644 src/app/api/oauth/cursor/login/cancel/route.ts create mode 100644 src/app/api/oauth/cursor/login/poll/route.ts create mode 100644 src/app/api/oauth/cursor/login/start/route.ts create mode 100644 src/lib/oauth/services/cursorLogin.ts create mode 100644 src/lib/oauth/services/persistCursorConnection.ts create mode 100644 src/lib/providerModels/cursorAvailableModels.ts create mode 100644 tests/unit/cursor-available-models.test.ts create mode 100644 tests/unit/cursor-catalog-combo-compat.test.ts create mode 100644 tests/unit/cursor-errors-classify.test.ts create mode 100644 tests/unit/cursor-login-pkce.test.ts create mode 100644 tests/unit/cursor-token-refresh-wiring.test.ts diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 0d237635cc..8e0926137a 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -263,6 +263,8 @@ Cost: currently listed as $0; terms and availability may change ### Cursor IDE +**Using Cursor as an OmniRoute client** (route Cursor chat through OmniRoute): + ``` Settings → Models → Advanced: OpenAI API Base URL: http://localhost:20128/v1 @@ -270,6 +272,10 @@ Settings → Models → Advanced: Model: cc/claude-opus-4-7 ``` +**Using OmniRoute as a Cursor provider** (OmniRoute calls Cursor upstream): prefer +**Dashboard → Providers → Cursor → Login with Cursor**. In Docker, see +[`docs/providers/CURSOR-DOCKER.md`](../providers/CURSOR-DOCKER.md). + ### Claude Code Edit `~/.claude/settings.json`: diff --git a/docs/providers/CURSOR-DOCKER.md b/docs/providers/CURSOR-DOCKER.md index eb5724b0c8..d524a8c832 100644 --- a/docs/providers/CURSOR-DOCKER.md +++ b/docs/providers/CURSOR-DOCKER.md @@ -1,15 +1,58 @@ --- -title: "Cursor model listing" +title: "Cursor Provider in Docker Environments" version: 3.8.50 -lastUpdated: 2026-08-09 +lastUpdated: 2026-08-17 --- -# Cursor model listing +# Cursor Provider in Docker Environments -## Live catalog is exclusive when synced +When OmniRoute runs inside Docker, the legacy **Import from Cursor IDE** / +`cursor-agent` flows fail because the container cannot see the host Cursor +install. Use **Login with Cursor** (deep-control PKCE) instead. + +## Why IDE / CLI Import Fails in Docker + +1. **Filesystem isolation** — Auto-import looks for Linux paths such as + `~/.config/Cursor/User/globalStorage/state.vscdb` _inside_ the container. + On Docker Desktop for macOS the host IDE DB is not mounted by default, and + the container OS is Linux even when the host is Darwin. +2. **No `cursor-agent` binary** — Official OmniRoute images do not ship + `cursor-agent`. Available Models previously shelled out to + `cursor-agent --list-models` and fell back to a static catalog. +3. **Wrong binary** — Do **not** bind-mount a macOS `cursor-agent` into a Linux + container. It will not execute. + +## Recommended: Login with Cursor + +1. Open **Dashboard → Providers → Cursor**. +2. Choose the **Login with Cursor** tab. +3. Click **Login with Cursor** — OmniRoute opens + `https://cursor.com/loginDeepControl?…` in your **host** browser. +4. Approve the login in the browser, then return to the dashboard. OmniRoute + polls `api2.cursor.sh/auth/poll` until tokens arrive. +5. OmniRoute stores **access + refresh** tokens and refreshes them via + `https://api2.cursor.sh/auth/exchange_user_api_key`. + +This path does not require Cursor IDE or `cursor-agent` inside the container. + +## Model discovery + +With a logged-in connection, **Available Models / Auto-Sync** prefers Cursor’s +HTTP `AiService/AvailableModels` catalog using the connection bearer token. +If that fails, OmniRoute still tries host `cursor-agent` (when present), then +the static registry seed. + +OmniRoute always exposes **`auto`** in the catalog (display “Auto”), plus +OpenCodex-style router modes **`auto-cost`**, **`auto-balance`**, and +**`auto-intelligence`**. On the wire these map to Cursor’s `default` model +(with an `optimization` ModelParameter for the three variants). Prefer +`cu/auto` when premium models are out of usage — Auto often still has budget. + +### Live catalog is exclusive when synced After a successful Cursor model sync (`cursor-agent --list-models` → persisted -synced catalog), the **dashboard**, **`/v1/models`**, and **Test All** list: +synced catalog, or the bearer-authenticated `AvailableModels` fetch above), the +**dashboard**, **`/v1/models`**, and **Test All** list: 1. Models returned by the live sync 2. Injected auto-router ids: `auto`, `auto-cost`, `auto-balance`, `auto-intelligence` @@ -24,10 +67,63 @@ Effort-suffixed ids (for example `claude-4.6-sonnet-high`) may still be `ModelParameter`. Exclusive listing intentionally hides those static variants from Test All so probes match what Cursor actually returns as available. -## Helpers +### Helpers - `providerUsesExclusiveSyncedListing("cursor"|"cu")` — `src/lib/providers/modelListingCapability.ts` - `mergeProviderModelListing` — dashboard merge - `ensureCursorAutoCatalogEntry` — auto* inject on discovery + listing - `shouldSuppressStaticModelForExclusiveListing` — `/v1/models` static loop + +## Provider Limits (quota) + +**Usage → Provider Limits** for Cursor uses Bearer APIs on `api2.cursor.sh` +(`GetCurrentPeriodUsage` → usage summary → auth/usage) after PKCE or token +import. The legacy cookie/`cursor.com` dashboard path remains a last fallback +for older IDE-imported sessions. + +Windows typically include **Total**, **Auto + Composer**, and **API**. If +limits look empty, re-run **Login with Cursor** or re-import tokens (IDE import +alone is no longer required). + +## Empty turns / out of usage + +When Cursor accepts a Run but returns no assistant text (common when premium +usage is exhausted), OmniRoute surfaces an actionable **429** (quota cues) or +**502** with guidance — not a bare “Provider returned empty content”. Streaming +failures such as `not_found: AI Model Not Found` (usage window exhausted) are +classified as **Cursor rate limit / usage exceeded** and keep that message +through the SSE pipeline (the shared empty-stream guard does not overwrite an +already-emitted error). Check Provider Limits, try model **`auto`**, or raise +Cursor plan limits. + +## Client version (headless) + +Without a local `cursor-agent` install, OmniRoute resolves +`x-cursor-client-version` via env `CURSOR_AGENT_CLI_VERSION`, then a disk-cached +scrape of the Cursor installer script, then a pinned build id. Override with +`CURSOR_AGENT_CLI_VERSION` when needed. + +## Fallback: Manual Token Import + +If you cannot complete browser login: + +1. On the host, extract tokens from Cursor’s `state.vscdb`: + + ```bash + sqlite3 "$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb" \ + "SELECT key, value FROM ItemTable WHERE key IN ('cursorAuth/accessToken','cursorAuth/refreshToken','storage.serviceMachineId');" + ``` + +2. Open **Import token** in the Cursor auth modal. +3. Paste **Access Token** and, when available, **Refresh Token** (required for + automatic refresh). Machine ID is optional. + +Access-token-only imports still work but will expire without a refresh token — +re-import when chat returns authentication errors. + +## Related + +- Zed Docker guidance: [`docs/providers/ZED-DOCKER.md`](./ZED-DOCKER.md) +- OpenCodex Cursor login reference (external): + https://github.com/lidge-jun/opencodex/blob/main/src/oauth/cursor.ts diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index 1bb6d02b21..67dfb74467 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -14,147 +14,228 @@ export const cursorProvider: RegistryEntry = { headers: getCursorRegistryHeaders(), clientVersion: CURSOR_REGISTRY_VERSION, models: [ - { id: "auto", name: "Auto (Server Picks)" }, - { id: "composer-2.5-fast", name: "Composer 2.5 Fast" }, - { id: "composer-2.5", name: "Composer 2.5" }, - { id: "composer-2-fast", name: "Composer 2 Fast" }, + { id: "auto", name: "Auto (current, default)" }, + { id: "auto-cost", name: "Auto (cost)" }, + { id: "auto-balance", name: "Auto (balance)" }, + { id: "auto-intelligence", name: "Auto (intelligence)" }, + // Legacy combo ids kept so existing cu/ targets are not orphaned. { id: "composer-2", name: "Composer 2" }, - // - { id: "gpt-5.5-none", name: "GPT 5.5 None" }, - { id: "gpt-5.5-none-fast", name: "GPT 5.5 None Fast" }, - { id: "gpt-5.5-low", name: "GPT 5.5 Low" }, - { id: "gpt-5.5-low-fast", name: "GPT 5.5 Low Fast" }, - { id: "gpt-5.5-medium", name: "GPT 5.5 Medium" }, - { id: "gpt-5.5-medium-fast", name: "GPT 5.5 Medium Fast" }, - { id: "gpt-5.5-high", name: "GPT 5.5 High" }, - { id: "gpt-5.5-high-fast", name: "GPT 5.5 High Fast" }, - { id: "gpt-5.5-extra-high", name: "GPT 5.5 Extra High" }, - { id: "gpt-5.5-extra-high-fast", name: "GPT 5.5 Extra High Fast" }, - // - { id: "gpt-5.4-low", name: "GPT 5.4 Low" }, + { id: "composer-2-fast", name: "Composer 2 Fast" }, { id: "gpt-5.4-low-fast", name: "GPT 5.4 Low Fast" }, - { id: "gpt-5.4-medium", name: "GPT 5.4 Medium" }, - { id: "gpt-5.4-medium-fast", name: "GPT 5.4 Medium Fast" }, - { id: "gpt-5.4-high", name: "GPT 5.4 High" }, - { id: "gpt-5.4-high-fast", name: "GPT 5.4 High Fast" }, - { id: "gpt-5.4-xhigh", name: "GPT 5.4 XHigh" }, - { id: "gpt-5.4-xhigh-fast", name: "GPT 5.4 XHigh Fast" }, - // - { id: "gpt-5.4-mini-none", name: "GPT 5.4 Mini None" }, - { id: "gpt-5.4-mini-low", name: "GPT 5.4 Mini Low" }, - { id: "gpt-5.4-mini-medium", name: "GPT 5.4 Mini Medium" }, - { id: "gpt-5.4-mini-high", name: "GPT 5.4 Mini High" }, - { id: "gpt-5.4-mini-xhigh", name: "GPT 5.4 Mini XHigh" }, - // - { id: "gpt-5.4-nano-none", name: "GPT 5.4 Nano None" }, - { id: "gpt-5.4-nano-low", name: "GPT 5.4 Nano Low" }, - { id: "gpt-5.4-nano-medium", name: "GPT 5.4 Nano Medium" }, - { id: "gpt-5.4-nano-high", name: "GPT 5.4 Nano High" }, - { id: "gpt-5.4-nano-xhigh", name: "GPT 5.4 Nano XHigh" }, - // { id: "gpt-5.3-codex-spark-preview-low", name: "GPT 5.3 Codex Spark Preview Low" }, { id: "gpt-5.3-codex-spark-preview", name: "GPT 5.3 Codex Spark Preview" }, { id: "gpt-5.3-codex-spark-preview-high", name: "GPT 5.3 Codex Spark Preview High" }, { id: "gpt-5.3-codex-spark-preview-xhigh", name: "GPT 5.3 Codex Spark Preview XHigh" }, - // - { id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex Low" }, - { id: "gpt-5.3-codex-low-fast", name: "GPT 5.3 Codex Low Fast" }, - { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, - { id: "gpt-5.3-codex-fast", name: "GPT 5.3 Codex Fast" }, - { id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex High" }, - { id: "gpt-5.3-codex-high-fast", name: "GPT 5.3 Codex High Fast" }, - { id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex XHigh" }, - { id: "gpt-5.3-codex-xhigh-fast", name: "GPT 5.3 Codex XHigh Fast" }, - // - { id: "gpt-5.2-low", name: "GPT 5.2 Low" }, - { id: "gpt-5.2-low-fast", name: "GPT 5.2 Low Fast" }, - { id: "gpt-5.2", name: "GPT 5.2" }, - { id: "gpt-5.2-fast", name: "GPT 5.2 Fast" }, - { id: "gpt-5.2-high", name: "GPT 5.2 High" }, - { id: "gpt-5.2-high-fast", name: "GPT 5.2 High Fast" }, - { id: "gpt-5.2-xhigh", name: "GPT 5.2 XHigh" }, - { id: "gpt-5.2-xhigh-fast", name: "GPT 5.2 XHigh Fast" }, - // - { id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low" }, - { id: "claude-opus-4-8-low-fast", name: "Claude Opus 4.8 Low Fast" }, - { id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium" }, - { id: "claude-opus-4-8-medium-fast", name: "Claude Opus 4.8 Medium Fast" }, - { id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High" }, - { id: "claude-opus-4-8-high-fast", name: "Claude Opus 4.8 High Fast" }, - { id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 XHigh" }, - { id: "claude-opus-4-8-xhigh-fast", name: "Claude Opus 4.8 XHigh Fast" }, - { id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max" }, - { id: "claude-opus-4-8-max-fast", name: "Claude Opus 4.8 Max Fast" }, - { id: "claude-opus-4-8-thinking-low", name: "Claude Opus 4.8 Thinking Low" }, - { id: "claude-opus-4-8-thinking-low-fast", name: "Claude Opus 4.8 Thinking Low Fast" }, - { id: "claude-opus-4-8-thinking-medium", name: "Claude Opus 4.8 Thinking Medium" }, - { id: "claude-opus-4-8-thinking-medium-fast", name: "Claude Opus 4.8 Thinking Medium Fast" }, - { id: "claude-opus-4-8-thinking-high", name: "Claude Opus 4.8 Thinking High" }, - { id: "claude-opus-4-8-thinking-high-fast", name: "Claude Opus 4.8 Thinking High Fast" }, - { id: "claude-opus-4-8-thinking-xhigh", name: "Claude Opus 4.8 Thinking XHigh" }, - { id: "claude-opus-4-8-thinking-xhigh-fast", name: "Claude Opus 4.8 Thinking XHigh Fast" }, - { id: "claude-opus-4-8-thinking-max", name: "Claude Opus 4.8 Thinking Max" }, - { id: "claude-opus-4-8-thinking-max-fast", name: "Claude Opus 4.8 Thinking Max Fast" }, - // - { id: "claude-fable-5-low", name: "Claude Fable 5 Low" }, - { id: "claude-fable-5-medium", name: "Claude Fable 5 Medium" }, - { id: "claude-fable-5-high", name: "Claude Fable 5 High" }, - { id: "claude-fable-5-xhigh", name: "Claude Fable 5 XHigh" }, - { id: "claude-fable-5-max", name: "Claude Fable 5 Max" }, - { id: "claude-fable-5-thinking-low", name: "Claude Fable 5 Thinking Low" }, - { id: "claude-fable-5-thinking-medium", name: "Claude Fable 5 Thinking Medium" }, - { id: "claude-fable-5-thinking-high", name: "Claude Fable 5 Thinking High" }, - { id: "claude-fable-5-thinking-xhigh", name: "Claude Fable 5 Thinking XHigh" }, - { id: "claude-fable-5-thinking-max", name: "Claude Fable 5 Thinking Max" }, - // - { id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low" }, - { id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium" }, - { id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High" }, - { id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 XHigh" }, - { id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max" }, - { id: "claude-sonnet-5-thinking-low", name: "Claude Sonnet 5 Thinking Low" }, - { id: "claude-sonnet-5-thinking-medium", name: "Claude Sonnet 5 Thinking Medium" }, - { id: "claude-sonnet-5-thinking-high", name: "Claude Sonnet 5 Thinking High" }, - { id: "claude-sonnet-5-thinking-xhigh", name: "Claude Sonnet 5 Thinking XHigh" }, - { id: "claude-sonnet-5-thinking-max", name: "Claude Sonnet 5 Thinking Max" }, - // - { id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low" }, - { id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium" }, - { id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High" }, - { id: "claude-opus-4-7-xhigh", name: "Claude Opus 4.7 XHigh" }, - { id: "claude-opus-4-7-max", name: "Claude Opus 4.7 Max" }, - - { id: "claude-opus-4-7-thinking-low", name: "Claude Opus 4.7 Thinking Low" }, - { id: "claude-opus-4-7-thinking-medium", name: "Claude Opus 4.7 Thinking Medium" }, - { id: "claude-opus-4-7-thinking-high", name: "Claude Opus 4.7 Thinking High" }, - { id: "claude-opus-4-7-thinking-xhigh", name: "Claude Opus 4.7 Thinking XHigh" }, - { id: "claude-opus-4-7-thinking-max", name: "Claude Opus 4.7 Thinking Max" }, - // - { id: "claude-4.6-opus-high", name: "Claude 4.6 Opus High" }, - { id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" }, { id: "claude-4.6-opus-high-thinking-fast", name: "Claude 4.6 Opus High Thinking Fast" }, - { id: "claude-4.6-opus-max", name: "Claude 4.6 Opus Max" }, - { id: "claude-4.6-opus-max-thinking", name: "Claude 4.6 Opus Max Thinking" }, { id: "claude-4.6-opus-max-thinking-fast", name: "Claude 4.6 Opus Max Thinking Fast" }, - // { id: "claude-4.6-sonnet-medium", name: "Claude 4.6 Sonnet Medium" }, { id: "claude-4.6-sonnet-medium-thinking", name: "Claude 4.6 Sonnet Medium Thinking" }, - // { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - // { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" }, { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - // { id: "grok-4.6-medium", name: "Grok 4.6 Medium" }, { id: "grok-4.6-fast-medium", name: "Grok 4.6 Fast Medium" }, { id: "grok-4.6-high", name: "Grok 4.6 High" }, { id: "grok-4.6-fast-high", name: "Grok 4.6 Fast High" }, { id: "grok-4.6-xhigh", name: "Grok 4.6 XHigh" }, { id: "grok-4.6-fast-xhigh", name: "Grok 4.6 Fast XHigh" }, - // { id: "kimi-k3", name: "Kimi K3" }, { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - ], + { id: "grok-4.3", name: "Grok 4.3" }, + { id: "grok-4.5-medium", name: "Grok 4.5 Medium" }, + { id: "grok-4.5-fast-medium", name: "Grok 4.5 Fast Medium" }, + { id: "grok-4.5-high", name: "Grok 4.5 High" }, + { id: "grok-4.5-fast-high", name: "Grok 4.5 Fast High" }, + { id: "grok-4.5-xhigh", name: "Grok 4.5 XHigh" }, + { id: "grok-4.5-fast-xhigh", name: "Grok 4.5 Fast XHigh" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "gpt-5.3-codex-low", name: "Codex 5.3 Low" }, + { id: "gpt-5.3-codex-low-fast", name: "Codex 5.3 Low Fast" }, + { id: "gpt-5.3-codex", name: "Codex 5.3" }, + { id: "gpt-5.3-codex-fast", name: "Codex 5.3 Fast" }, + { id: "gpt-5.3-codex-high", name: "Codex 5.3 High" }, + { id: "gpt-5.3-codex-high-fast", name: "Codex 5.3 High Fast" }, + { id: "gpt-5.3-codex-xhigh", name: "Codex 5.3 Extra High" }, + { id: "gpt-5.3-codex-xhigh-fast", name: "Codex 5.3 Extra High Fast" }, + { id: "gpt-5.2", name: "GPT-5.2" }, + { id: "cursor-grok-4.5-high", name: "Cursor Grok 4.5" }, + { id: "cursor-grok-4.5-high-fast", name: "Cursor Grok 4.5 Fast" }, + { id: "composer-2.5", name: "Composer 2.5" }, + { id: "claude-opus-5-thinking-high", name: "Opus 5 1M Thinking" }, + { id: "claude-opus-5-thinking-high-fast", name: "Opus 5 1M Thinking Fast" }, + { id: "claude-opus-5-thinking-xhigh", name: "Opus 5 1M Extra High Thinking" }, + { id: "claude-opus-5-thinking-xhigh-fast", name: "Opus 5 1M Extra High Thinking Fast" }, + { id: "claude-opus-4-8-thinking-high", name: "Opus 4.8 1M Thinking" }, + { id: "claude-opus-4-8-thinking-high-fast", name: "Opus 4.8 1M Thinking Fast" }, + { id: "gpt-5.6-sol-high", name: "GPT-5.6 Sol 1M High" }, + { id: "gpt-5.6-sol-high-fast", name: "GPT-5.6 Sol High Fast" }, + { id: "gpt-5.6-sol-xhigh", name: "GPT-5.6 Sol 1M Extra High" }, + { id: "gpt-5.6-sol-xhigh-fast", name: "GPT-5.6 Sol Extra High Fast" }, + { id: "gpt-5.5-high", name: "GPT-5.5 1M High" }, + { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, + { id: "claude-fable-5-thinking-high", name: "Fable 5 1M Thinking (NO ZDR)" }, + { id: "claude-fable-5-thinking-xhigh", name: "Fable 5 1M Extra High Thinking (NO ZDR)" }, + { id: "claude-sonnet-5-thinking-high", name: "Sonnet 5 1M Thinking" }, + { id: "claude-sonnet-5-thinking-xhigh", name: "Sonnet 5 1M Extra High Thinking" }, + { id: "kimi-k3-high", name: "Kimi K3 High" }, + { id: "cursor-grok-4.5-low", name: "Cursor Grok 4.5 Low" }, + { id: "cursor-grok-4.5-low-fast", name: "Cursor Grok 4.5 Low Fast" }, + { id: "cursor-grok-4.5-medium", name: "Cursor Grok 4.5 Medium" }, + { id: "cursor-grok-4.5-medium-fast", name: "Cursor Grok 4.5 Medium Fast" }, + { id: "composer-2.5-fast", name: "Composer 2.5 Fast" }, + { id: "claude-opus-5-low", name: "Opus 5 1M Low" }, + { id: "claude-opus-5-low-fast", name: "Opus 5 1M Low Fast" }, + { id: "claude-opus-5-medium", name: "Opus 5 1M Medium" }, + { id: "claude-opus-5-medium-fast", name: "Opus 5 1M Medium Fast" }, + { id: "claude-opus-5-high", name: "Opus 5 1M" }, + { id: "claude-opus-5-high-fast", name: "Opus 5 1M Fast" }, + { id: "claude-opus-5-thinking-low", name: "Opus 5 1M Low Thinking" }, + { id: "claude-opus-5-thinking-low-fast", name: "Opus 5 1M Low Thinking Fast" }, + { id: "claude-opus-5-thinking-medium", name: "Opus 5 1M Medium Thinking" }, + { id: "claude-opus-5-thinking-medium-fast", name: "Opus 5 1M Medium Thinking Fast" }, + { id: "claude-opus-5-thinking-max", name: "Opus 5 1M Max Thinking" }, + { id: "claude-opus-5-thinking-max-fast", name: "Opus 5 1M Max Thinking Fast" }, + { id: "claude-opus-4-8-low", name: "Opus 4.8 1M Low" }, + { id: "claude-opus-4-8-low-fast", name: "Opus 4.8 1M Low Fast" }, + { id: "claude-opus-4-8-medium", name: "Opus 4.8 1M Medium" }, + { id: "claude-opus-4-8-medium-fast", name: "Opus 4.8 1M Medium Fast" }, + { id: "claude-opus-4-8-high", name: "Opus 4.8 1M" }, + { id: "claude-opus-4-8-high-fast", name: "Opus 4.8 1M Fast" }, + { id: "claude-opus-4-8-xhigh", name: "Opus 4.8 1M Extra High" }, + { id: "claude-opus-4-8-xhigh-fast", name: "Opus 4.8 1M Extra High Fast" }, + { id: "claude-opus-4-8-max", name: "Opus 4.8 1M Max" }, + { id: "claude-opus-4-8-max-fast", name: "Opus 4.8 1M Max Fast" }, + { id: "claude-opus-4-8-thinking-low", name: "Opus 4.8 1M Low Thinking" }, + { id: "claude-opus-4-8-thinking-low-fast", name: "Opus 4.8 1M Low Thinking Fast" }, + { id: "claude-opus-4-8-thinking-medium", name: "Opus 4.8 1M Medium Thinking" }, + { id: "claude-opus-4-8-thinking-medium-fast", name: "Opus 4.8 1M Medium Thinking Fast" }, + { id: "claude-opus-4-8-thinking-xhigh", name: "Opus 4.8 1M Extra High Thinking" }, + { id: "claude-opus-4-8-thinking-xhigh-fast", name: "Opus 4.8 1M Extra High Thinking Fast" }, + { id: "claude-opus-4-8-thinking-max", name: "Opus 4.8 1M Max Thinking" }, + { id: "claude-opus-4-8-thinking-max-fast", name: "Opus 4.8 1M Max Thinking Fast" }, + { id: "gpt-5.6-sol-none", name: "GPT-5.6 Sol 1M None" }, + { id: "gpt-5.6-sol-none-fast", name: "GPT-5.6 Sol None Fast" }, + { id: "gpt-5.6-sol-low", name: "GPT-5.6 Sol 1M Low" }, + { id: "gpt-5.6-sol-low-fast", name: "GPT-5.6 Sol Low Fast" }, + { id: "gpt-5.6-sol-medium", name: "GPT-5.6 Sol 1M" }, + { id: "gpt-5.6-sol-medium-fast", name: "GPT-5.6 Sol Fast" }, + { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol 1M Max" }, + { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, + { id: "gpt-5.5-none", name: "GPT-5.5 1M None" }, + { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, + { id: "gpt-5.5-low", name: "GPT-5.5 1M Low" }, + { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, + { id: "gpt-5.5-medium", name: "GPT-5.5 1M" }, + { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Fast" }, + { id: "gpt-5.5-extra-high", name: "GPT-5.5 1M Extra High" }, + { id: "gpt-5.5-extra-high-fast", name: "GPT-5.5 Extra High Fast" }, + { id: "claude-fable-5-low", name: "Fable 5 1M Low (NO ZDR)" }, + { id: "claude-fable-5-medium", name: "Fable 5 1M Medium (NO ZDR)" }, + { id: "claude-fable-5-high", name: "Fable 5 1M (NO ZDR)" }, + { id: "claude-fable-5-xhigh", name: "Fable 5 1M Extra High (NO ZDR)" }, + { id: "claude-fable-5-max", name: "Fable 5 1M Max (NO ZDR)" }, + { id: "claude-fable-5-thinking-low", name: "Fable 5 1M Low Thinking (NO ZDR)" }, + { id: "claude-fable-5-thinking-medium", name: "Fable 5 1M Medium Thinking (NO ZDR)" }, + { id: "claude-fable-5-thinking-max", name: "Fable 5 1M Max Thinking (NO ZDR)" }, + { id: "claude-sonnet-5-low", name: "Sonnet 5 1M Low" }, + { id: "claude-sonnet-5-medium", name: "Sonnet 5 1M Medium" }, + { id: "claude-sonnet-5-high", name: "Sonnet 5 1M" }, + { id: "claude-sonnet-5-xhigh", name: "Sonnet 5 1M Extra High" }, + { id: "claude-sonnet-5-max", name: "Sonnet 5 1M Max" }, + { id: "claude-sonnet-5-thinking-low", name: "Sonnet 5 1M Low Thinking" }, + { id: "claude-sonnet-5-thinking-medium", name: "Sonnet 5 1M Medium Thinking" }, + { id: "claude-sonnet-5-thinking-max", name: "Sonnet 5 1M Max Thinking" }, + { id: "gpt-5.6-terra-none", name: "GPT-5.6 Terra 1M None" }, + { id: "gpt-5.6-terra-none-fast", name: "GPT-5.6 Terra None Fast" }, + { id: "gpt-5.6-terra-low", name: "GPT-5.6 Terra 1M Low" }, + { id: "gpt-5.6-terra-low-fast", name: "GPT-5.6 Terra Low Fast" }, + { id: "gpt-5.6-terra-medium", name: "GPT-5.6 Terra 1M" }, + { id: "gpt-5.6-terra-medium-fast", name: "GPT-5.6 Terra Fast" }, + { id: "gpt-5.6-terra-high", name: "GPT-5.6 Terra 1M High" }, + { id: "gpt-5.6-terra-high-fast", name: "GPT-5.6 Terra High Fast" }, + { id: "gpt-5.6-terra-xhigh", name: "GPT-5.6 Terra 1M Extra High" }, + { id: "gpt-5.6-terra-xhigh-fast", name: "GPT-5.6 Terra Extra High Fast" }, + { id: "gpt-5.6-terra-max", name: "GPT-5.6 Terra 1M Max" }, + { id: "gpt-5.6-terra-max-fast", name: "GPT-5.6 Terra Max Fast" }, + { id: "claude-opus-4-7-low", name: "Opus 4.7 1M Low" }, + { id: "claude-opus-4-7-low-fast", name: "Opus 4.7 1M Low Fast" }, + { id: "claude-opus-4-7-medium", name: "Opus 4.7 1M Medium" }, + { id: "claude-opus-4-7-medium-fast", name: "Opus 4.7 1M Medium Fast" }, + { id: "claude-opus-4-7-high", name: "Opus 4.7 1M High" }, + { id: "claude-opus-4-7-high-fast", name: "Opus 4.7 1M High Fast" }, + { id: "claude-opus-4-7-xhigh", name: "Opus 4.7 1M" }, + { id: "claude-opus-4-7-xhigh-fast", name: "Opus 4.7 1M Fast" }, + { id: "claude-opus-4-7-max", name: "Opus 4.7 1M Max" }, + { id: "claude-opus-4-7-max-fast", name: "Opus 4.7 1M Max Fast" }, + { id: "claude-opus-4-7-thinking-low", name: "Opus 4.7 1M Low Thinking" }, + { id: "claude-opus-4-7-thinking-low-fast", name: "Opus 4.7 1M Low Thinking Fast" }, + { id: "claude-opus-4-7-thinking-medium", name: "Opus 4.7 1M Medium Thinking" }, + { id: "claude-opus-4-7-thinking-medium-fast", name: "Opus 4.7 1M Medium Thinking Fast" }, + { id: "claude-opus-4-7-thinking-high", name: "Opus 4.7 1M High Thinking" }, + { id: "claude-opus-4-7-thinking-high-fast", name: "Opus 4.7 1M High Thinking Fast" }, + { id: "claude-opus-4-7-thinking-xhigh", name: "Opus 4.7 1M Thinking" }, + { id: "claude-opus-4-7-thinking-xhigh-fast", name: "Opus 4.7 1M Thinking Fast" }, + { id: "claude-opus-4-7-thinking-max", name: "Opus 4.7 1M Max Thinking" }, + { id: "claude-opus-4-7-thinking-max-fast", name: "Opus 4.7 1M Max Thinking Fast" }, + { id: "gpt-5.4-low", name: "GPT-5.4 1M Low" }, + { id: "gpt-5.4-medium", name: "GPT-5.4 1M" }, + { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Fast" }, + { id: "gpt-5.4-high", name: "GPT-5.4 1M High" }, + { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { id: "gpt-5.4-xhigh", name: "GPT-5.4 1M Extra High" }, + { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 Extra High Fast" }, + { id: "claude-4.6-opus-high", name: "Opus 4.6 1M" }, + { id: "claude-4.6-opus-max", name: "Opus 4.6 1M Max" }, + { id: "claude-4.6-opus-high-thinking", name: "Opus 4.6 1M Thinking" }, + { id: "claude-4.6-opus-max-thinking", name: "Opus 4.6 1M Max Thinking" }, + { id: "claude-4.5-opus-high", name: "Opus 4.5" }, + { id: "claude-4.5-opus-high-thinking", name: "Opus 4.5 Thinking" }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, + { id: "gpt-5.2-low-fast", name: "GPT-5.2 Low Fast" }, + { id: "gpt-5.2-fast", name: "GPT-5.2 Fast" }, + { id: "gpt-5.2-high", name: "GPT-5.2 High" }, + { id: "gpt-5.2-high-fast", name: "GPT-5.2 High Fast" }, + { id: "gpt-5.2-xhigh", name: "GPT-5.2 Extra High" }, + { id: "gpt-5.2-xhigh-fast", name: "GPT-5.2 Extra High Fast" }, + { id: "gpt-5.6-luna-none", name: "GPT-5.6 Luna 1M None" }, + { id: "gpt-5.6-luna-none-fast", name: "GPT-5.6 Luna None Fast" }, + { id: "gpt-5.6-luna-low", name: "GPT-5.6 Luna 1M Low" }, + { id: "gpt-5.6-luna-low-fast", name: "GPT-5.6 Luna Low Fast" }, + { id: "gpt-5.6-luna-medium", name: "GPT-5.6 Luna 1M" }, + { id: "gpt-5.6-luna-medium-fast", name: "GPT-5.6 Luna Fast" }, + { id: "gpt-5.6-luna-high", name: "GPT-5.6 Luna 1M High" }, + { id: "gpt-5.6-luna-high-fast", name: "GPT-5.6 Luna High Fast" }, + { id: "gpt-5.6-luna-xhigh", name: "GPT-5.6 Luna 1M Extra High" }, + { id: "gpt-5.6-luna-xhigh-fast", name: "GPT-5.6 Luna Extra High Fast" }, + { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna 1M Max" }, + { id: "gpt-5.6-luna-max-fast", name: "GPT-5.6 Luna Max Fast" }, + { id: "gemini-3.6-flash-minimal", name: "Gemini 3.6 Flash Minimal" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash Low" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash Medium" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash" }, + { id: "gpt-5.4-mini-none", name: "GPT-5.4 Mini None" }, + { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, + { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini" }, + { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, + { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini Extra High" }, + { id: "gpt-5.4-nano-none", name: "GPT-5.4 Nano None" }, + { id: "gpt-5.4-nano-low", name: "GPT-5.4 Nano Low" }, + { id: "gpt-5.4-nano-medium", name: "GPT-5.4 Nano" }, + { id: "gpt-5.4-nano-high", name: "GPT-5.4 Nano High" }, + { id: "gpt-5.4-nano-xhigh", name: "GPT-5.4 Nano Extra High" }, + { id: "claude-4.5-sonnet", name: "Sonnet 4.5" }, + { id: "claude-4.5-sonnet-thinking", name: "Sonnet 4.5 Thinking" }, + { id: "gpt-5.1-low", name: "GPT-5.1 Low" }, + { id: "gpt-5.1", name: "GPT-5.1" }, + { id: "gpt-5.1-high", name: "GPT-5.1 High" }, + { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "claude-4-sonnet", name: "Sonnet 4" }, + { id: "claude-4-sonnet-thinking", name: "Sonnet 4 Thinking" }, + { id: "gpt-5-mini", name: "GPT-5 Mini" }, + { id: "kimi-k3-low", name: "Kimi K3 Low" }, + { id: "kimi-k3-max", name: "Kimi K3" }, + { id: "glm-5.2-high", name: "GLM 5.2" }, + { id: "glm-5.2-max", name: "GLM 5.2 Max" }, ], }; /** diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index ebcfe053bd..8ffca7b318 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -12,6 +12,7 @@ declare const EdgeRuntime: string | undefined; import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts"; +import { getAccessToken } from "../services/tokenRefresh.ts"; import { buildAgentRequestBody, decodeAgentServerMessage, @@ -83,6 +84,12 @@ import { composerReasoningRemainder, } from "./cursor/composer.ts"; import { CursorServerConfigError, resolveCursorAgentUrl } from "./cursor/agentEndpoint.ts"; +import { + classifyCursorError, + isCursorBenignCancelError, + resolveCursorEmptyTurnError, + type ClassifiedCursorError, +} from "./cursor/cursorErrors.ts"; import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts"; // Composer helpers re-exported for external importers (tests). export { @@ -250,19 +257,33 @@ function tryParseJsonError(payload: Buffer): { message: string; status: number } if (!text.includes('"error"')) return null; const parsed = JSON.parse(text); const err = parsed?.error || {}; - const message = + const rawMessage = err?.details?.[0]?.debug?.details?.title || err?.details?.[0]?.debug?.details?.detail || err?.message || - text; - const status = - err?.code === "resource_exhausted" ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST; - return { message, status }; + (typeof err?.code === "string" ? `${err.code}: ${text}` : text); + const codeHint = + typeof err?.code === "string" && + !String(rawMessage).toLowerCase().includes(err.code.toLowerCase()) + ? `${err.code}: ${rawMessage}` + : String(rawMessage); + const classified = classifyCursorError(codeHint); + return { message: classified.message, status: classified.status }; } catch { return null; } } +/** True when the turn produced no client-visible assistant payload. */ +function isCursorEmptyTurn(ctx: StreamCtx): boolean { + return ( + ctx.totalText.length === 0 && + ctx.thinkingText.length === 0 && + ctx.toolCalls.length === 0 && + !ctx.composerInlineToolCallsEmitted + ); +} + // ─── Phase 4: streaming dispatch context ─────────────────────────────────── // // One StreamCtx flows through a single execute() call. It owns the live @@ -355,6 +376,27 @@ function emitChunk(ctx: StreamCtx, delta: object, finishReason: string | null = ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); } +/** + * Emit a terminal OpenAI SSE error matching `buildStreamErrorChunks` shape + * (`finish_reason: "error"` + `error.message`) so #8649 sawError stands down + * and Model Test All keeps the classified Cursor message. + */ +export function emitCursorSseError(ctx: StreamCtx, classified: ClassifiedCursorError): void { + const payload = { + id: ctx.responseId, + object: "chat.completion.chunk", + created: ctx.created, + model: ctx.model, + choices: [{ index: 0, delta: {}, finish_reason: "error" }], + error: { + message: classified.message, + type: classified.type, + }, + }; + ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); + ctx.emit("data: [DONE]\n\n"); +} + export function buildCursorUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { const promptTokens = estimateInputTokens(body); const completionTokens = @@ -1441,6 +1483,17 @@ export class CursorExecutor extends BaseExecutor { finishLifecycle(ctx, false); controller.close(); } catch (err) { + // OpenCodex: NGHTTP2_CANCEL after client-tool suspend is expected — finish + // the SSE turn instead of surfacing a transport failure. + if ( + isCursorBenignCancelError(err) && + (ctx.totalText.length > 0 || ctx.pendingToolCalls.size > 0) + ) { + this.finalizeSseStream(ctx, body); + finishLifecycle(ctx, false); + controller.close(); + return; + } finishLifecycle(ctx, true); controller.error(err); } @@ -1468,10 +1521,23 @@ export class CursorExecutor extends BaseExecutor { try { await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, todoHistory, signal); } catch (err) { + if ( + isCursorBenignCancelError(err) && + (ctx.totalText.length > 0 || ctx.pendingToolCalls.size > 0) + ) { + finishLifecycle(ctx, false); + return { + response: this.buildResponseFromCtx(ctx, body), + url, + headers, + transformedBody: body, + }; + } finishLifecycle(ctx, true); const message = err instanceof Error ? err.message : String(err); + const classified = classifyCursorError(message); return { - response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), + response: buildErrorResponse(classified.status, classified.message, classified.type), url, headers, transformedBody: body, @@ -1493,24 +1559,22 @@ export class CursorExecutor extends BaseExecutor { */ private finalizeSseStream(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { if (ctx.midStreamError && ctx.totalText.length === 0) { - const payload = { - id: ctx.responseId, - object: "chat.completion.chunk", - created: ctx.created, - model: ctx.model, - choices: [], - error: { - message: ctx.midStreamError.message, - type: - ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED - ? "rate_limit_error" - : "api_error", - }, - }; - ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); - ctx.emit("data: [DONE]\n\n"); + emitCursorSseError(ctx, classifyCursorError(ctx.midStreamError.message)); return; } + + // Silent empty turn (auth accepted, no text) — surface actionable error instead of + // an empty assistant completion that chatCore maps to opaque "empty content" 502. + if (isCursorEmptyTurn(ctx) && ctx.endReason && ctx.endReason !== "tool_calls") { + emitCursorSseError( + ctx, + resolveCursorEmptyTurnError({ + upstreamMessage: ctx.midStreamError?.message, + }) + ); + return; + } + if (!ctx.emittedRoleChunk) { // Edge case: empty response. Emit a role chunk so clients see at least // one delta before finish. @@ -1565,18 +1629,34 @@ export class CursorExecutor extends BaseExecutor { */ private buildResponseFromCtx(ctx: StreamCtx, body: { messages?: ChatMessage[] }): Response { if (ctx.midStreamError && ctx.totalText.length === 0) { + const classified = classifyCursorError(ctx.midStreamError.message); return new Response( JSON.stringify({ error: { - message: ctx.midStreamError.message, - type: - ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED - ? "rate_limit_error" - : "api_error", + message: classified.message, + type: classified.type, }, }), { - status: ctx.midStreamError.status, + status: classified.status, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (isCursorEmptyTurn(ctx) && ctx.endReason && ctx.endReason !== "tool_calls") { + const empty = resolveCursorEmptyTurnError({ + upstreamMessage: ctx.midStreamError?.message, + }); + return new Response( + JSON.stringify({ + error: { + message: empty.message, + type: empty.type, + }, + }), + { + status: empty.status, headers: { "Content-Type": "application/json" }, } ); @@ -1655,8 +1735,23 @@ export class CursorExecutor extends BaseExecutor { ); } - async refreshCredentials() { - return null; + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) { + log?.warn?.( + "TOKEN_REFRESH", + "Cursor: no refresh token available, re-authentication required" + ); + return null; + } + const result = await getAccessToken("cursor", credentials, log); + if (!result || result.error) { + log?.warn?.( + "TOKEN_REFRESH", + `Cursor: token refresh failed${result?.error ? ` (${result.error})` : ""} — re-authentication required` + ); + return null; + } + return result; } } diff --git a/open-sse/executors/cursor/cursorErrors.ts b/open-sse/executors/cursor/cursorErrors.ts new file mode 100644 index 0000000000..9d05eaab6d --- /dev/null +++ b/open-sse/executors/cursor/cursorErrors.ts @@ -0,0 +1,269 @@ +/** + * Classify Cursor transport / Connect / gRPC error text into actionable categories. + * Modeled on OpenCodex `adapters/cursor/cursor-errors.ts` (safe messages + quota vs size). + */ + +const ABSOLUTE_PATH_PATTERN = + /(?:\/Users\/[^ "';,]+|\/home\/[^ "';,]+|[A-Za-z]:\\Users\\[^ "';,]+)/g; +const CURSOR_CREDENTIAL_PATTERN = + /\b(authorization|auth[_-]?token|cursor[_-]?token|bearer)=([^&\s"',;]+)/gi; + +const QUOTA_RATE_CUES = [ + "too many requests", + "quota", + "rate limit", + "rate-limit", + "throttl", + "out of usage", + "increase limits", + "actionrequired", +]; +const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [ + "tool catalog too large", + "tool registration too large", + "too many tools", + "message too large", + "payload too large", + "request too large", + /request exceeds .*size/, + /request (?:body|size) exceeds .*(?:size|limit)/, + "maximum allowed size", +]; + +export type CursorErrorKind = + "rate_limit" | "auth" | "invalid" | "overload" | "timeout" | "connection" | "upstream"; + +export type ClassifiedCursorError = { + kind: CursorErrorKind; + /** HTTP status to surface to OmniRoute clients. */ + status: number; + /** OpenAI-style error.type */ + type: string; + /** Secret-safe user-facing message with category prefix. */ + message: string; +}; + +function sanitize(value: string): string { + return value + .replace(CURSOR_CREDENTIAL_PATTERN, "$1=[REDACTED]") + .replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]") + .replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[REDACTED_JWT]"); +} + +export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean { + if (QUOTA_RATE_CUES.some((cue) => lowerMessage.includes(cue))) return false; + return REQUEST_TOO_LARGE_PATTERNS.some((pattern) => + typeof pattern === "string" ? lowerMessage.includes(pattern) : pattern.test(lowerMessage) + ); +} + +function errorMessage(value: unknown): string { + if (value instanceof Error) return value.message; + if (typeof value === "string") return value; + return String(value ?? ""); +} + +function errorCode(value: unknown): string { + if (typeof value !== "object" || !value || !("code" in value)) return ""; + const code = (value as { code?: unknown }).code; + return code === undefined || code === null ? "" : String(code); +} + +/** + * True when Cursor intentionally cancelled the HTTP/2 stream after a client-tool + * suspend (OpenCodex `isCursorBenignCancelError`). Not an upstream failure. + */ +export function isCursorBenignCancelError(value: unknown): boolean { + const message = errorMessage(value).toLowerCase(); + const code = errorCode(value).toUpperCase(); + if (code === "NGHTTP2_CANCEL") return true; + if (message.includes("nghttp2_cancel")) return true; + if (message.includes("cursor stream suspended")) return true; + return false; +} + +export function classifyCursorErrorKind(rawMessage: string): CursorErrorKind { + const lower = rawMessage.toLowerCase(); + + if (lower.includes("resource_exhausted") || lower.includes("resource exhausted")) { + return isCursorRequestTooLargeDetail(lower) ? "invalid" : "rate_limit"; + } + if (QUOTA_RATE_CUES.some((cue) => lower.includes(cue))) return "rate_limit"; + + // Live Cursor out-of-usage for premium models often surfaces as: + // not_found: AI Model Not Found (reset after 109h …) + // OmniRoute may also append "(reset after …)" after classification; treat the + // Cursor-specific "AI Model Not Found" cue as rate/quota either way. + if ( + lower.includes("ai model not found") || + (lower.includes("reset after") && lower.includes("model not found")) + ) { + return "rate_limit"; + } + + if ( + lower.includes("unauthenticated") || + lower.includes("unauthorized") || + lower.includes("permission_denied") || + lower.includes("permission denied") || + lower.includes("forbidden") || + lower.includes("invalid token") || + lower.includes("expired token") || + lower.includes("authentication") || + lower.includes("access denied") + ) { + return "auth"; + } + + if ( + lower.includes("unavailable") || + lower.includes("overloaded") || + lower.includes("temporarily") || + lower.includes("server is busy") + ) { + return "overload"; + } + + if ( + lower.includes("invalid") || + lower.includes("not found") || + lower.includes("unsupported") || + lower.includes("malformed") || + lower.includes("unimplemented") + ) { + return "invalid"; + } + + if ( + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("etimedout") || + lower.includes("deadline") + ) { + return "timeout"; + } + + if ( + lower.includes("econnreset") || + lower.includes("econnrefused") || + lower.includes("goaway") || + lower.includes("nghttp2") || + lower.includes("socket hang up") || + lower.includes("connection reset") + ) { + return "connection"; + } + + return "upstream"; +} + +function kindToStatus(kind: CursorErrorKind): number { + switch (kind) { + case "rate_limit": + return 429; + case "auth": + return 401; + case "invalid": + return 400; + case "overload": + case "timeout": + case "connection": + case "upstream": + default: + return 502; + } +} + +function kindToType(kind: CursorErrorKind): string { + switch (kind) { + case "rate_limit": + return "rate_limit_error"; + case "auth": + return "authentication_error"; + case "invalid": + return "invalid_request_error"; + default: + return "api_error"; + } +} + +function kindPrefix(kind: CursorErrorKind): string { + switch (kind) { + case "rate_limit": + return "Cursor rate limit / usage exceeded"; + case "auth": + return "Cursor authentication failed"; + case "invalid": + return "Cursor invalid request"; + case "overload": + return "Cursor server overloaded"; + case "timeout": + return "Cursor request timed out"; + case "connection": + return "Cursor connection failed"; + default: + return "Cursor upstream error"; + } +} + +/** Produce a classified, secret-safe Cursor error for HTTP / SSE responses. */ +export function classifyCursorError(rawMessage: string): ClassifiedCursorError { + const kind = classifyCursorErrorKind(rawMessage); + const detail = sanitize(rawMessage) + .replace(/resource[_ ]exhausted/gi, "resource limit exceeded") + .slice(0, 500); + const prefix = kindPrefix(kind); + const message = detail.startsWith(prefix) ? detail : detail ? `${prefix}: ${detail}` : prefix; + return { + kind, + status: kindToStatus(kind), + type: kindToType(kind), + message, + }; +} + +export const CURSOR_EMPTY_TURN_MESSAGE = + 'Cursor returned an empty turn (often usage/quota exhausted). Try model "auto", or check Usage → Provider Limits / raise Cursor limits.'; + +/** + * Resolve the error to emit when a Cursor turn ends with no assistant text/tool_calls. + * Prefer classifying an upstream JSON/error message; otherwise use the empty-turn hint. + * When `quotaExhaustedHint` is true (fresh Provider Limits cache), force 429. + */ +export function resolveCursorEmptyTurnError(options: { + upstreamMessage?: string | null; + quotaExhaustedHint?: boolean; +}): ClassifiedCursorError { + const upstream = options.upstreamMessage?.trim(); + if (upstream) { + const classified = classifyCursorError(upstream); + if (options.quotaExhaustedHint && classified.kind !== "auth") { + return { + ...classified, + kind: "rate_limit", + status: 429, + type: "rate_limit_error", + message: classified.message.includes("usage") + ? classified.message + : `${classified.message} (${CURSOR_EMPTY_TURN_MESSAGE})`, + }; + } + return classified; + } + + if (options.quotaExhaustedHint) { + return { + kind: "rate_limit", + status: 429, + type: "rate_limit_error", + message: CURSOR_EMPTY_TURN_MESSAGE, + }; + } + + return { + kind: "upstream", + status: 502, + type: "api_error", + message: CURSOR_EMPTY_TURN_MESSAGE, + }; +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 2ca3c5df62..893496846d 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -48,6 +48,7 @@ import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts"; +import { refreshCursorToken } from "./tokenRefresh/providers/cursor.ts"; import { refreshOpenferenceToken } from "./tokenRefresh/providers/openference.ts"; import { refreshKiroToken } from "./tokenRefresh/providers/kiro.ts"; import { refreshQoderToken } from "./tokenRefresh/providers/qoder.ts"; @@ -62,6 +63,7 @@ export { refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, + refreshCursorToken, refreshOpenferenceToken, refreshKiroToken, refreshQoderToken, @@ -382,6 +384,12 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "codex": return await refreshCodexToken(credentials.refreshToken, log, proxyConfig); + case "cursor": + if (!credentials.refreshToken) { + return { error: "unrecoverable_refresh_error", code: "no_refresh_token" }; + } + return await refreshCursorToken(credentials.refreshToken, log, proxyConfig); + case "openference": return await refreshOpenferenceToken(credentials.refreshToken, log, proxyConfig); @@ -453,6 +461,7 @@ export function supportsTokenRefresh(provider) { // testStatus="expired" / errorCode="no_refresh_token". "gitlab-duo", "codebuddy-cn", + "cursor", ]); if (explicitlySupported.has(provider)) return true; const config = PROVIDERS[provider]; diff --git a/open-sse/services/tokenRefresh/providers/cursor.ts b/open-sse/services/tokenRefresh/providers/cursor.ts new file mode 100644 index 0000000000..69a146b07c --- /dev/null +++ b/open-sse/services/tokenRefresh/providers/cursor.ts @@ -0,0 +1,115 @@ +/** + * Cursor OAuth token refresh via api2.cursor.sh/auth/exchange_user_api_key. + * OpenCodex-compatible (Bearer refresh token, JSON body `{}`). + * Self-contained in open-sse (no import from src/). + */ + +const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key"; +const REFRESH_TIMEOUT_MS = 15_000; +const REFRESH_ATTEMPTS = 3; +const REFRESH_RETRY_BASE_MS = 300; +const EXPIRY_SKEW_MS = 5 * 60 * 1000; +const FALLBACK_TTL_MS = 60 * 60 * 1000; + +function isRetryableRefreshStatus(status: number): boolean { + return status === 429 || status === 500 || status === 502 || status === 503 || status === 504; +} + +function refreshRetryDelayMs(attempt: number, baseMs: number): number { + const exp = baseMs * 2 ** attempt; + return Math.floor(exp * (0.8 + Math.random() * 0.4)); +} + +function decodeExpMs(token: string): number { + try { + const parts = token.split("."); + if (parts.length !== 3) return Date.now() + FALLBACK_TTL_MS; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8")) as { + exp?: unknown; + }; + if (typeof payload.exp === "number") return payload.exp * 1000 - EXPIRY_SKEW_MS; + } catch { + /* ignore */ + } + return Date.now() + FALLBACK_TTL_MS; +} + +export type RefreshCursorTokenOptions = { + retryBaseMs?: number; + attempts?: number; +}; + +/** + * @returns {{ accessToken, refreshToken, expiresAt } | { error, code } | null} + */ +export async function refreshCursorToken( + refreshToken: string, + log?: { error?: (...args: unknown[]) => void; info?: (...args: unknown[]) => void }, + _proxyConfig: unknown = null, + options: RefreshCursorTokenOptions = {} +) { + if (!refreshToken) { + return { error: "unrecoverable_refresh_error", code: "no_refresh_token" }; + } + + const attempts = options.attempts ?? REFRESH_ATTEMPTS; + const retryBaseMs = options.retryBaseMs ?? REFRESH_RETRY_BASE_MS; + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt++) { + let response: Response; + try { + response = await fetch(CURSOR_REFRESH_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${refreshToken}`, + "Content-Type": "application/json", + }, + body: "{}", + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), + }); + } catch (err) { + lastError = err; + if (attempt === attempts - 1) break; + await new Promise((r) => setTimeout(r, refreshRetryDelayMs(attempt, retryBaseMs))); + continue; + } + + if (response.ok) { + const data = (await response.json()) as { accessToken?: string; refreshToken?: string }; + if (!data.accessToken) { + log?.error?.("TOKEN_REFRESH", "Cursor refresh response missing access token"); + return null; + } + const nextRefresh = data.refreshToken || refreshToken; + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cursor token"); + return { + accessToken: data.accessToken, + refreshToken: nextRefresh, + expiresAt: new Date(decodeExpMs(data.accessToken)).toISOString(), + }; + } + + if (response.status === 401 || response.status === 403) { + log?.error?.("TOKEN_REFRESH", "Cursor refresh rejected — re-authentication required", { + status: response.status, + }); + return { error: "unrecoverable_refresh_error", code: "unauthorized" }; + } + + if (!isRetryableRefreshStatus(response.status) || attempt === attempts - 1) { + log?.error?.("TOKEN_REFRESH", "Failed to refresh Cursor token", { status: response.status }); + return null; + } + + lastError = new Error(`Cursor token refresh failed: ${response.status}`); + await response.body?.cancel().catch(() => {}); + await new Promise((r) => setTimeout(r, refreshRetryDelayMs(attempt, retryBaseMs))); + } + + log?.error?.( + "TOKEN_REFRESH", + lastError instanceof Error ? lastError.message : "Cursor token refresh failed" + ); + return null; +} diff --git a/open-sse/services/usage/cursor.ts b/open-sse/services/usage/cursor.ts index 67acc09a6a..a0d15ee0b1 100644 --- a/open-sse/services/usage/cursor.ts +++ b/open-sse/services/usage/cursor.ts @@ -1,21 +1,24 @@ /** * usage/cursor.ts — Cursor (Pro) usage fetcher + JWT/config helpers. * - * Extracted from services/usage.ts (god-file decomposition): the Cursor family — the - * dashboard usage-API config, the WorkOS JWT `sub` decoder, and the getCursorUsage fetcher - * that probes the cursor.com/dashboard/spending endpoint. Depends only on the sibling - * scalar/quota leaves — no host coupling — so it lives as a co-located provider leaf. - * usage.ts imports getCursorUsage (dispatcher). Behavior-preserving move. + * Prefer Bearer APIs on api2.cursor.sh (works with deep-control PKCE JWTs). + * Fall back to the cookie-based cursor.com dashboard endpoint for IDE-imported + * WorkOS sessions. OpenCodex-compatible chain: + * GetCurrentPeriodUsage → /api/usage/summary → /auth/usage → cookie dashboard. */ import { toRecord, toNumber, clampPercentage } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; -// Cursor dashboard usage API config -// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS -// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and -// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request). -const CURSOR_USAGE_CONFIG = { +const REQUEST_TIMEOUT_MS = 12_000; + +const CURSOR_API2 = "https://api2.cursor.sh"; +const CURSOR_PERIOD_USAGE_URL = `${CURSOR_API2}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`; +const CURSOR_USAGE_SUMMARY_URL = `${CURSOR_API2}/api/usage/summary`; +const CURSOR_AUTH_USAGE_URL = `${CURSOR_API2}/auth/usage`; + +/** Legacy IDE/session cookie path (last resort). */ +const CURSOR_COOKIE_USAGE_CONFIG = { usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage", origin: "https://cursor.com", referer: "https://cursor.com/dashboard/spending", @@ -23,11 +26,19 @@ const CURSOR_USAGE_CONFIG = { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }; +const REAUTH_HINT = "Use Cursor Login (PKCE) or re-import the connection from Cursor IDE."; + +export type CursorUsageResult = { + plan?: string; + quotas?: Record; + message?: string; +}; + /** * Decode the `sub` claim of a Cursor JWT (the WorkOS user id). * Returns null if the token is not a parseable JWT. */ -function decodeCursorJwtSub(token: string): string | null { +export function decodeCursorJwtSub(token: string): string | null { if (!token || typeof token !== "string") return null; const parts = token.split("."); if (parts.length !== 3) return null; @@ -42,14 +53,292 @@ function decodeCursorJwtSub(token: string): string | null { } } +function bearerHeaders(accessToken: string): Record { + return { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": "omniroute-cursor-quota", + }; +} + +function toDollars(cents: number): number { + return Math.round(cents) / 100; +} + +function buildPlanUsageQuotas( + planUsage: Record, + billingCycleEnd: unknown +): Record | null { + const limitCents = Math.max( + 0, + toNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents, 0) + ); + const includedSpendRaw = toNumber( + planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used, + NaN + ); + const totalSpendCents = Number.isFinite(includedSpendRaw) + ? Math.max(0, includedSpendRaw) + : Math.max(0, toNumber(planUsage.totalSpend, 0)); + + const rawTotalPct = toNumber(planUsage.totalPercentUsed ?? planUsage.percentUsed, NaN); + let totalPercentUsed: number; + if (Number.isFinite(rawTotalPct)) { + totalPercentUsed = clampPercentage(rawTotalPct); + } else if (limitCents > 0) { + totalPercentUsed = clampPercentage((totalSpendCents / limitCents) * 100); + } else { + return null; + } + + const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0)); + const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0)); + const effectiveLimitCents = limitCents > 0 ? limitCents : 100; + + const billingCycleEndMs = toNumber(billingCycleEnd, 0); + const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null; + const limitDollars = toDollars(effectiveLimitCents); + + const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => { + const usedCents = + typeof usedCentsOverride === "number" + ? usedCentsOverride + : Math.round((effectiveLimitCents * percentUsed) / 100); + const clampedUsed = Math.min(usedCents, effectiveLimitCents); + return { + used: toDollars(clampedUsed), + total: limitDollars, + remaining: toDollars(Math.max(effectiveLimitCents - clampedUsed, 0)), + remainingPercentage: clampPercentage(100 - percentUsed), + resetAt, + unlimited: false, + }; + }; + + return { + Total: buildWindow(totalPercentUsed, limitCents > 0 ? totalSpendCents : undefined), + "Auto + Composer": buildWindow(autoPercentUsed), + API: buildWindow(apiPercentUsed), + }; +} + +async function fetchJson( + url: string, + init: RequestInit +): Promise<{ ok: true; data: Record } | { ok: false }> { + try { + const response = await fetch(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false }; + const data = toRecord(await response.json().catch(() => null)); + if (Object.keys(data).length === 0) return { ok: false }; + return { ok: true, data }; + } catch { + return { ok: false }; + } +} + +function tryPeriodUsage(data: Record): CursorUsageResult | null { + const planUsage = toRecord(data.planUsage); + if (Object.keys(planUsage).length === 0) return null; + const quotas = buildPlanUsageQuotas(planUsage, data.billingCycleEnd ?? planUsage.billingCycleEnd); + if (!quotas) return null; + return { plan: "Cursor Pro", quotas }; +} + +function tryUsageSummary(data: Record): CursorUsageResult | null { + const individual = toRecord(data.individualUsage); + const plan = toRecord(individual.plan); + if (Object.keys(plan).length === 0) return null; + const used = toNumber(plan.used, NaN); + const limit = toNumber(plan.limit, NaN); + const percent = clampPercentage( + toNumber( + plan.totalPercentUsed, + Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? (used / limit) * 100 : NaN + ) + ); + if ( + !Number.isFinite(toNumber(plan.totalPercentUsed, NaN)) && + !(Number.isFinite(used) && limit > 0) + ) { + return null; + } + const quotas = buildPlanUsageQuotas( + { + limit: Number.isFinite(limit) ? limit : 100, + totalSpend: Number.isFinite(used) ? used : Math.round(percent), + totalPercentUsed: percent, + autoPercentUsed: percent, + apiPercentUsed: 0, + }, + data.billingCycleEnd + ); + if (!quotas) return null; + return { plan: "Cursor Pro", quotas }; +} + +function tryAuthUsage(data: Record): CursorUsageResult | null { + let used: number | undefined; + let limit: number | undefined; + const gpt4 = toRecord(data["gpt-4"]); + if (Object.keys(gpt4).length > 0) { + used = toNumber(gpt4.numRequests ?? gpt4.used, NaN); + limit = toNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests, NaN); + } + if (!Number.isFinite(used) || !Number.isFinite(limit) || (limit as number) <= 0) { + for (const [key, value] of Object.entries(data)) { + if (key === "startOfMonth" || key === "billingCycleStart") continue; + const bucket = toRecord(value); + if (Object.keys(bucket).length === 0) continue; + const bucketUsed = toNumber(bucket.numRequests ?? bucket.used, NaN); + const bucketLimit = toNumber( + bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests, + NaN + ); + if (Number.isFinite(bucketUsed) && Number.isFinite(bucketLimit) && bucketLimit > 0) { + used = bucketUsed; + limit = bucketLimit; + break; + } + } + } + if (!Number.isFinite(used) || !Number.isFinite(limit) || (limit as number) <= 0) return null; + const percent = clampPercentage(((used as number) / (limit as number)) * 100); + const startOfMonth = parseResetTime(data.startOfMonth ?? data.billingCycleStart); + let monthlyResetAt: string | null = null; + if (startOfMonth) { + const start = new Date(startOfMonth); + monthlyResetAt = new Date( + Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate()) + ).toISOString(); + } + const limitNum = limit as number; + const usedNum = used as number; + return { + plan: "Cursor Pro", + quotas: { + Total: { + used: usedNum, + total: limitNum, + remaining: Math.max(limitNum - usedNum, 0), + remainingPercentage: clampPercentage(100 - percent), + resetAt: monthlyResetAt, + unlimited: false, + }, + }, + }; +} + +async function fetchCookieDashboardUsage( + accessToken: string, + userId: string +): Promise { + try { + const response = await fetch(CURSOR_COOKIE_USAGE_CONFIG.usageUrl, { + method: "POST", + redirect: "manual", + headers: { + Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`, + Origin: CURSOR_COOKIE_USAGE_CONFIG.origin, + Referer: CURSOR_COOKIE_USAGE_CONFIG.referer, + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": CURSOR_COOKIE_USAGE_CONFIG.userAgent, + }, + body: "{}", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (response.status >= 300 && response.status < 400) { + return { + plan: "Cursor", + message: `Cursor session expired. ${REAUTH_HINT}`, + }; + } + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { + plan: "Cursor", + message: `Cursor session unauthorized. ${REAUTH_HINT}`, + }; + } + return { + plan: "Cursor", + message: `Cursor usage endpoint error (${response.status}). ${REAUTH_HINT}`, + }; + } + + const data = toRecord(await response.json()); + const planUsage = toRecord(data.planUsage); + if (Object.keys(planUsage).length === 0) { + return { + plan: "Cursor", + message: "Cursor connected. No active plan usage returned.", + }; + } + const quotas = buildPlanUsageQuotas(planUsage, data.billingCycleEnd); + if (!quotas) { + return { + plan: "Cursor", + message: "Cursor connected. No active plan usage returned.", + }; + } + return { plan: "Cursor Pro", quotas }; + } catch (error) { + return { + plan: "Cursor", + message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`, + }; + } +} + /** - * Cursor Pro Plan Usage - * Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three - * windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API. + * Cursor Pro Plan Usage — Bearer APIs first (PKCE), cookie dashboard last (IDE import). */ -export async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) { +export async function getCursorUsage( + accessToken: string, + providerSpecificData?: unknown +): Promise { if (!accessToken) { - return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." }; + return { message: `Cursor access token missing. ${REAUTH_HINT}` }; + } + + const auth = bearerHeaders(accessToken); + + const period = await fetchJson(CURSOR_PERIOD_USAGE_URL, { + method: "POST", + headers: { + ...auth, + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + }, + body: "{}", + }); + if (period.ok) { + const mapped = tryPeriodUsage(period.data); + if (mapped) return mapped; + } + + const summary = await fetchJson(CURSOR_USAGE_SUMMARY_URL, { + method: "GET", + headers: auth, + }); + if (summary.ok) { + const mapped = tryUsageSummary(summary.data); + if (mapped) return mapped; + } + + const authUsage = await fetchJson(CURSOR_AUTH_USAGE_URL, { + method: "GET", + headers: auth, + }); + if (authUsage.ok) { + const mapped = tryAuthUsage(authUsage.data); + if (mapped) return mapped; } const storedUserId = (() => { @@ -59,103 +348,11 @@ export async function getCursorUsage(accessToken: string, providerSpecificData?: const userId = storedUserId || decodeCursorJwtSub(accessToken); if (!userId) { - return { - message: "Cursor token missing user id. Re-import the connection from Cursor IDE.", - }; - } - - try { - const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, { - method: "POST", - redirect: "manual", - headers: { - Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`, - Origin: CURSOR_USAGE_CONFIG.origin, - Referer: CURSOR_USAGE_CONFIG.referer, - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": CURSOR_USAGE_CONFIG.userAgent, - }, - body: "{}", - }); - - // 3xx redirect to WorkOS authkit means the session cookie was rejected. - if (response.status >= 300 && response.status < 400) { - return { - plan: "Cursor", - message: "Cursor session expired. Re-import the token from Cursor IDE.", - }; - } - - if (!response.ok) { - const errorText = (await response.text()).slice(0, 200); - if (response.status === 401 || response.status === 403) { - return { - plan: "Cursor", - message: "Cursor session unauthorized. Re-import the token from Cursor IDE.", - }; - } - return { - plan: "Cursor", - message: `Cursor usage endpoint error (${response.status}): ${errorText}`, - }; - } - - const data = toRecord(await response.json()); - const planUsage = toRecord(data.planUsage); - - if (Object.keys(planUsage).length === 0) { - return { - plan: "Cursor", - message: "Cursor connected. No active plan usage returned.", - }; - } - - const limitCents = Math.max(0, toNumber(planUsage.limit, 0)); - const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0)); - const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0)); - const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0)); - const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0)); - - // billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number. - const billingCycleEndMs = toNumber(data.billingCycleEnd, 0); - const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null; - - // Convert cents → dollars rounded to 2 decimal places. - const toDollars = (cents: number) => Math.round(cents) / 100; - - const limitDollars = toDollars(limitCents); - const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => { - const usedCents = - typeof usedCentsOverride === "number" - ? usedCentsOverride - : Math.round((limitCents * percentUsed) / 100); - const used = toDollars(Math.min(usedCents, limitCents)); - const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0)); - return { - used, - total: limitDollars, - remaining, - remainingPercentage: clampPercentage(100 - percentUsed), - resetAt, - unlimited: false, - }; - }; - - const quotas: Record = { - Total: buildWindow(totalPercentUsed, totalSpendCents), - "Auto + Composer": buildWindow(autoPercentUsed), - API: buildWindow(apiPercentUsed), - }; - - return { - plan: "Cursor Pro", - quotas, - }; - } catch (error) { return { plan: "Cursor", - message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`, + message: `Cursor usage unavailable via API and token has no user id for cookie fallback. ${REAUTH_HINT}`, }; } + + return fetchCookieDashboardUsage(accessToken, userId); } diff --git a/open-sse/utils/cursorAgentCliVersion.ts b/open-sse/utils/cursorAgentCliVersion.ts index 2a65df051b..91bedd2206 100644 --- a/open-sse/utils/cursorAgentCliVersion.ts +++ b/open-sse/utils/cursorAgentCliVersion.ts @@ -4,10 +4,19 @@ * Wire header: `x-cursor-client-version: cli-${id}` where `id` is a dated * build like `2026.07.08-0c04a8a` (not the IDE `3.x` semver). * - * Resolution: CURSOR_AGENT_CLI_VERSION env → local install detect → pin. + * Resolution: CURSOR_AGENT_CLI_VERSION env → local install detect → + * disk-cached installer scrape (stale-while-revalidate) → pin. */ -import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -19,9 +28,19 @@ export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a"; const VERSION_ID_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/; const CACHE_TTL_MS = 60 * 60 * 1000; +const INSTALL_URL = "https://cursor.com/install"; +const REMOTE_TIMEOUT_MS = 5_000; +const VERSION_CACHE_FILE = "cursor-agent-cli-version.json"; let cachedVersion: string | null = null; let cachedAt = 0; +let remoteRefreshInFlight: Promise | null = null; +let remoteRefreshScheduled = false; + +/** Test seam: override fetch for installer scrape. */ +let fetchImpl: typeof fetch = fetch; +/** Test seam: override disk cache directory. */ +let cacheDirOverride: string | null = null; export function isCursorAgentCliVersionId(value: string): boolean { return VERSION_ID_RE.test(value); @@ -43,17 +62,26 @@ export function extractVersionIdFromResolvedPath(resolvedPath: string): string | export function newestVersionInDir(versionsDir: string): string | null { try { if (!existsSync(versionsDir)) return null; - const matches = readdirSync(versionsDir) - .filter((name) => { - if (!isCursorAgentCliVersionId(name)) return false; - try { - return lstatSync(join(versionsDir, name)).isDirectory(); - } catch { - return false; + // Prefer newest mtime (oakimov), break ties with lexicographic id. + let newest: { name: string; mtimeMs: number } | null = null; + for (const name of readdirSync(versionsDir)) { + if (!isCursorAgentCliVersionId(name)) continue; + try { + const st = lstatSync(join(versionsDir, name)); + if (!st.isDirectory()) continue; + const mtimeMs = st.mtimeMs; + if ( + !newest || + mtimeMs > newest.mtimeMs || + (mtimeMs === newest.mtimeMs && name > newest.name) + ) { + newest = { name, mtimeMs }; } - }) - .sort(); - return matches.length > 0 ? matches[matches.length - 1] : null; + } catch { + /* skip vanished entries */ + } + } + return newest?.name ?? null; } catch { return null; } @@ -93,6 +121,80 @@ export function detectCursorAgentCliVersionFromFs(home: string = homedir()): str return newestVersionInDir(versionsDir); } +type DiskVersionCache = { version: string; fetchedAt: number }; + +function resolveCacheDir(): string { + if (cacheDirOverride) return cacheDirOverride; + const dataDir = process.env.DATA_DIR?.trim(); + if (dataDir) return join(dataDir, "cache"); + return join(homedir(), ".omniroute", "cache"); +} + +function versionCachePath(): string { + return join(resolveCacheDir(), VERSION_CACHE_FILE); +} + +export function extractVersionIdFromInstallerScript(script: string): string | null { + const match = script.match(/downloads\.cursor\.com\/lab\/([^/"'\s]+)\//); + if (!match) return null; + const id = match[1]; + return isCursorAgentCliVersionId(id) ? id : null; +} + +function readDiskVersionCache(): DiskVersionCache | null { + try { + const raw = JSON.parse(readFileSync(versionCachePath(), "utf8")) as Record; + if (typeof raw.version !== "string" || !isCursorAgentCliVersionId(raw.version)) return null; + if (typeof raw.fetchedAt !== "number" || !Number.isFinite(raw.fetchedAt)) return null; + return { version: raw.version, fetchedAt: raw.fetchedAt }; + } catch { + return null; + } +} + +function writeDiskVersionCache(cache: DiskVersionCache): void { + try { + const dir = resolveCacheDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(versionCachePath(), JSON.stringify(cache, null, 2)); + } catch { + // Cache writes are best-effort. + } +} + +async function fetchInstallerVersionId(): Promise { + const response = await fetchImpl(INSTALL_URL, { + signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS), + }); + if (!response.ok) return null; + const text = await response.text(); + return extractVersionIdFromInstallerScript(text); +} + +function scheduleRemoteVersionRefresh(): void { + if (remoteRefreshInFlight || remoteRefreshScheduled) return; + // Defer so sync header resolution never starts network in the same turn. + remoteRefreshScheduled = true; + setTimeout(() => { + remoteRefreshScheduled = false; + if (remoteRefreshInFlight) return; + remoteRefreshInFlight = (async () => { + try { + const id = await fetchInstallerVersionId(); + if (id) writeDiskVersionCache({ version: id, fetchedAt: Date.now() }); + } catch { + // Ignore — pin / stale cache remain valid. + } finally { + remoteRefreshInFlight = null; + } + })(); + }, 0); +} + +/** + * Resolve CLI build id synchronously for request headers. + * Env → local FS → disk cache (refresh in background if stale) → pin. + */ export function getCursorAgentCliVersion(): string { const now = Date.now(); if (cachedVersion && now - cachedAt < CACHE_TTL_MS) { @@ -114,11 +216,57 @@ export function getCursorAgentCliVersion(): string { return cachedVersion; } + const disk = readDiskVersionCache(); + if (disk) { + cachedVersion = disk.version; + cachedAt = now; + // Stale-while-revalidate (oakimov): always serve disk cache; refresh in + // background when fresh (keep warm) or stale. + scheduleRemoteVersionRefresh(); + return cachedVersion; + } + + scheduleRemoteVersionRefresh(); return CURSOR_AGENT_CLI_VERSION; } +/** + * Await a remote installer scrape (tests / warm-up). Writes disk cache on success. + */ +export async function refreshCursorAgentCliVersionFromInstaller(): Promise { + try { + const id = await fetchInstallerVersionId(); + if (id) { + writeDiskVersionCache({ version: id, fetchedAt: Date.now() }); + cachedVersion = id; + cachedAt = Date.now(); + return id; + } + } catch { + /* ignore */ + } + return null; +} + /** Exposed for testing: reset the in-memory cache. */ export function resetCursorAgentCliVersionCache(): void { cachedVersion = null; cachedAt = 0; + remoteRefreshInFlight = null; + remoteRefreshScheduled = false; +} + +/** Exposed for testing: inject fetch + cache dir. */ +export function configureCursorAgentCliVersionForTests(options: { + fetchImpl?: typeof fetch; + cacheDir?: string | null; +}): void { + if (options.fetchImpl) fetchImpl = options.fetchImpl; + if (options.cacheDir !== undefined) cacheDirOverride = options.cacheDir; +} + +export function resetCursorAgentCliVersionTestHooks(): void { + fetchImpl = fetch; + cacheDirOverride = null; + resetCursorAgentCliVersionCache(); } diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index d86fc7f4ce..21164b6ed1 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -285,6 +285,12 @@ const CURSOR_MODEL_ALIASES: Record = { "composer-2-5-fast": "composer-2.5-fast", "composer-2.5-sdk-fast": "composer-2.5-fast", "composer-latest-fast": "composer-2.5-fast", + "grok-4.5-medium": "cursor-grok-4.5-medium", + "grok-4.5-fast-medium": "cursor-grok-4.5-medium-fast", + "grok-4.5-high": "cursor-grok-4.5-high", + "grok-4.5-fast-high": "cursor-grok-4.5-high-fast", + "grok-4.5-xhigh": "cursor-grok-4.5-xhigh", + "grok-4.5-fast-xhigh": "cursor-grok-4.5-xhigh-fast", }; export function normalizeCursorModelId(modelId: string): string { @@ -301,6 +307,10 @@ export function normalizeCursorModelId(modelId: string): string { // {id:"reasoning", value:}. "-fast"/"-thinking" are separate toggles // (already handled elsewhere / not covered by this suffix set) and must not // be misread as an effort value. +// +// Grok (`cursor-grok-*` / legacy `grok-*`) follows the Claude-style `effort` +// parameter. Without the split, ids like `cursor-grok-4.5-high` return empty +// turns (same symptom as #7289). Combined `-high-fast` is supported. const CURSOR_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const; /** @@ -329,6 +339,40 @@ function splitCursorEffortSuffix( return null; } +/** + * Grok family: strip optional `-fast`, then effort suffix → ModelParameters. + * Prefer `cursor-grok-` over bare `grok-` so `cursor-grok-*` is not mis-matched. + */ +function resolveGrokRequestedModel( + normalized: string +): { modelId: string; parameters: Array<{ id: string; value: string }> } | null { + const prefix = normalized.startsWith("cursor-grok-") + ? "cursor-grok-" + : normalized.startsWith("grok-") + ? "grok-" + : null; + if (!prefix) return null; + + let id = normalized; + const extraParams: Array<{ id: string; value: string }> = []; + if (id.endsWith("-fast") && id.length > prefix.length + "-fast".length) { + id = id.slice(0, -"-fast".length); + extraParams.push({ id: "fast", value: "true" }); + } + + const effortSplit = splitCursorEffortSuffix(id, prefix, "effort"); + if (effortSplit) { + return { + modelId: effortSplit.modelId, + parameters: [...effortSplit.parameters, ...extraParams], + }; + } + if (extraParams.length > 0) { + return { modelId: id, parameters: extraParams }; + } + return null; +} + /** * cursor-agent rewrites model ids before putting them on the wire: * "auto" → RequestedModel { model_id: "default" } @@ -340,6 +384,8 @@ function splitCursorEffortSuffix( * parameters: [{id: "effort", value: "high"}] } * "gpt-5.5-high" → RequestedModel { model_id: "gpt-5.5", * parameters: [{id: "reasoning", value: "high"}] } + * "cursor-grok-4.5-high" → RequestedModel { model_id: "cursor-grok-4.5", + * parameters: [{id: "effort", value: "high"}] } * * Other ids are passed through verbatim after spelling-variant normalization * (see normalizeCursorModelId). @@ -398,6 +444,10 @@ export function resolveRequestedModel( parameters: [{ id: "fast", value: "true" }], }; } + const grokSplit = resolveGrokRequestedModel(normalized); + if (grokSplit) { + return grokSplit; + } const claudeSplit = splitCursorEffortSuffix(normalized, "claude-", "effort"); if (claudeSplit) { return claudeSplit; diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 11a6f4e779..0a7e41d5bb 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -573,7 +573,10 @@ export function createNoopAbortWritable(): { * streaming twin of the non-streaming `isEmptyContentResponse` check. Only * applies to bodies that actually looked like SSE, and terminal states where * emptiness is legitimate (length / tool_calls / content_filter / max_tokens / - * tool_use) are excluded by the watcher. + * tool_use) are excluded by the watcher. If the stream already carried a + * substantive SSE `error` / `response.failed` / Claude `event:error`, stand + * down — same spirit as Claude #3685 `lifecycle.hasError` and readiness #8972 + * (do not invent empty content on top of an actionable error). */ type SilentCloseOutcome = { kind: "truncated" } | { kind: "error"; reason: string }; @@ -611,6 +614,7 @@ function resolveSilentCloseOutcome(input: { } const watcher = input.contentWatcher; + if (watcher.sawError()) return null; if (watcher.sawSseFrame() && !watcher.sawContent() && !watcher.sawLegitEmptyTerminal()) { return { kind: "error", reason: "Provider returned empty content" }; } diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index 23f57678e7..2d06659b80 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -167,6 +167,59 @@ const TERMINAL_REASON_PATTERN = /"(?:finish_reason|stop_reason)"\s*:\s*"([^"]+)" const SSE_FIELD_LINE = /(?:^|\r?\n)\s*(?:data|event):/; +/** Same spirit as combo `isSubstantiveError` — non-empty string or non-empty object. */ +function isSubstantiveErrorValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if (hasNonEmptyString(record.message)) return true; + return Object.keys(record).length > 0; + } + return value === true; +} + +/** + * True when an SSE frame already carries a structured upstream/client error + * (OpenAI `error`, Claude `event:error` / `type:error`, Responses `response.failed`). + * Used by #8649 so we do not invent "Provider returned empty content" after an + * executor already emitted an actionable error (Claude #3685 / readiness #8972 parity). + */ +export function frameHasStructuredStreamError(frame: string): boolean { + const lines = frame.split(/\r?\n/); + let eventType = ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(":")) continue; + if (trimmed.startsWith("event:")) { + eventType = trimmed.slice(6).trim(); + if (/^error$/i.test(eventType)) return true; + continue; + } + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (!data || data === "[DONE]") continue; + + try { + const parsed: unknown = JSON.parse(data); + if (!isRecord(parsed)) continue; + const type = getPayloadType(parsed, eventType); + if (type === "error" || type === "response.failed" || eventType === "response.failed") { + return true; + } + if (isSubstantiveErrorValue(parsed.error)) return true; + const nestedResponse = isRecord(parsed.response) ? parsed.response : null; + if (nestedResponse?.status === "failed" && nestedResponse.error != null) return true; + } catch { + // non-JSON data lines are not structured errors + } + } + + return false; +} + export type StreamContentWatcher = { /** Feed a decoded slice of the client-facing stream. Safe to call with partial frames. */ note: (text: string) => void; @@ -183,6 +236,11 @@ export type StreamContentWatcher = { * so callers must not read emptiness into it. */ sawSseFrame: () => boolean; + /** + * True once a substantive SSE error frame was seen. Separate from sawContent + * so #8649 can stand down without treating errors as model output. + */ + sawError: () => boolean; }; /** @@ -195,6 +253,9 @@ export type StreamContentWatcher = { * single frame larger than the cap is scanned in pieces, which can only ever * lose content-detection precision in the direction of "saw content", never * toward a false empty. + * + * Also tracks `sawError` so an already-emitted structured error is not rewritten + * as empty content (parity with Claude #3685 `lifecycle.hasError` and readiness #8972). */ export function createStreamContentWatcher(): StreamContentWatcher { const MAX_BUFFERED = 64 * 1024; @@ -202,10 +263,12 @@ export function createStreamContentWatcher(): StreamContentWatcher { let content = false; let legitEmpty = false; let sse = false; + let error = false; const inspect = (frame: string): void => { if (!frame) return; if (!sse && SSE_FIELD_LINE.test(frame)) sse = true; + if (!error && frameHasStructuredStreamError(frame)) error = true; if (!content && hasUsefulStreamContent(frame)) content = true; if (legitEmpty) return; for (const match of frame.matchAll(TERMINAL_REASON_PATTERN)) { @@ -238,6 +301,7 @@ export function createStreamContentWatcher(): StreamContentWatcher { sawContent: () => content, sawLegitEmptyTerminal: () => legitEmpty, sawSseFrame: () => sse, + sawError: () => error, }; } @@ -262,11 +326,7 @@ function processStreamReadinessEvent(state: StreamReadinessSignalState): boolean try { const payload: unknown = JSON.parse(data); - if ( - !state.upstreamDiagnostic && - isRecord(payload) && - isErrorOnlyStructuredPayload(payload) - ) { + if (!state.upstreamDiagnostic && isRecord(payload) && isErrorOnlyStructuredPayload(payload)) { const error = payload.error; const rawMessage = typeof error === "string" diff --git a/scripts/ad-hoc/sync-cursor-models.mjs b/scripts/ad-hoc/sync-cursor-models.mjs index 48698d016a..62f30bb718 100644 --- a/scripts/ad-hoc/sync-cursor-models.mjs +++ b/scripts/ad-hoc/sync-cursor-models.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node -// Sync the cursor models list in open-sse/config/providerRegistry.ts from -// cursor-agent's runtime model list. Triggers an intentional invalid --model -// invocation so cursor-agent prints "Available models: ..." on stderr. +// Sync the cursor models list in open-sse/config/providers/registry/cursor/index.ts +// from cursor-agent's runtime model list (`--list-models`). // // Usage: // node scripts/ad-hoc/sync-cursor-models.mjs # spawn cursor-agent and apply // node scripts/ad-hoc/sync-cursor-models.mjs --dry-run # print proposed block, don't write -// node scripts/ad-hoc/sync-cursor-models.mjs --from-stdin # read the error message from stdin +// node scripts/ad-hoc/sync-cursor-models.mjs --from-stdin # read --list-models output from stdin import { spawnSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; @@ -14,7 +13,17 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts"); +const REGISTRY_PATH = resolve( + __dirname, + "..", + "..", + "open-sse", + "config", + "providers", + "registry", + "cursor", + "index.ts" +); const args = new Set(process.argv.slice(2)); const DRY_RUN = args.has("--dry-run"); diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts index ee0ee963f7..c7ba57c419 100755 --- a/src/app/api/oauth/cursor/auto-import/route.ts +++ b/src/app/api/oauth/cursor/auto-import/route.ts @@ -24,6 +24,7 @@ export async function GET(request: Request) { return NextResponse.json({ found: true, accessToken: ideResult.accessToken, + refreshToken: ideResult.refreshToken, machineId: ideResult.machineId, source: ideResult.source, }); diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index 026cfc51cd..845946cae9 100755 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -1,12 +1,15 @@ import { NextResponse } from "next/server"; import { CursorService } from "@/lib/oauth/services/cursor"; -import { createProviderConnection, isCloudEnabled, resolveProxyForProvider } from "@/models"; -import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { credentialsFromCursorTokens } from "@/lib/oauth/services/cursorLogin"; +import { persistCursorConnection } from "@/lib/oauth/services/persistCursorConnection"; +import { isCloudEnabled } from "@/models"; import { syncToCloud } from "@/lib/cloudSync"; import { cursorImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { resolveProxyForProvider } from "@/models"; async function requireOAuthImportAuth(request: Request) { if (!(await isAuthRequired(request))) return null; @@ -16,11 +19,7 @@ async function requireOAuthImportAuth(request: Request) { /** * POST /api/oauth/cursor/import - * Import and validate access token from Cursor IDE's local SQLite database - * - * Request body: - * - accessToken: string - Access token from cursorAuth/accessToken - * - machineId: string - Machine ID from storage.serviceMachineId + * Import access token (and optional refresh token) from Cursor IDE / paste. */ export async function POST(request: Request) { const authResponse = await requireOAuthImportAuth(request); @@ -46,23 +45,16 @@ export async function POST(request: Request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { accessToken, machineId } = validation.data; + const { accessToken, machineId, refreshToken } = validation.data; const cursorService = new CursorService(); - - // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider("cursor"); - // Validate token by making API call (through proxy if configured) const tokenData = await runWithProxyContext(proxy, () => cursorService.validateImportToken(accessToken.trim(), machineId?.trim()) ); - // Try to extract user info from token (JWT decode, no API call) const jwtInfo = cursorService.extractUserInfo(tokenData.accessToken); - - // Best-effort fetch real profile (email + name) from cursor.com using the - // same WorkOS session cookie format we use for usage limits. const profile = jwtInfo?.userId ? await runWithProxyContext(proxy, () => cursorService.fetchUserInfo(tokenData.accessToken, jwtInfo.userId) @@ -70,26 +62,41 @@ export async function POST(request: Request) { : null; const email = profile?.email || jwtInfo?.email || null; + const trimmedRefresh = + typeof refreshToken === "string" && refreshToken.trim().length > 0 + ? refreshToken.trim() + : null; - // Save to database (no `name` — let the dashboard fall back to email so the - // privacy mask toggle applies, matching the codex/claude rendering). - const connection: any = await createProviderConnection({ - provider: "cursor", - authType: "oauth", - accessToken: tokenData.accessToken, - refreshToken: null, // Cursor doesn't have public refresh endpoint - expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), - email, - providerSpecificData: { + let connection; + if (trimmedRefresh) { + const creds = credentialsFromCursorTokens(tokenData.accessToken, trimmedRefresh); + connection = await persistCursorConnection({ + ...creds, + email: email || creds.email, machineId: tokenData.machineId, authMethod: "imported", - provider: "Imported", - userId: jwtInfo?.userId, - }, - testStatus: "active", - }); + }); + } else { + // Access-only import — no refresh; user must re-import when expired. + const { createProviderConnection } = await import("@/models"); + connection = await createProviderConnection({ + provider: "cursor", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: null, + expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + email, + providerSpecificData: { + machineId: tokenData.machineId, + authMethod: "imported", + provider: "Imported", + userId: jwtInfo?.userId, + accountId: jwtInfo?.userId || null, + }, + testStatus: "active", + }); + } - // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); return NextResponse.json({ @@ -100,7 +107,7 @@ export async function POST(request: Request) { email: connection.email, }, }); - } catch (error: any) { + } catch (error: unknown) { console.error("Cursor import token error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } @@ -128,6 +135,12 @@ export async function GET(request: Request) { description: "From cursorAuth/accessToken in state.vscdb", type: "textarea", }, + { + name: "refreshToken", + label: "Refresh Token (optional)", + description: "From cursorAuth/refreshToken — enables automatic refresh", + type: "textarea", + }, { name: "machineId", label: "Machine ID", @@ -138,9 +151,6 @@ export async function GET(request: Request) { }); } -/** - * Sync to Cloud if enabled - */ async function syncToCloudIfEnabled() { try { const cloudEnabled = await isCloudEnabled(); diff --git a/src/app/api/oauth/cursor/login/cancel/route.ts b/src/app/api/oauth/cursor/login/cancel/route.ts new file mode 100644 index 0000000000..8f5b730f26 --- /dev/null +++ b/src/app/api/oauth/cursor/login/cancel/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { cancelCursorLoginSession } from "@/lib/oauth/services/cursorLogin"; + +const cancelSchema = z.object({ + sessionId: z.string().trim().min(1, "sessionId is required"), +}); + +async function requireOAuthAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +/** + * POST /api/oauth/cursor/login/cancel + * Drop an in-progress deep-control login session. + */ +export async function POST(request: Request) { + const authResponse = await requireOAuthAuth(request); + if (authResponse) return authResponse; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + const validation = validateBody(cancelSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const cancelled = cancelCursorLoginSession(validation.data.sessionId); + return NextResponse.json({ success: true, cancelled }); +} diff --git a/src/app/api/oauth/cursor/login/poll/route.ts b/src/app/api/oauth/cursor/login/poll/route.ts new file mode 100644 index 0000000000..52d1bd8542 --- /dev/null +++ b/src/app/api/oauth/cursor/login/poll/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + credentialsFromCursorTokens, + peekCursorLoginSession, + pollCursorAuthOnce, + consumeCursorLoginSession, +} from "@/lib/oauth/services/cursorLogin"; +import { persistCursorConnection } from "@/lib/oauth/services/persistCursorConnection"; +import { isCloudEnabled } from "@/models"; +import { syncToCloud } from "@/lib/cloudSync"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; + +const pollSchema = z.object({ + sessionId: z.string().trim().min(1, "sessionId is required"), +}); + +async function requireOAuthAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +async function syncToCloudIfEnabled() { + try { + if (await isCloudEnabled()) { + await syncToCloud(); + } + } catch { + // best-effort + } +} + +/** + * POST /api/oauth/cursor/login/poll + * One poll against Cursor auth/poll. UI repeats until ok/error/timeout. + */ +export async function POST(request: Request) { + const authResponse = await requireOAuthAuth(request); + if (authResponse) return authResponse; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + const validation = validateBody(pollSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { sessionId } = validation.data; + const session = peekCursorLoginSession(sessionId); + if (!session) { + return NextResponse.json( + { status: "expired", error: "Login session expired or not found. Start again." }, + { status: 410 } + ); + } + + try { + const result = await pollCursorAuthOnce(session.uuid, session.verifier); + if (result.status === "pending") { + return NextResponse.json({ status: "pending" }); + } + if (result.status === "error") { + return NextResponse.json( + { status: "error", error: result.message }, + { status: result.httpStatus && result.httpStatus >= 400 ? result.httpStatus : 502 } + ); + } + + // Success — consume session so verifier cannot be reused + consumeCursorLoginSession(sessionId); + + const creds = credentialsFromCursorTokens(result.accessToken, result.refreshToken); + const machineId = await getConsistentMachineId(); + const connection = await persistCursorConnection({ + ...creds, + machineId, + authMethod: "deep_control", + }); + + await syncToCloudIfEnabled(); + + return NextResponse.json({ + status: "ok", + success: true, + connection: { + id: (connection as { id?: string })?.id, + provider: "cursor", + email: (connection as { email?: string })?.email ?? creds.email ?? null, + }, + }); + } catch (error) { + const message = sanitizeErrorMessage(error) || "Failed to poll Cursor login"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/cursor/login/start/route.ts b/src/app/api/oauth/cursor/login/start/route.ts new file mode 100644 index 0000000000..24041ec49e --- /dev/null +++ b/src/app/api/oauth/cursor/login/start/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { + createCursorLoginSession, + generateCursorAuthParams, +} from "@/lib/oauth/services/cursorLogin"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +async function requireOAuthAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +/** + * POST /api/oauth/cursor/login/start + * Begin deep-control PKCE login. Verifier stays server-side. + */ +export async function POST(request: Request) { + const authResponse = await requireOAuthAuth(request); + if (authResponse) return authResponse; + + try { + const params = await generateCursorAuthParams(); + const { sessionId, loginUrl } = createCursorLoginSession(params); + return NextResponse.json({ + success: true, + sessionId, + loginUrl, + // Multi-replica note: sessions are in-process; use sticky routing if scaled out. + expiresInSeconds: 15 * 60, + }); + } catch (error) { + const message = sanitizeErrorMessage(error) || "Failed to start Cursor login"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 818156aaec..01f1bd7285 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -92,6 +92,7 @@ import { import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; +import { fetchCursorAvailableModels } from "@/lib/providerModels/cursorAvailableModels"; import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { fetchRaycastModels } from "@omniroute/open-sse/services/raycast.ts"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; @@ -1356,19 +1357,45 @@ export async function GET( const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + const warnings: string[] = []; + const token = (accessToken || apiKey || "").trim(); + const machineId = + typeof connection?.providerSpecificData === "object" && + connection.providerSpecificData && + typeof (connection.providerSpecificData as { machineId?: unknown }).machineId === "string" + ? (connection.providerSpecificData as { machineId: string }).machineId + : null; + + if (token) { + try { + const models = await fetchCursorAvailableModels({ + accessToken: token, + machineId, + }); + return buildApiDiscoveryResponse(models); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log("[models] Cursor AvailableModels failed:", message); + warnings.push(`AvailableModels unavailable (${message})`); + } + } else { + warnings.push("no Cursor access token on connection"); + } + try { const models = ensureCursorAutoCatalogEntry(await fetchCursorAgentModels()); return buildApiDiscoveryResponse(models); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.log("[models] cursor-agent fetch failed:", message); + const detail = [...warnings, `cursor-agent unavailable (${message})`].join("; "); const fallback = buildDiscoveryFallbackResponse({ - cacheWarning: `cursor-agent unavailable (${message}) — using cached catalog`, - localWarning: `cursor-agent unavailable (${message}) — using local catalog`, + cacheWarning: `${detail} — using cached catalog`, + localWarning: `${detail} — using local catalog`, }); if (fallback) return fallback; return NextResponse.json( - { error: `Failed to fetch Cursor models: ${message}` }, + { error: `Failed to fetch Cursor models: ${detail}` }, { status: 502 } ); } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index c7eeeb3754..1fadd60490 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "ربط IDE Cursor", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "الكشف التلقائي عن الرموز...", "readingFromCursor": "القراءة من Cursor IDE أو وكيل Cursor", "tokensAutoDetected": "تم اكتشاف الرموز المميزة تلقائيًا من Cursor IDE!", "cursorNotDetected": "لم يتم الكشف عن IDE للمؤشر. يرجى لصق الرمز المميز الخاص بك يدويًا.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "رمز الوصول", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "سيتم ملء رمز الوصول تلقائيًا...", "machineId": "معرف الآلة", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "غير قادر على اكتشاف الرموز المميزة تلقائيًا", "errorAutoDetectFailed": "فشل الكشف التلقائي عن الرموز المميزة", "errorEnterToken": "الرجاء إدخال رمز الوصول", - "errorImportFailed": "فشل الاستيراد" + "errorImportFailed": "فشل الاستيراد", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "تكوين التسعير", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 605fe9ed18..2bb6769e2b 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index b2dfd1176a..4ee7bb7d6d 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Свържете Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Автоматично откриване на токени...", "readingFromCursor": "Четене от Cursor IDE или cursor-agent", "tokensAutoDetected": "Токените са успешно автоматично открити от Cursor IDE!", "cursorNotDetected": "IDE на курсора не е открит. Моля, поставете ръчно вашето означение.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Токен за достъп", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Токенът за достъп ще се попълни автоматично...", "machineId": "ID на машината", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Не могат да се открият автоматично токени", "errorAutoDetectFailed": "Автоматичното откриване на токени не бе успешно", "errorEnterToken": "Моля, въведете токен за достъп", - "errorImportFailed": "Неуспешно импортиране" + "errorImportFailed": "Неуспешно импортиране", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Конфигурация на цените", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 34ecf02c74..72645b5d51 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 18761a6f44..90f35e8b1f 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Připojte kurzorové IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Automatická detekce tokenů...", "readingFromCursor": "Čtení z Cursor IDE nebo kurzorového agenta", "tokensAutoDetected": "Tokeny byly úspěšně automaticky detekovány z Cursor IDE!", "cursorNotDetected": "IDE kurzoru nebylo zjištěno. Vložte prosím svůj token ručně.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Přístupový token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Přístupový token se automaticky vyplní...", "machineId": "ID stroje", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Nelze automaticky detekovat tokeny", "errorAutoDetectFailed": "Automatická detekce tokenů se nezdařila", "errorEnterToken": "Zadejte přístupový token", - "errorImportFailed": "Import se nezdařil" + "errorImportFailed": "Import se nezdařil", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Konfigurace cen", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index cd127ca8a3..ad8577e7d0 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Tilslut Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Automatisk registrering af tokens...", "readingFromCursor": "Læser fra Cursor IDE eller cursor-agent", "tokensAutoDetected": "Tokens blev automatisk registreret fra Cursor IDE!", "cursorNotDetected": "Markør-IDE blev ikke fundet. Indsæt venligst dit token manuelt.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Adgangstoken", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Adgangstoken udfyldes automatisk...", "machineId": "Maskin-id", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Kan ikke automatisk registrere tokens", "errorAutoDetectFailed": "Automatisk registrering af tokens mislykkedes", "errorEnterToken": "Indtast venligst adgangstoken", - "errorImportFailed": "Import mislykkedes" + "errorImportFailed": "Import mislykkedes", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Priskonfiguration", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d1f60bb142..e79671a9c9 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Verbinden Sie die Cursor-IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Automatische Erkennung von Token...", "readingFromCursor": "Lesen aus der Cursor-IDE oder dem Cursor-Agenten", "tokensAutoDetected": "Tokens wurden erfolgreich automatisch von der Cursor-IDE erkannt!", "cursorNotDetected": "Cursor-IDE nicht erkannt. Bitte fügen Sie Ihr Token manuell ein.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Zugriffstoken", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Das Zugriffstoken wird automatisch ausgefüllt...", "machineId": "Maschinen-ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Token können nicht automatisch erkannt werden", "errorAutoDetectFailed": "Die automatische Erkennung von Token ist fehlgeschlagen", "errorEnterToken": "Bitte geben Sie das Zugriffstoken ein", - "errorImportFailed": "Der Import ist fehlgeschlagen" + "errorImportFailed": "Der Import ist fehlgeschlagen", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Preiskonfiguration", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1c176ff464..71ce552796 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -10632,12 +10632,24 @@ "continue": "Continue" }, "cursorAuthModal": { - "title": "Connect Cursor IDE", + "title": "Connect Cursor", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", - "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "cursorNotDetected": "Cursor IDE not detected. Paste your access token (and refresh token if available).", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10649,7 +10661,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 94b17341c1..e2c8484476 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Conectar cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Tokens de detección automática...", "readingFromCursor": "Lectura desde Cursor IDE o cursor-agent", "tokensAutoDetected": "¡Los tokens se detectaron automáticamente con éxito desde Cursor IDE!", "cursorNotDetected": "IDE del cursor no detectado. Pegue manualmente su token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Token de acceso", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "El token de acceso se completará automáticamente...", "machineId": "ID de máquina", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "No se pueden detectar tokens automáticamente", "errorAutoDetectFailed": "Error de detección automática de tokens", "errorEnterToken": "Por favor ingrese el token de acceso", - "errorImportFailed": "Importación fallida" + "errorImportFailed": "Importación fallida", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configuración de precios", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 851f93aba9..0adb263f7e 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 9149e2ec4f..f2b8bf69fa 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Yhdistä kohdistin IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Tunnistetaan automaattisesti...", "readingFromCursor": "Lukeminen Cursor IDE:stä tai cursor-agentista", "tokensAutoDetected": "Tokenit tunnistettiin automaattisesti Cursor IDE:stä!", "cursorNotDetected": "Kohdistimen IDE:tä ei havaittu. Liitä tunnus manuaalisesti.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Käyttöoikeustunnus", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Käyttöoikeustunnus täytetään automaattisesti...", "machineId": "Koneen tunnus", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Tunnuksia ei voi tunnistaa automaattisesti", "errorAutoDetectFailed": "Tunnusten automaattinen tunnistus epäonnistui", "errorEnterToken": "Anna käyttöoikeustunnus", - "errorImportFailed": "Tuonti epäonnistui" + "errorImportFailed": "Tuonti epäonnistui", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Hinnoitteluasetukset", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 72a3792df2..9acd2a0360 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connecter l'IDE du curseur", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Jetons à détection automatique...", "readingFromCursor": "Lecture à partir de Cursor IDE ou de Cursor-Agent", "tokensAutoDetected": "Jetons détectés automatiquement avec succès à partir de Cursor IDE !", "cursorNotDetected": "Curseur IDE non détecté. Veuillez coller manuellement votre jeton.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Jeton d'accès", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Le jeton d'accès se remplira automatiquement...", "machineId": "ID de l'ordinateur", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Impossible de détecter automatiquement les jetons", "errorAutoDetectFailed": "Échec de la détection automatique des jetons", "errorEnterToken": "Veuillez saisir le jeton d'accès", - "errorImportFailed": "Échec de l'importation" + "errorImportFailed": "Échec de l'importation", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configuration des prix", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index a1a38f1980..7e822441bf 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 96007496ae..a4aeba92c4 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "חבר את הסמן IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "מזהה אוטומטית אסימונים...", "readingFromCursor": "קריאה מ-IDE Cursor או Cursor-agent", "tokensAutoDetected": "אסימונים זוהו בהצלחה אוטומטית מ-Cursor IDE!", "cursorNotDetected": "IDE הסמן לא זוהה. אנא הדבק ידנית את האסימון שלך.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "אסימון גישה", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "אסימון הגישה יאוכלס אוטומטית...", "machineId": "מזהה מכונה", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "לא ניתן לזהות אוטומטית אסימונים", "errorAutoDetectFailed": "זיהוי אוטומטי של אסימונים נכשל", "errorEnterToken": "נא להזין אסימון גישה", - "errorImportFailed": "הייבוא נכשל" + "errorImportFailed": "הייבוא נכשל", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "תצורת תמחור", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 3cbff8e202..3d5c9677f8 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "कर्सर आईडीई कनेक्ट करें", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "टोकन का स्वतः पता लगाना...", "readingFromCursor": "कर्सर आईडीई या कर्सर-एजेंट से पढ़ना", "tokensAutoDetected": "कर्सर आईडीई से टोकन का सफलतापूर्वक स्वतः पता लगाया गया!", "cursorNotDetected": "कर्सर आईडीई का पता नहीं चला. कृपया अपना टोकन मैन्युअल रूप से चिपकाएँ।", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "प्रवेश टोकन", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "एक्सेस टोकन स्वतः-पॉप्युलेट हो जाएगा...", "machineId": "मशीन आईडी", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "टोकन का स्वतः पता लगाने में असमर्थ", "errorAutoDetectFailed": "टोकन का स्वतः पता लगाना विफल रहा", "errorEnterToken": "कृपया एक्सेस टोकन दर्ज करें", - "errorImportFailed": "आयात विफल" + "errorImportFailed": "आयात विफल", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "मूल्य निर्धारण विन्यास", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 6f723ed853..22ad3bf8da 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Csatlakoztassa a kurzor IDE-t", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Tokenek automatikus felismerése...", "readingFromCursor": "Olvasás Cursor IDE-ből vagy cursor-agentből", "tokensAutoDetected": "A tokenek automatikusan felismerve a Cursor IDE-ből!", "cursorNotDetected": "A kurzor IDE nem észlelhető. Kérjük, manuálisan illessze be a tokent.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Hozzáférési token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "A hozzáférési token automatikusan kitöltődik...", "machineId": "Gépazonosító", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "A tokenek automatikus észlelése nem lehetséges", "errorAutoDetectFailed": "A tokenek automatikus észlelése sikertelen", "errorEnterToken": "Kérjük, adja meg a hozzáférési tokent", - "errorImportFailed": "Az importálás sikertelen" + "errorImportFailed": "Az importálás sikertelen", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Árképzési konfiguráció", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index f6aa07123a..31fc6af52f 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Hubungkan IDE Kursor", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Token yang terdeteksi secara otomatis...", "readingFromCursor": "Membaca dari IDE Kursor atau agen kursor", "tokensAutoDetected": "Token berhasil terdeteksi secara otomatis dari Cursor IDE!", "cursorNotDetected": "IDE kursor tidak terdeteksi. Harap tempelkan token Anda secara manual.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Akses Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Token akses akan terisi secara otomatis...", "machineId": "ID Mesin", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Tidak dapat mendeteksi token secara otomatis", "errorAutoDetectFailed": "Token deteksi otomatis gagal", "errorEnterToken": "Silakan masukkan token akses", - "errorImportFailed": "Impor gagal" + "errorImportFailed": "Impor gagal", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Konfigurasi Harga", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 5f2587eb6a..71f95d00b6 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 902ed0795a..6c2265269f 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connetti l'IDE del cursore", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Token di rilevamento automatico...", "readingFromCursor": "Lettura da Cursor IDE o cursor-agent", "tokensAutoDetected": "Token rilevati automaticamente con successo da Cursor IDE!", "cursorNotDetected": "IDE del cursore non rilevato. Incolla manualmente il token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Gettone di accesso", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Il token di accesso si popolerà automaticamente...", "machineId": "ID macchina", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Impossibile rilevare automaticamente i token", "errorAutoDetectFailed": "Il rilevamento automatico dei token non è riuscito", "errorEnterToken": "Inserisci il token di accesso", - "errorImportFailed": "Importazione non riuscita" + "errorImportFailed": "Importazione non riuscita", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configurazione dei prezzi", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index d586218bc8..dc7d677df4 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "カーソルIDEの接続", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "トークンを自動検出しています...", "readingFromCursor": "Cursor IDE またはカーソルエージェントからの読み取り", "tokensAutoDetected": "トークンは Cursor IDE から正常に自動検出されました。", "cursorNotDetected": "カーソル IDE が検出されません。トークンを手動で貼り付けてください。", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "アクセストークン", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "アクセストークンは自動入力されます...", "machineId": "マシンID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "トークンを自動検出できません", "errorAutoDetectFailed": "トークンの自動検出に失敗しました", "errorEnterToken": "アクセストークンを入力してください", - "errorImportFailed": "インポートに失敗しました" + "errorImportFailed": "インポートに失敗しました", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "価格設定", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 4f10276f71..18db2fe226 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Cursor IDE 연결", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "토큰 자동 감지 중...", "readingFromCursor": "Cursor IDE 또는 cursor-agent에서 읽는 중", "tokensAutoDetected": "Cursor IDE에서 토큰이 자동 감지되었습니다!", "cursorNotDetected": "Cursor IDE가 감지되지 않았습니다. 토큰을 수동으로 붙여넣으세요.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "액세스 토큰", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "액세스 토큰이 자동으로 채워집니다...", "machineId": "머신 ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "토큰을 자동 감지할 수 없습니다.", "errorAutoDetectFailed": "토큰 자동 감지 실패", "errorEnterToken": "액세스 토큰을 입력하세요.", - "errorImportFailed": "가져오기 실패" + "errorImportFailed": "가져오기 실패", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "가격 구성", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 86437f4f3a..2a7082adda 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 498af0d138..3072ad2071 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Sambungkan IDE Kursor", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Token pengesan automatik...", "readingFromCursor": "Membaca daripada Cursor IDE atau cursor-agent", "tokensAutoDetected": "Token berjaya dikesan secara automatik daripada Cursor IDE!", "cursorNotDetected": "IDE kursor tidak dikesan. Sila tampal token anda secara manual.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Token Akses", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Token akses akan diisi secara automatik...", "machineId": "ID mesin", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Tidak dapat mengesan token secara automatik", "errorAutoDetectFailed": "Autokesan token gagal", "errorEnterToken": "Sila masukkan token akses", - "errorImportFailed": "Import gagal" + "errorImportFailed": "Import gagal", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Konfigurasi Harga", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 6422dad14d..e017240ae9 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Sluit Cursor-IDE aan", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Tokens automatisch detecteren...", "readingFromCursor": "Lezen vanuit Cursor IDE of cursor-agent", "tokensAutoDetected": "Tokens succesvol automatisch gedetecteerd vanuit Cursor IDE!", "cursorNotDetected": "Cursor-IDE niet gedetecteerd. Plak uw token handmatig.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Toegangstoken", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Toegangstoken wordt automatisch ingevuld...", "machineId": "Machine-ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Kan tokens niet automatisch detecteren", "errorAutoDetectFailed": "Automatische detectie van tokens is mislukt", "errorEnterToken": "Voer een toegangstoken in", - "errorImportFailed": "Importeren is mislukt" + "errorImportFailed": "Importeren is mislukt", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Prijsconfiguratie", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 6f105c14c3..ea9dde2c0d 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Koble til markør-IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Automatisk oppdager tokens...", "readingFromCursor": "Leser fra Cursor IDE eller cursor-agent", "tokensAutoDetected": "Tokens ble automatisk oppdaget fra Cursor IDE!", "cursorNotDetected": "Markør-IDE ble ikke oppdaget. Vennligst lim inn tokenet ditt manuelt.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Tilgangstoken", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Tilgangstoken fylles ut automatisk...", "machineId": "Maskin-ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Kan ikke oppdage tokens automatisk", "errorAutoDetectFailed": "Automatisk gjenkjenning av tokens mislyktes", "errorEnterToken": "Vennligst skriv inn tilgangstoken", - "errorImportFailed": "Import mislyktes" + "errorImportFailed": "Import mislyktes", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Priskonfigurasjon", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index f9dc8a149b..ad97aebb04 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Ikonekta ang Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Awtomatikong pagtukoy ng mga token...", "readingFromCursor": "Pagbabasa mula sa Cursor IDE o cursor-agent", "tokensAutoDetected": "Ang mga token ay matagumpay na na-auto-detect mula sa Cursor IDE!", "cursorNotDetected": "Hindi nakita ang cursor IDE. Mangyaring manu-manong i-paste ang iyong token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Awtomatikong magpo-populate ang token ng access...", "machineId": "ID ng makina", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Hindi ma-auto-detect ang mga token", "errorAutoDetectFailed": "Nabigo ang auto-detect na mga token", "errorEnterToken": "Mangyaring magpasok ng token ng pag-access", - "errorImportFailed": "Nabigo ang pag-import" + "errorImportFailed": "Nabigo ang pag-import", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configuration ng Pagpepresyo", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 2bca5453b1..59edd20912 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Połącz Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Automatyczne wykrywanie tokens...", "readingFromCursor": "Odczytywanie z Cursor IDE lub cursor-agent", "tokensAutoDetected": "Tokens pomyślnie automatycznie wykryte z Cursor IDE!", "cursorNotDetected": "Nie wykryto Cursor IDE. Wklej ręcznie swój token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token zostanie automatycznie uzupełniony...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Nie można automatycznie wykryć tokens", "errorAutoDetectFailed": "Automatyczne wykrywanie tokens nie powiodło się", "errorEnterToken": "Wprowadź access token", - "errorImportFailed": "Import nie powiódł się" + "errorImportFailed": "Import nie powiódł się", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Konfiguracja cennika", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 9a3b93def6..f5609c45e1 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -10631,11 +10631,23 @@ }, "cursorAuthModal": { "title": "Conecte o Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Detecção automática de tokens...", "readingFromCursor": "Lendo do Cursor IDE ou cursor-agent", "tokensAutoDetected": "Tokens detectados automaticamente com sucesso no Cursor IDE!", "cursorNotDetected": "Cursor IDE não detectado. Cole manualmente seu token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Token de acesso", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "O token de acesso será preenchido automaticamente...", "machineId": "ID da máquina", @@ -10647,7 +10659,10 @@ "errorAutoDetect": "Não foi possível detectar tokens automaticamente", "errorAutoDetectFailed": "Falha na detecção automática de tokens", "errorEnterToken": "Por favor insira o token de acesso", - "errorImportFailed": "Falha na importação" + "errorImportFailed": "Falha na importação", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configuração de preços", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index dc1a90c803..b0b1834804 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Conecte o Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Detecção automática de tokens...", "readingFromCursor": "Lendo do Cursor IDE ou cursor-agent", "tokensAutoDetected": "Tokens detectados automaticamente com sucesso no Cursor IDE!", "cursorNotDetected": "Cursor IDE não detectado. Cole manualmente seu token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Token de acesso", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "O token de acesso será preenchido automaticamente...", "machineId": "ID da máquina", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Não foi possível detectar tokens automaticamente", "errorAutoDetectFailed": "Falha na detecção automática de tokens", "errorEnterToken": "Por favor insira o token de acesso", - "errorImportFailed": "Falha na importação" + "errorImportFailed": "Falha na importação", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configuração de preços", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 4265215f2e..8917ca5e18 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Conectați Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Se detectează automat jetoanele...", "readingFromCursor": "Citirea din Cursor IDE sau cursor-agent", "tokensAutoDetected": "Jetoanele au fost detectate automat cu succes din Cursor IDE!", "cursorNotDetected": "IDE-ul cursorului nu a fost detectat. Vă rugăm să lipiți manual indicativul.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Token de acces", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Indicatorul de acces se va completa automat...", "machineId": "ID mașină", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Nu se pot detecta automat jetoanele", "errorAutoDetectFailed": "Jetoanele de detectare automată nu au reușit", "errorEnterToken": "Vă rugăm să introduceți simbolul de acces", - "errorImportFailed": "Importul nu a reușit" + "errorImportFailed": "Importul nu a reușit", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Configurarea prețurilor", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 8210e133a2..bf1dc9f5f8 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Подключить курсор IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Автоматическое обнаружение токенов...", "readingFromCursor": "Чтение из Cursor IDE или курсорного агента", "tokensAutoDetected": "Токены успешно автоматически обнаружены в Cursor IDE!", "cursorNotDetected": "Курсор IDE не обнаружен. Пожалуйста, вставьте свой токен вручную.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Токен доступа", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Токен доступа будет автоматически заполнен...", "machineId": "Идентификатор машины", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Невозможно автоматически обнаружить токены", "errorAutoDetectFailed": "Не удалось автоматически обнаружить токены", "errorEnterToken": "Пожалуйста, введите токен доступа", - "errorImportFailed": "Импорт не удался" + "errorImportFailed": "Импорт не удался", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Конфигурация цен", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index ba4a09a022..96a655d18c 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Pripojte kurzorové IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Automatické zisťovanie tokenov...", "readingFromCursor": "Čítanie z kurzorového IDE alebo kurzorového agenta", "tokensAutoDetected": "Tokeny boli úspešne automaticky zistené z Cursor IDE!", "cursorNotDetected": "Nebolo zistené IDE kurzora. Prilepte svoj token ručne.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Prístupový token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Prístupový token sa automaticky vyplní...", "machineId": "ID stroja", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Nie je možné automaticky rozpoznať tokeny", "errorAutoDetectFailed": "Automatická detekcia tokenov zlyhala", "errorEnterToken": "Zadajte prístupový token", - "errorImportFailed": "Import zlyhal" + "errorImportFailed": "Import zlyhal", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Konfigurácia cien", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 1433b5b49c..d01e5d2fbb 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Anslut Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Identifierar tokens automatiskt...", "readingFromCursor": "Läser från Cursor IDE eller cursor-agent", "tokensAutoDetected": "Tokens har framgångsrikt identifierats automatiskt från Cursor IDE!", "cursorNotDetected": "Markör-IDE upptäcktes inte. Vänligen klistra in din token manuellt.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Åtkomsttoken kommer att fyllas i automatiskt...", "machineId": "Maskin-ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Det går inte att automatiskt upptäcka tokens", "errorAutoDetectFailed": "Automatisk identifiering av tokens misslyckades", "errorEnterToken": "Vänligen ange åtkomsttoken", - "errorImportFailed": "Importen misslyckades" + "errorImportFailed": "Importen misslyckades", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Priskonfiguration", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 3a2b3c204c..1c76b1a959 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index ab9fc3c1f3..13084bff87 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 6d1a07e41b..ec29dbbc07 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index b30a5bebc5..0e673e240a 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "เชื่อมต่อเคอร์เซอร์ IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "กำลังตรวจจับโทเค็นอัตโนมัติ...", "readingFromCursor": "อ่านจากเคอร์เซอร์ IDE หรือเคอร์เซอร์เอเจนต์", "tokensAutoDetected": "โทเค็นตรวจพบอัตโนมัติสำเร็จจาก Cursor IDE!", "cursorNotDetected": "ตรวจไม่พบเคอร์เซอร์ IDE โปรดวางโทเค็นของคุณด้วยตนเอง", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "โทเค็นการเข้าถึง", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "โทเค็นการเข้าถึงจะเติมข้อมูลอัตโนมัติ...", "machineId": "หมายเลขเครื่อง", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "ไม่สามารถตรวจจับโทเค็นอัตโนมัติได้", "errorAutoDetectFailed": "โทเค็นการตรวจจับอัตโนมัติล้มเหลว", "errorEnterToken": "กรุณาใส่โทเค็นการเข้าถึง", - "errorImportFailed": "การนำเข้าล้มเหลว" + "errorImportFailed": "การนำเข้าล้มเหลว", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "การกำหนดค่าราคา", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 958b931040..42985cce3f 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "İmleç IDE'sini bağlayın", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Belirteçler otomatik olarak algılanıyor...", "readingFromCursor": "İmleç IDE'sinden veya imleç aracısından okuma", "tokensAutoDetected": "Belirteçler Cursor IDE'den başarıyla otomatik olarak algılandı!", "cursorNotDetected": "İmleç IDE'si algılanmadı. Lütfen jetonunuzu manuel olarak yapıştırın.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Erişim Jetonu", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Erişim belirteci otomatik olarak doldurulacak...", "machineId": "Makine Kimliği", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Belirteçler otomatik olarak algılanamıyor", "errorAutoDetectFailed": "Belirteçlerin otomatik algılanması başarısız oldu", "errorEnterToken": "Lütfen erişim belirtecini girin", - "errorImportFailed": "İçe aktarma başarısız oldu" + "errorImportFailed": "İçe aktarma başarısız oldu", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Fiyatlandırma Yapılandırması", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 586571a9ca..29847ed7e6 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Підключіть Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Автоматичне визначення токенів...", "readingFromCursor": "Читання з Cursor IDE або cursor-agent", "tokensAutoDetected": "Маркери успішно автоматично виявлені в Cursor IDE!", "cursorNotDetected": "Курсор IDE не виявлено. Вставте маркер вручну.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Маркер доступу", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Маркер доступу буде заповнено автоматично...", "machineId": "ID машини", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Неможливо автоматично визначити маркери", "errorAutoDetectFailed": "Помилка автоматичного визначення маркерів", "errorEnterToken": "Будь ласка, введіть маркер доступу", - "errorImportFailed": "Помилка імпорту" + "errorImportFailed": "Помилка імпорту", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Конфігурація ціни", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index aef5f7ae73..303d6fa8cf 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "Connect Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Auto-detecting tokens...", "readingFromCursor": "Reading from Cursor IDE or cursor-agent", "tokensAutoDetected": "Tokens successfully auto-detected from Cursor IDE!", "cursorNotDetected": "Cursor IDE not detected. Please manually paste your token.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Access Token", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Access token will auto-populate...", "machineId": "Machine ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "Unable to auto-detect tokens", "errorAutoDetectFailed": "Auto-detect tokens failed", "errorEnterToken": "Please enter access token", - "errorImportFailed": "Import failed" + "errorImportFailed": "Import failed", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Pricing Configuration", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 0004b404b9..cc16d48ef0 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -10631,11 +10631,23 @@ }, "cursorAuthModal": { "title": "Kết nối Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "Tự động phát hiện token...", "readingFromCursor": "Đang đọc token từ Cursor IDE hoặc cursor-agent", "tokensAutoDetected": "Đã tự động phát hiện token từ Cursor IDE thành công!", "cursorNotDetected": "Không phát hiện thấy Cursor IDE. Vui lòng dán token của bạn thủ công.", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "Token truy cập", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "Token truy cập sẽ được tự động điền...", "machineId": "Mã máy", @@ -10647,7 +10659,10 @@ "errorAutoDetect": "Không thể tự động phát hiện token", "errorAutoDetectFailed": "Tự động phát hiện token không thành công", "errorEnterToken": "Vui lòng nhập token truy cập", - "errorImportFailed": "Nhập thất bại" + "errorImportFailed": "Nhập thất bại", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "Cấu hình giá", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 05eed0cd99..d1238f7f4c 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "连接 Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "自动检测令牌中...", "readingFromCursor": "正在从 Cursor IDE 或 cursor-agent 读取", "tokensAutoDetected": "已成功从 Cursor IDE 自动检测到令牌!", "cursorNotDetected": "未检测到 Cursor IDE。请手动粘贴您的令牌。", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "访问令牌", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "访问令牌将自动填充...", "machineId": "机器 ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "无法自动检测令牌", "errorAutoDetectFailed": "自动检测令牌失败", "errorEnterToken": "请输入访问令牌", - "errorImportFailed": "导入失败" + "errorImportFailed": "导入失败", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "定价配置", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 442b9a66e7..0207aa2a6f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -10626,11 +10626,23 @@ }, "cursorAuthModal": { "title": "連線 Cursor IDE", + "tabLogin": "Login with Cursor", + "tabImport": "Import token", + "loginDescription": "Opens Cursor's browser login. Works in Docker — approve in your host browser, then return here.", + "loginWithCursor": "Login with Cursor", + "startingLogin": "Starting…", + "waitingApproval": "Waiting for Cursor login approval…", + "openUrlHint": "If the browser did not open:", + "openLoginLink": "Open login page", + "cancelLogin": "Cancel login", "autoDetecting": "自動檢測權杖中...", "readingFromCursor": "正在從 Cursor IDE 或 cursor-agent 讀取", "tokensAutoDetected": "已成功從 Cursor IDE 自動檢測到權杖!", "cursorNotDetected": "未檢測到 Cursor IDE。請手動貼上您的權杖。", + "dockerImportHint": "Running in Docker? Prefer Login with Cursor. IDE auto-import and cursor-agent are usually unavailable inside the container. See docs/providers/CURSOR-DOCKER.md.", "accessToken": "訪問權杖", + "refreshToken": "Refresh Token", + "refreshTokenPlaceholder": "Optional — enables automatic token refresh", "required": "*", "accessTokenPlaceholder": "訪問權杖將自動填充...", "machineId": "機器 ID", @@ -10642,7 +10654,10 @@ "errorAutoDetect": "無法自動檢測權杖", "errorAutoDetectFailed": "自動檢測權杖失敗", "errorEnterToken": "請輸入訪問權杖", - "errorImportFailed": "匯入失敗" + "errorImportFailed": "匯入失敗", + "errorLoginStart": "Failed to start Cursor login", + "errorLoginPoll": "Cursor login failed", + "errorLoginTimeout": "Cursor login timed out — try again" }, "pricingModal": { "title": "定價設定", diff --git a/src/lib/cursor/tokenExtractor.ts b/src/lib/cursor/tokenExtractor.ts index fda379004b..1ca27939a2 100644 --- a/src/lib/cursor/tokenExtractor.ts +++ b/src/lib/cursor/tokenExtractor.ts @@ -64,6 +64,7 @@ export async function verifyLinuxCursorInstalled(probe: CursorInstallProbe = {}) * exact match wins. */ const ACCESS_TOKEN_KEYS = ["cursorAuth/accessToken", "cursorAuth/token"] as const; +const REFRESH_TOKEN_KEYS = ["cursorAuth/refreshToken"] as const; const MACHINE_ID_KEYS = [ "storage.serviceMachineId", "storage.machineId", @@ -92,12 +93,13 @@ interface VscDbRow { interface ExtractedCursorTokens { accessToken?: string; + refreshToken?: string; machineId?: string; } /** - * Pick the first matching access-token / machine-id from a set of rows. - * Pure function — easy to unit-test without a SQLite handle. + * Pick the first matching access-token / refresh-token / machine-id from a + * set of rows. Pure function — easy to unit-test without a SQLite handle. */ export function extractCursorTokensFromRows(rows: VscDbRow[]): ExtractedCursorTokens { const tokens: ExtractedCursorTokens = {}; @@ -105,6 +107,12 @@ export function extractCursorTokensFromRows(rows: VscDbRow[]): ExtractedCursorTo if (!tokens.accessToken && (ACCESS_TOKEN_KEYS as readonly string[]).includes(row.key)) { const v = normalizeVscDbValue(row.value); if (typeof v === "string") tokens.accessToken = v; + } else if ( + !tokens.refreshToken && + (REFRESH_TOKEN_KEYS as readonly string[]).includes(row.key) + ) { + const v = normalizeVscDbValue(row.value); + if (typeof v === "string") tokens.refreshToken = v; } else if (!tokens.machineId && (MACHINE_ID_KEYS as readonly string[]).includes(row.key)) { const v = normalizeVscDbValue(row.value); if (typeof v === "string") tokens.machineId = v; @@ -114,10 +122,11 @@ export function extractCursorTokensFromRows(rows: VscDbRow[]): ExtractedCursorTo } /** - * Fuzzy-match access-token / machine-id from any rows whose key vaguely - * resembles the expected pattern (e.g. `cursorAuth/someOtherAccessTokenKey`, - * `storage.someMachineId`). Used only when the exact-key lookup yielded - * nothing — guards against silent breakage when Cursor renames a key. + * Fuzzy-match access-token / refresh-token / machine-id from any rows whose + * key vaguely resembles the expected pattern (e.g. + * `cursorAuth/someOtherAccessTokenKey`, `storage.someMachineId`). Used only + * when the exact-key lookup yielded nothing — guards against silent breakage + * when Cursor renames a key. */ export function fuzzyExtractCursorTokensFromRows( rows: VscDbRow[], @@ -130,6 +139,9 @@ export function fuzzyExtractCursorTokensFromRows( const value = normalizeVscDbValue(row.value); if (typeof value !== "string") continue; if (!tokens.accessToken && lower.includes("accesstoken")) tokens.accessToken = value; + if (!tokens.refreshToken && lower.includes("refreshtoken") && !lower.includes("accesstoken")) { + tokens.refreshToken = value; + } if (!tokens.machineId && lower.includes("machineid")) tokens.machineId = value; } return tokens; @@ -245,6 +257,7 @@ export async function tryAgentAuth(): Promise<{ export async function tryIdeAuth(options?: { timeoutMs?: number }): Promise<{ found: boolean; accessToken?: string; + refreshToken?: string; machineId?: string; source?: string; error?: string; @@ -329,7 +342,7 @@ export async function tryIdeAuth(options?: { timeoutMs?: number }): Promise<{ } try { - const desiredKeys = [...ACCESS_TOKEN_KEYS, ...MACHINE_ID_KEYS]; + const desiredKeys = [...ACCESS_TOKEN_KEYS, ...REFRESH_TOKEN_KEYS, ...MACHINE_ID_KEYS]; const placeholders = desiredKeys.map(() => "?").join(","); const rows = db .prepare(`SELECT key, value FROM itemTable WHERE key IN (${placeholders})`) @@ -360,6 +373,7 @@ export async function tryIdeAuth(options?: { timeoutMs?: number }): Promise<{ return { found: true, accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, machineId: tokens.machineId, source: "cursor-ide", }; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 0cace6c604..0318db40b3 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -16,6 +16,7 @@ import { GROK_BUILD_TOKEN_URL, } from "@omniroute/open-sse/config/grokBuild.ts"; import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts"; +import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersion.ts"; import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab"; /** @@ -364,20 +365,26 @@ export const KIRO_CONFIG = { authMethods: ["builder-id", "idc", "google", "github", "import"], }; -// Cursor OAuth Configuration (Import Token from Cursor IDE) +// Cursor OAuth Configuration (deep-control PKCE + optional IDE import) // Cursor stores credentials in SQLite database: state.vscdb -// Keys: cursorAuth/accessToken, storage.serviceMachineId +// Keys: cursorAuth/accessToken, cursorAuth/refreshToken, storage.serviceMachineId +// Deep-control PKCE + refresh aligned with OpenCodex (lidge-jun/opencodex src/oauth/cursor.ts). +// clientVersion pin lives in open-sse/utils/cursorAgentCliVersion.ts — single source of truth. export const CURSOR_CONFIG = { // API endpoints apiEndpoint: "https://api2.cursor.sh", chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", - modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData", + modelsEndpoint: "/aiserver.v1.AiService/AvailableModels", + // Standalone deep-control login (no IDE/CLI required) + loginUrl: "https://cursor.com/loginDeepControl", + pollUrl: "https://api2.cursor.sh/auth/poll", + refreshUrl: "https://api2.cursor.sh/auth/exchange_user_api_key", // Additional endpoints api3Endpoint: "https://api3.cursor.sh", // Telemetry agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode - // Client metadata - clientVersion: "3.2.14", + // Client metadata — pin from cursorAgentCliVersion (not a second hardcoded string) + clientVersion: CURSOR_AGENT_CLI_VERSION, clientType: "ide", // Token storage locations (for user reference) tokenStoragePaths: { @@ -388,6 +395,7 @@ export const CURSOR_CONFIG = { // Database keys dbKeys: { accessToken: "cursorAuth/accessToken", + refreshToken: "cursorAuth/refreshToken", machineId: "storage.serviceMachineId", }, }; diff --git a/src/lib/oauth/providers/cursor.ts b/src/lib/oauth/providers/cursor.ts index 83ee0e5ae9..7653acb5e2 100644 --- a/src/lib/oauth/providers/cursor.ts +++ b/src/lib/oauth/providers/cursor.ts @@ -3,13 +3,19 @@ import { CURSOR_CONFIG } from "../constants/oauth"; export const cursor = { config: CURSOR_CONFIG, flowType: "import_token", - mapTokens: (tokens) => ({ + mapTokens: (tokens: { + accessToken: string; + refreshToken?: string | null; + expiresIn?: number; + machineId?: string; + authMethod?: string; + }) => ({ accessToken: tokens.accessToken, - refreshToken: null, + refreshToken: tokens.refreshToken ?? null, expiresIn: tokens.expiresIn || 86400, providerSpecificData: { machineId: tokens.machineId, - authMethod: "imported", + authMethod: tokens.authMethod || (tokens.refreshToken ? "deep_control" : "imported"), }, }), }; diff --git a/src/lib/oauth/services/cursorLogin.ts b/src/lib/oauth/services/cursorLogin.ts new file mode 100644 index 0000000000..efc919a91a --- /dev/null +++ b/src/lib/oauth/services/cursorLogin.ts @@ -0,0 +1,245 @@ +/** + * Cursor deep-control PKCE login + refresh. + * Protocol aligned with OpenCodex `src/oauth/cursor.ts` (loginDeepControl + auth/poll + + * exchange_user_api_key). Verifiers stay server-side in the session store. + */ + +import { randomUUID } from "node:crypto"; +import { refreshCursorToken as refreshCursorTokenOpenSse } from "@omniroute/open-sse/services/tokenRefresh/providers/cursor.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { generatePKCE } from "../utils/pkce"; +import { CURSOR_CONFIG } from "../constants/oauth"; + +const SESSION_TTL_MS = 15 * 60 * 1000; +const EXPIRY_SKEW_MS = 5 * 60 * 1000; +const FALLBACK_TTL_MS = 60 * 60 * 1000; + +export type CursorAuthParams = { + verifier: string; + challenge: string; + uuid: string; + loginUrl: string; +}; + +export type CursorTokenCredentials = { + accessToken: string; + refreshToken: string; + expiresAt: Date; + accountId?: string; + email?: string; +}; + +type CursorJwtPayload = { + sub?: unknown; + email?: unknown; + exp?: unknown; +}; + +type StoredSession = { + verifier: string; + challenge: string; + uuid: string; + loginUrl: string; + createdAt: number; + expiresAt: number; +}; + +const sessions = new Map(); + +function decodeCursorJwtPayload(token: string): CursorJwtPayload | undefined { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length !== 3 || !payload) return undefined; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as CursorJwtPayload; + } catch { + return undefined; + } +} + +function cursorJwtIdentity(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" && Number.isSafeInteger(value)) return String(value); + return undefined; +} + +function cursorJwtEmail(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + return value.toLowerCase(); +} + +/** Resolve token expiry (Date) from JWT `exp` minus skew; ~1h fallback. */ +export function getCursorTokenExpiry(token: string): Date { + const decoded = decodeCursorJwtPayload(token); + if (typeof decoded?.exp === "number") { + return new Date(decoded.exp * 1000 - EXPIRY_SKEW_MS); + } + return new Date(Date.now() + FALLBACK_TTL_MS); +} + +/** Build credentials from Cursor tokens, extracting stable identity from JWT `sub`. */ +export function credentialsFromCursorTokens( + accessToken: string, + refreshToken: string +): CursorTokenCredentials { + const payload = decodeCursorJwtPayload(accessToken) ?? decodeCursorJwtPayload(refreshToken); + const accountId = cursorJwtIdentity(payload?.sub); + const email = cursorJwtEmail(payload?.email); + return { + accessToken, + refreshToken, + expiresAt: getCursorTokenExpiry(accessToken), + ...(accountId ? { accountId } : {}), + ...(email ? { email } : {}), + }; +} + +/** Generate PKCE params + deep-control login URL (challenge only — never the verifier). */ +export async function generateCursorAuthParams(): Promise { + const { codeVerifier, codeChallenge } = generatePKCE(); + const uuid = randomUUID(); + const params = new URLSearchParams({ + challenge: codeChallenge, + uuid, + mode: "login", + redirectTarget: "cli", + }); + return { + verifier: codeVerifier, + challenge: codeChallenge, + uuid, + loginUrl: `${CURSOR_CONFIG.loginUrl}?${params.toString()}`, + }; +} + +function pruneExpiredSessions(now = Date.now()): void { + for (const [id, session] of sessions) { + if (session.expiresAt <= now) sessions.delete(id); + } +} + +export function clearCursorLoginSessions(): void { + sessions.clear(); +} + +/** + * Store verifier server-side. Returns public sessionId + loginUrl only. + * Multi-replica deployments need sticky sessions or a shared store. + */ +export function createCursorLoginSession(params: CursorAuthParams): { + sessionId: string; + loginUrl: string; +} { + pruneExpiredSessions(); + const sessionId = randomUUID(); + const now = Date.now(); + sessions.set(sessionId, { + verifier: params.verifier, + challenge: params.challenge, + uuid: params.uuid, + loginUrl: params.loginUrl, + createdAt: now, + expiresAt: now + SESSION_TTL_MS, + }); + return { sessionId, loginUrl: params.loginUrl }; +} + +/** Public view — never includes verifier. */ +export function getCursorLoginSession( + sessionId: string +): { uuid: string; loginUrl: string; expiresAt: number } | null { + pruneExpiredSessions(); + const session = sessions.get(sessionId); + if (!session) return null; + return { uuid: session.uuid, loginUrl: session.loginUrl, expiresAt: session.expiresAt }; +} + +/** Peek full session (for poll) without deleting. */ +export function peekCursorLoginSession(sessionId: string): StoredSession | null { + pruneExpiredSessions(); + return sessions.get(sessionId) ?? null; +} + +/** Consume (delete) full session — used after successful login or cancel. */ +export function consumeCursorLoginSession(sessionId: string): StoredSession | null { + pruneExpiredSessions(); + const session = sessions.get(sessionId) ?? null; + if (session) sessions.delete(sessionId); + return session; +} + +export function cancelCursorLoginSession(sessionId: string): boolean { + pruneExpiredSessions(); + return sessions.delete(sessionId); +} + +export type PollCursorOnceResult = + | { status: "pending" } + | { status: "ok"; accessToken: string; refreshToken: string } + | { status: "error"; message: string; httpStatus?: number }; + +/** + * Single poll attempt against Cursor auth/poll. + * 404 = still pending; 200 = tokens. Route layer owns the UI poll loop. + */ +export async function pollCursorAuthOnce( + uuid: string, + verifier: string, + signal?: AbortSignal +): Promise { + const url = `${CURSOR_CONFIG.pollUrl}?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`; + try { + const response = await fetch(url, { signal }); + if (response.status === 404) return { status: "pending" }; + if (response.ok) { + const data = (await response.json()) as { accessToken?: string; refreshToken?: string }; + if (!data.accessToken || !data.refreshToken) { + return { status: "error", message: "Cursor auth response missing tokens" }; + } + return { status: "ok", accessToken: data.accessToken, refreshToken: data.refreshToken }; + } + return { + status: "error", + message: `Cursor auth poll failed: ${response.status}`, + httpStatus: response.status, + }; + } catch (err) { + if (signal?.aborted) { + return { status: "error", message: "Cursor login cancelled" }; + } + const msg = err instanceof Error ? err.message : String(err); + return { status: "error", message: sanitizeErrorMessage(msg) }; + } +} + +export type RefreshCursorOptions = { + retryBaseMs?: number; + attempts?: number; +}; + +export type RefreshCursorResult = + CursorTokenCredentials | { error: "unrecoverable_refresh_error"; code: string } | null; + +/** + * Exchange a refresh token for fresh credentials (delegates to open-sse provider). + * Keeps the old refresh if the server omits one. 401/403 fail fast as unrecoverable. + */ +export async function refreshCursorAccessToken( + refreshToken: string, + log?: { error?: (...args: unknown[]) => void; info?: (...args: unknown[]) => void }, + options: RefreshCursorOptions = {} +): Promise { + const result = await refreshCursorTokenOpenSse(refreshToken, log, null, { + retryBaseMs: options.retryBaseMs, + attempts: options.attempts, + }); + if (!result) return null; + if ("error" in result) { + return { error: "unrecoverable_refresh_error", code: String(result.code || "unauthorized") }; + } + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresAt: new Date(result.expiresAt), + }; +} diff --git a/src/lib/oauth/services/persistCursorConnection.ts b/src/lib/oauth/services/persistCursorConnection.ts new file mode 100644 index 0000000000..1e577cd240 --- /dev/null +++ b/src/lib/oauth/services/persistCursorConnection.ts @@ -0,0 +1,76 @@ +/** + * Persist Cursor OAuth credentials (deep-control login or improved import). + */ + +import { + createProviderConnection, + getProviderConnections, + updateProviderConnection, +} from "@/models"; +import type { CursorTokenCredentials } from "./cursorLogin"; + +export type PersistCursorAuthMethod = "deep_control" | "imported" | "cursor-agent"; + +export type PersistCursorConnectionInput = CursorTokenCredentials & { + machineId?: string | null; + authMethod: PersistCursorAuthMethod; +}; + +function readAccountId(psd: unknown): string | null { + if (!psd || typeof psd !== "object") return null; + const rec = psd as Record; + for (const key of ["accountId", "userId"]) { + const v = rec[key]; + if (typeof v === "string" && v.length > 0) return v; + } + return null; +} + +/** + * Create or update a Cursor connection. Prefers accountId (JWT sub) match for + * multi-account safety; falls back to createProviderConnection email upsert. + */ +export async function persistCursorConnection(input: PersistCursorConnectionInput) { + const providerSpecificData = { + machineId: input.machineId || null, + authMethod: input.authMethod, + provider: input.authMethod === "deep_control" ? "Deep Control" : "Imported", + accountId: input.accountId || null, + userId: input.accountId || null, + ...(input.accountId ? { username: input.accountId } : {}), + }; + + if (input.accountId) { + const existing = (await getProviderConnections({ provider: "cursor" })) as Array<{ + id: string; + providerSpecificData?: unknown; + }>; + const match = existing.find( + (row) => readAccountId(row.providerSpecificData) === input.accountId + ); + if (match) { + return updateProviderConnection(match.id, { + accessToken: input.accessToken, + refreshToken: input.refreshToken, + expiresAt: input.expiresAt.toISOString(), + email: input.email || undefined, + providerSpecificData, + testStatus: "active", + lastError: null, + lastErrorType: null, + errorCode: null, + }); + } + } + + return createProviderConnection({ + provider: "cursor", + authType: "oauth", + accessToken: input.accessToken, + refreshToken: input.refreshToken, + expiresAt: input.expiresAt.toISOString(), + email: input.email || null, + providerSpecificData, + testStatus: "active", + }); +} diff --git a/src/lib/providerModels/cursorAvailableModels.ts b/src/lib/providerModels/cursorAvailableModels.ts new file mode 100644 index 0000000000..0fdc3d1939 --- /dev/null +++ b/src/lib/providerModels/cursorAvailableModels.ts @@ -0,0 +1,188 @@ +/** + * Fetch Cursor Available Models via HTTP (no cursor-agent binary required). + * Uses Connect-RPC JSON against api2.cursor.sh AiService/AvailableModels. + */ + +import { CURSOR_CONFIG } from "@/lib/oauth/constants/oauth"; +import { CursorService } from "@/lib/oauth/services/cursor"; +import { + humanizeCursorModelId, + type CursorAgentModelEntry, +} from "@/lib/providerModels/cursorAgent"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; + +export type FetchCursorAvailableModelsOptions = { + accessToken: string; + machineId?: string | null; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function pickModelId(entry: Record): string | null { + for (const key of ["name", "modelId", "model_id", "id", "slug"]) { + const v = entry[key]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return null; +} + +function pickModelName(entry: Record, id: string): string { + for (const key of ["displayName", "display_name", "title", "label"]) { + const v = entry[key]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return humanizeCursorModelId(id); +} + +/** + * Normalize AvailableModels JSON (Connect JSON or protobuf-json) into catalog rows. + * Exported for unit tests. + * + * Always ensures catalog id `auto` is present (Cursor often returns wire id `default` + * only). OmniRoute clients request `cu/auto`; resolveRequestedModel maps it to `default`. + */ +export function normalizeCursorAvailableModelsPayload(payload: unknown): CursorAgentModelEntry[] { + const root = asRecord(payload) ?? {}; + const candidates: unknown[] = []; + + for (const key of ["models", "availableModels", "available_models", "model"]) { + const v = root[key]; + if (Array.isArray(v)) candidates.push(...v); + } + + // Some Connect JSON responses nest under `models.models` or similar + const nestedModels = asRecord(root.models); + if (nestedModels) { + for (const key of ["models", "items", "list"]) { + const v = nestedModels[key]; + if (Array.isArray(v)) candidates.push(...v); + } + } + + if (Array.isArray(payload)) candidates.push(...payload); + + const seen = new Set(); + const out: CursorAgentModelEntry[] = []; + for (const item of candidates) { + if (typeof item === "string" && item.trim()) { + const id = item.trim(); + if (seen.has(id)) continue; + seen.add(id); + out.push({ id, name: humanizeCursorModelId(id), owned_by: "cursor" }); + continue; + } + const rec = asRecord(item); + if (!rec) continue; + const id = pickModelId(rec); + if (!id || seen.has(id)) continue; + // Prefer usable / non-disabled when flags exist + if (rec.disabled === true || rec.isDisabled === true) continue; + if (rec.usable === false || rec.isUsable === false) continue; + seen.add(id); + out.push({ id, name: pickModelName(rec, id), owned_by: "cursor" }); + } + + return ensureCursorAutoCatalogEntry(out); +} + +/** OpenCodex-style Cursor Router optimization modes (catalog ids). */ +export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [ + "auto-cost", + "auto-balance", + "auto-intelligence", +] as const; + +const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record< + (typeof CURSOR_AUTO_ROUTER_VARIANT_IDS)[number], + string +> = { + "auto-cost": "Auto (cost)", + "auto-balance": "Auto (balance)", + "auto-intelligence": "Auto (intelligence)", +}; + +/** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */ +export function ensureCursorAutoCatalogEntry( + models: CursorAgentModelEntry[] +): CursorAgentModelEntry[] { + const byId = new Map(models.map((m) => [m.id, m])); + const out = [...models]; + + if (!byId.has("auto")) { + const defaultEntry = byId.get("default"); + const autoEntry: CursorAgentModelEntry = { + id: "auto", + name: defaultEntry?.name || "Auto (current, default)", + owned_by: "cursor", + }; + // Prefer `auto` as the public id; keep `default` for wire-compat listings. + out.unshift(autoEntry); + byId.set("auto", autoEntry); + } + + // Always expose Cost/Balance/Intelligence router modes (OpenCodex CURSOR_ROUTER_MODEL_IDS). + for (const id of CURSOR_AUTO_ROUTER_VARIANT_IDS) { + if (byId.has(id)) continue; + const entry: CursorAgentModelEntry = { + id, + name: CURSOR_AUTO_ROUTER_VARIANT_NAMES[id], + owned_by: "cursor", + }; + out.push(entry); + byId.set(id, entry); + } + + return out; +} + +export async function fetchCursorAvailableModels( + options: FetchCursorAvailableModelsOptions +): Promise { + const { accessToken, signal } = options; + if (!accessToken) throw new Error("Cursor access token is required for AvailableModels"); + + const machineId = options.machineId || (await getConsistentMachineId()); + const cursorService = new CursorService(); + const headers = { + ...cursorService.buildHeaders(accessToken, machineId), + // Prefer Connect JSON so we can parse without a protobuf schema + "Content-Type": "application/json", + Accept: "application/json", + }; + + const url = `${CURSOR_CONFIG.apiEndpoint}${CURSOR_CONFIG.modelsEndpoint}`; + const fetchImpl = options.fetchImpl || fetch; + const response = await fetchImpl(url, { + method: "POST", + headers, + body: "{}", + signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Cursor AvailableModels failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}` + ); + } + + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("proto") || contentType.includes("protobuf")) { + throw new Error( + "Cursor AvailableModels returned protobuf; JSON catalog unavailable for this client version" + ); + } + + const payload = await response.json(); + const models = normalizeCursorAvailableModelsPayload(payload); + if (models.length === 0) { + throw new Error("Cursor AvailableModels returned no models"); + } + return models; +} diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 3e38eb3dbc..4927c4823f 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -718,10 +718,12 @@ export async function checkConnection(conn) { // cosmetic "Token Expired". Surface reality as a terminal "expired" status instead. // Guard tightly so we do NOT clobber: // - providers without refresh tokens (supportsTokenRefresh=false; #8407 devin-cli) + // - Cursor access-token-only imports (refresh is optional; deep-control stores one) // - connections already in a terminal/specific state (expired/banned/credits_exhausted) // - transient cooldown state (unavailable) owned by the request path const refreshCapableNeedsReauth = supportsTokenRefresh(conn.provider) && + conn.provider !== "cursor" && (!conn.testStatus || conn.testStatus === "active") && !(conn.apiKey && conn.apiKey.length > 0); // API-key-only connections don't need refresh tokens if (refreshCapableNeedsReauth) { diff --git a/src/shared/components/CursorAuthModal.tsx b/src/shared/components/CursorAuthModal.tsx index 53de6a4983..b56ce06646 100644 --- a/src/shared/components/CursorAuthModal.tsx +++ b/src/shared/components/CursorAuthModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; import Button from "./Button"; @@ -13,9 +13,12 @@ type CursorAuthModalProps = { reauthConnection?: unknown; }; +type AuthTab = "login" | "import"; + /** * Cursor Auth Modal - * Auto-detect and import token from Cursor IDE's local SQLite database + * Primary: deep-control PKCE login (Docker-friendly). + * Secondary: IDE / paste-token import (optional refresh token). */ export default function CursorAuthModal({ isOpen, @@ -24,43 +27,161 @@ export default function CursorAuthModal({ reauthConnection: _, }: CursorAuthModalProps) { const t = useTranslations("cursorAuthModal"); + const [tab, setTab] = useState("login"); const [accessToken, setAccessToken] = useState(""); + const [refreshToken, setRefreshToken] = useState(""); const [machineId, setMachineId] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [importing, setImporting] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false); const [autoDetected, setAutoDetected] = useState(false); + const [dockerHint, setDockerHint] = useState(false); + + const [loginUrl, setLoginUrl] = useState(""); + const [sessionId, setSessionId] = useState(""); + const [loginStarting, setLoginStarting] = useState(false); + const [loginPolling, setLoginPolling] = useState(false); + const pollAbortRef = useRef(false); - // Auto-detect tokens when modal opens useEffect(() => { - if (!isOpen) return; + if (!isOpen) { + pollAbortRef.current = true; + return; + } + + pollAbortRef.current = false; + + // Reset modal UI state for this open. Nested in a function (like autoDetect + // below) rather than called directly in the effect body, since these are a + // response to the modal being (re)opened — not a synchronization of React + // state with an external system — and react-hooks/set-state-in-effect flags + // direct top-level setState calls in an effect. + const resetModalState = () => { + setTab("login"); + setError(null); + setLoginUrl(""); + setSessionId(""); + setLoginPolling(false); + // Detect Docker-ish environments for import-tab guidance + setDockerHint(false); + }; + resetModalState(); const autoDetect = async () => { setAutoDetecting(true); - setError(null); setAutoDetected(false); - try { const res = await fetch("/api/oauth/cursor/auto-import"); const data = await res.json(); - if (data.found) { - setAccessToken(data.accessToken); + setAccessToken(data.accessToken || ""); + setRefreshToken(data.refreshToken || ""); setMachineId(data.machineId || ""); setAutoDetected(true); } else { - setError(data.error || t("errorAutoDetect")); + // Soft hint only on import tab; don't block login tab + if ( + typeof data.error === "string" && + /docker|config files found|not appear/i.test(data.error) + ) { + setDockerHint(true); + } } - } catch (err) { - setError(t("errorAutoDetectFailed")); + } catch { + /* ignore — login tab is primary */ } finally { setAutoDetecting(false); } }; - autoDetect(); + void autoDetect(); }, [isOpen]); + useEffect(() => { + return () => { + pollAbortRef.current = true; + }; + }, []); + + const handleStartLogin = async () => { + setLoginStarting(true); + setError(null); + setLoginUrl(""); + setSessionId(""); + try { + const res = await fetch("/api/oauth/cursor/login/start", { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || t("errorLoginStart")); + setLoginUrl(data.loginUrl); + setSessionId(data.sessionId); + if (typeof window !== "undefined" && data.loginUrl) { + window.open(data.loginUrl, "_blank", "noopener,noreferrer"); + } + void pollUntilDone(data.sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : t("errorLoginStart")); + } finally { + setLoginStarting(false); + } + }; + + const pollUntilDone = async (sid: string) => { + setLoginPolling(true); + pollAbortRef.current = false; + const maxAttempts = 150; + let delayMs = 1000; + try { + for (let i = 0; i < maxAttempts; i++) { + if (pollAbortRef.current) return; + await new Promise((r) => setTimeout(r, delayMs)); + if (pollAbortRef.current) return; + + const res = await fetch("/api/oauth/cursor/login/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: sid }), + }); + const data = await res.json(); + + if (data.status === "pending") { + delayMs = Math.min(delayMs * 1.2, 10_000); + continue; + } + if (data.status === "ok" || data.success) { + onSuccess?.(); + onClose(); + return; + } + throw new Error(data.error || t("errorLoginPoll")); + } + throw new Error(t("errorLoginTimeout")); + } catch (err) { + if (!pollAbortRef.current) { + setError(err instanceof Error ? err.message : t("errorLoginPoll")); + } + } finally { + setLoginPolling(false); + } + }; + + const handleCancelLogin = async () => { + pollAbortRef.current = true; + if (sessionId) { + try { + await fetch("/api/oauth/cursor/login/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + } catch { + /* ignore */ + } + } + setLoginPolling(false); + setLoginUrl(""); + setSessionId(""); + }; + const handleImportToken = async () => { if (!accessToken.trim()) { setError(t("errorEnterToken")); @@ -73,6 +194,7 @@ export default function CursorAuthModal({ try { const body: Record = { accessToken: accessToken.trim() }; if (machineId.trim()) body.machineId = machineId.trim(); + if (refreshToken.trim()) body.refreshToken = refreshToken.trim(); const res = await fetch("/api/oauth/cursor/import", { method: "POST", @@ -83,67 +205,122 @@ export default function CursorAuthModal({ const data = await res.json(); if (!res.ok) { - throw new Error(data.error || t("errorImportFailed")); + throw new Error( + typeof data.error === "string" + ? data.error + : data.error?.message || t("errorImportFailed") + ); } - // Success - close modal and trigger refresh onSuccess?.(); onClose(); } catch (err) { - setError(err.message); + setError(err instanceof Error ? err.message : t("errorImportFailed")); } finally { setImporting(false); } }; + const handleClose = () => { + void handleCancelLogin(); + onClose(); + }; + return ( - +
- {/* Auto-detecting state */} - {autoDetecting && ( -
-
- - progress_activity - +
+ + +
+ + {tab === "login" && ( +
+

{t("loginDescription")}

+ + {loginPolling && ( +
+
+ + progress_activity + +
+

{t("waitingApproval")}

+ {loginUrl && ( +

+ {t("openUrlHint")}{" "} + + {t("openLoginLink")} + +

+ )} +
+ )} + + {error && ( +
+

{error}

+
+ )} + +
+ {!loginPolling ? ( + + ) : ( + + )} +
-

{t("autoDetecting")}

-

{t("readingFromCursor")}

)} - {/* Form (shown after auto-detect completes) */} - {!autoDetecting && ( - <> - {/* Success message if auto-detected */} - {autoDetected && ( + {tab === "import" && ( +
+ {autoDetecting && ( +
+

{t("autoDetecting")}

+
+ )} + + {!autoDetecting && autoDetected && (
-
- - check_circle - -

- {t("tokensAutoDetected")} -

-
+

+ {t("tokensAutoDetected")} +

)} - {/* Info message if not auto-detected */} - {!autoDetected && !error && ( + {!autoDetecting && !autoDetected && (
-
- - info - -

- {t("cursorNotDetected")} -

-
+

+ {dockerHint ? t("dockerImportHint") : t("cursorNotDetected")} +

)} - {/* Access Token Input */}
- {/* Machine ID Input (optional — not needed for cursor-agent imports) */} +
+ +