cursor-opencode-provider 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/agent-url.js +2 -2
- package/dist/auth-renewal.d.ts +85 -0
- package/dist/auth-renewal.js +284 -0
- package/dist/auth.d.ts +38 -22
- package/dist/auth.js +79 -96
- package/dist/classic-tools.d.ts +15 -0
- package/dist/classic-tools.js +45 -0
- package/dist/context/build.d.ts +42 -5
- package/dist/context/build.js +96 -92
- package/dist/context/dynamic-catalog.d.ts +2 -58
- package/dist/context/dynamic-catalog.js +10 -160
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +37 -14
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +7 -4
- package/dist/context/paths.js +13 -9
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +4 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +27 -3
- package/dist/host-agent-mode.js +2 -2
- package/dist/image-save.js +3 -3
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +41 -8
- package/dist/language-model.js +391 -98
- package/dist/opencode2/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- package/dist/plan-execution-kickoff.js +2 -2
- package/dist/plugin-opencode2.js +10 -16
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +138 -109
- package/dist/protocol/blob-store.js +3 -2
- package/dist/protocol/conversation-bind.js +1 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +11 -1
- package/dist/protocol/conversation-state.d.ts +2 -0
- package/dist/protocol/conversation-state.js +19 -0
- package/dist/protocol/create-plan.js +2 -1
- package/dist/protocol/messages.js +22 -4
- package/dist/protocol/request.d.ts +11 -9
- package/dist/protocol/request.js +15 -25
- package/dist/protocol/tools.d.ts +17 -10
- package/dist/protocol/tools.js +53 -93
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/session.d.ts +9 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- package/package.json +1 -1
- package/dist/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/context/skills.d.ts +0 -11
- package/dist/context/skills.js +0 -110
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ The provider never detects or names fork hosts. Compatibility layers own host id
|
|
|
132
132
|
| Model, version, and 24-hour conversation **cache** | `~/.cache/opencode/` | A host bridge may replace the root |
|
|
133
133
|
| Cursor **project metadata** (`agent-tools`, terminals, …) | `~/.cache/opencode/projects/<slug>/` | under `<host-cache>/projects/` |
|
|
134
134
|
| OpenCode **auth** (`auth.json`) | `~/.local/share/opencode/` | `$XDG_DATA_HOME/opencode/` when set |
|
|
135
|
-
| OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` |
|
|
135
|
+
| OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` | `$XDG_CONFIG_HOME/opencode/` when set, as OpenCode resolves it |
|
|
136
136
|
|
|
137
137
|
### Select a model
|
|
138
138
|
|
|
@@ -220,7 +220,7 @@ const model = cursor.languageModel("composer-2.5")
|
|
|
220
220
|
// model implements AI SDK LanguageModelV3 (doStream / doGenerate)
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
Give one credential source. The provider uses the first present and never falls back from one to another: `getAccessToken` (an async function returning the current access token, called for every Run so a long-running process can renew its login; it receives `{ forceRefresh: true }` once after Cursor rejects a token), then `accessToken` (a JWT sent as-is, never renewed), then `apiKey` (a raw `crsr_...` key, exchanged for a JWT and exchanged again near its expiry, the way Cursor CLI renews an API-key login). Optional: `apiBaseURL`, `agentBaseURL`, `cacheDir`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. `cacheDir` pins the host cache root for model/version caches and Cursor project metadata; when omitted, the provider uses an injected structural host path bridge or the native OpenCode XDG cache described in [Paths](#paths-host-cache). Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Once that budget is spent, or replay is unsafe, the terminal error does not re-arm OpenCode's outer retry loop. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
|
|
224
224
|
|
|
225
225
|
## Environment variables
|
|
226
226
|
|
|
@@ -268,7 +268,7 @@ OpenCode
|
|
|
268
268
|
|
|
269
269
|
| Module | Role |
|
|
270
270
|
|--------|------|
|
|
271
|
-
| `src/plugin.ts` | Classic OpenCode hooks: provider registration, OAuth, API key exchange,
|
|
271
|
+
| `src/plugin.ts` | Classic OpenCode hooks: provider registration, OAuth, API key exchange, login renewal and persistence |
|
|
272
272
|
| `src/plugin-v2.ts` | OpenCode 1.18 Effect/Promise v2 plugin (`ctx.aisdk.*`); load via `./plugin/v2` only |
|
|
273
273
|
| `src/plugin-opencode2.ts` | OpenCode 2.0 plugin (`provider.transform`, integration, tools, aisdk, `shell.create.before`, `websearch`, plan kickoff); load via `./plugin/opencode2` or `./server` |
|
|
274
274
|
| `src/opencode2/` | 2.0-only provider inventory, integration/auth, and local API types |
|
|
@@ -280,7 +280,8 @@ OpenCode
|
|
|
280
280
|
| `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
|
|
281
281
|
| `src/session.ts` | Held-open agent Run session and pending exec correlation |
|
|
282
282
|
| `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
|
|
283
|
-
| `src/auth.ts` | PKCE OAuth, API key exchange,
|
|
283
|
+
| `src/auth.ts` | PKCE OAuth, API key exchange, browser-login session refresh (`/oauth/token`) |
|
|
284
|
+
| `src/auth-renewal.ts` | Credential renewal policy: session and API-key renewal kept separate, backoff, on-demand renewal, Bearer resolution |
|
|
284
285
|
| `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
|
|
285
286
|
| `src/protocol/conversation-persistence.ts` | Atomic per-session restart snapshots under the 24-hour `cursor-conversations/` cache |
|
|
286
287
|
| `src/context/paths.ts` | Host cache root + Cursor project metadata under `<host-cache>/projects/<slug>/` |
|
|
@@ -293,7 +294,7 @@ OpenCode
|
|
|
293
294
|
|
|
294
295
|
The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, preferring OpenCode `todowrite` / `todoread` over Cursor TodoWrite when those tools are advertised without inviting catalog-comparison narration, and preferring `edit` / `write` (or `apply_patch`, when the host advertises that instead) over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
|
|
295
296
|
|
|
296
|
-
Cursor keeps OpenCode `skill` and configured MCP tools off its native top-level function list on **both** OpenCode 1.x and OpenCode 2.0 — they appear as MCP descriptors and are reached through GetDynamicTools / CallDynamicTool. When the host advertises `skill` and/or tools of MCP servers configured in `opencode.json`, the provider names those routes in system guidance and mirrors OpenCode's load-when-matches / do-not-reinvoke rules
|
|
297
|
+
Cursor keeps OpenCode `skill` and configured MCP tools off its native top-level function list on **both** OpenCode 1.x and OpenCode 2.0 — they appear as names-only MCP descriptors and are reached through GetDynamicTools / CallDynamicTool (exec #36 supplies full definitions). When the host advertises `skill` and/or tools of MCP servers configured in `opencode.json`, the provider names those routes in system guidance and mirrors OpenCode's load-when-matches / do-not-reinvoke rules so models prefer `skill` / MCP over Grep/Shell and do not narrate that those tools are unavailable. Skill catalog changes after admission are host `<system-update>` notes, not a second RequestContext skill list. OpenCode 2 still moves MCP servers that did not set `"codemode": true` onto the direct AI SDK catalog first; without that placement they never reach Cursor's MCP ads at all (see [OpenCode 2 MCP tools](docs/opencode-2.md#mcp-tools)).
|
|
297
298
|
|
|
298
299
|
Native Cursor todo display updates (`update_todos_tool_call` / `read_todos_tool_call`) are mirrored into `todowrite` / `todoread` when those host tools are advertised. OpenCode 2 dropped the host builtins; set `CURSOR_OPENCODE2_TODOS=1` (or `true`) to register the same canonical names when the catalog omits them (in-memory per session, cleared on `session.deleted` and process restart). With the flag unset, the plugin registers no todo tools. Enabled plugin tools are **direct** catalog tools (`options.codemode: false` plus an `output` schema), not Code Mode helpers. The provider retains the last known full list per OpenCode session — refreshed by bridged writes, direct host `todowrite` calls, and `todoread` results, surviving Run close, checkpoint resume, and later turns — so `merge: true` updates that omit Cursor's completed list still become a host replace-all snapshot (matched by id, then by content across id spaces). This snapshot is in-memory only and never enters the prompt, RequestContext, or persisted restart state, so Cursor's prompt-cache prefix is unaffected; a provider process restart clears it until the next write/read (intentional — see `tasks/lessons.md`).
|
|
299
300
|
|
|
@@ -346,7 +347,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
|
|
|
346
347
|
|---------|-------------|
|
|
347
348
|
| No Cursor models in the picker (OpenCode 1.x) | Confirm Cursor auth (`opencode auth login` → **cursor**). Restart OpenCode — if auth is present and the cache is empty, models are fetched on startup. Confirm `provider.cursor.npm` is the package name (or a built `file://…/dist/index.js`). See [OpenCode 1.x troubleshooting](docs/opencode-1.md#troubleshooting). |
|
|
348
349
|
| No Cursor models in the picker (`opencode2`) | Confirm `/connect` → **Cursor** (or shared `auth.json`). Use a dedicated `OPENCODE_CONFIG_DIR` (not mixed with 1.x). Ensure `$OPENCODE_CONFIG_DIR/plugins/cursor/` is a package directory re-exporting `plugin/opencode2` (not a bare `.js`). After auth, Cursor models appear in the picker from the in-memory inventory — no `opencode.json` model list is required. Filter by provider **Cursor** (`time.released` is `0`, so models sort last). Leftover `providers.cursor` fights that inventory — [safe transition](docs/opencode-2.md#safe-transition). |
|
|
349
|
-
| Auth / 401 errors mid-session |
|
|
350
|
+
| Auth / 401 errors mid-session | Logins renew themselves: a browser login (60-day session) is renewed through Cursor's `/oauth/token` once less than 1272 h of it remain, as Cursor's IDE decides, whenever a request needs a token (there is no background timer, as with OpenCode's own OAuth providers); an API-key login exchanges its stored key again within five minutes of the exchanged JWT's expiry, as Cursor CLI does. The two never stand in for each other. Sign in again when the error says the session was ended, the key was rejected, or a sign-in policy blocks it. An API-key login saved before the key was kept (`metadata.apiKey`) cannot be renewed and needs one fresh API-key sign-in. Renewal attempts and failures go to the debug log (`CURSOR_PROVIDER_DEBUG`). |
|
|
350
351
|
| Local OpenCode 2.0 still runs the published package | Set `CURSOR_OPENCODE2_DEV_ENTRY` to an absolute `…/dist/index.js` path **before** starting the daemon, persist it with `opencode2 service set env CURSOR_OPENCODE2_DEV_ENTRY …`, rebuild (`bun run build`), then `opencode2 service restart`. Loading only `dist/plugin-opencode2.js` is not enough — without the env var, 2.0 still `npm install`s the published package into the host cache. |
|
|
351
352
|
| “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
|
|
352
353
|
| Empty or stale model list | Delete `<host-cache>/cursor-models.json` (native default `~/.cache/opencode/`) and restart OpenCode. A compatibility layer may supply a different host-cache root through the structural path bridge. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
|
|
@@ -367,7 +368,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
367
368
|
|
|
368
369
|
- **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
|
|
369
370
|
- **Cursor's checkpoint controls displayed totals** — live continuation captures show `TurnEnded` counters can decrease between turns and can represent aggregate model work rather than final context occupancy. When a checkpoint contains `ConversationStateStructure.token_details`, the provider makes AI SDK input + output equal Cursor's `used_tokens`; at `TurnEnded`, output/reasoning remain based on `TurnEnded`, while uncached/cache-read/cache-write input are proportionally normalized to the remaining context input. OpenCode TUI/GUI replace each assistant message's tokens (TUI requires `tokens.output > 0`) and add cost per step, so mid-turn tool boundaries emit the last-known occupancy snapshot rather than a delta or a billed zero. Those snapshots set `output=1` so the footer updates and `providerMetadata.copilot.totalNanoAiu: 0` so OpenCode's getUsage reports $0 instead of charging occupancy as a new prompt. A Run without fresh token details retains the previous checkpoint snapshot and marks `providerMetadata.cursor.context` as `source: "checkpoint-previous-turn", stale: true`. If no checkpoint has ever supplied token details, standard usage is zero and context metadata is absent, so `TurnEnded` is never misrepresented as occupancy; its exact values remain under `providerMetadata.cursor.*Raw`. The full known snapshot includes `usedTokens`, `maxTokens`, percentage, category breakdown, source, and staleness. Cursor consistently sends `cache_write=0` in captured Runs, so no alternate write count is available.
|
|
370
|
-
- **`request_context` from OpenCode** — each Run sends Cursor `RequestContext`
|
|
371
|
+
- **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` with OpenCode's system prompt (its `AGENTS.md` / `instructions`, skill list, MCP instructions) plus the provider's interaction guidance as one always-apply rule, workspace env, git, layout, host-advertised `task`/`subagent` names, names-only tool overlay, and plugin lines. Cursor does not follow a client-seeded `system` message or an untyped rule, so the always-apply (`global`) rule is the channel that makes `AGENTS.md` effective; the provider does not read instruction or skill files itself. Merged `opencode.json` is still read for MCP server ids, plugin lists, and interaction guidance. Expensive workspace state (environment, repository, git, and layout) is frozen per Cursor conversation and persisted for restart recovery. Tools/MCP names, host subagents, and plugin metadata are rediscovered each Run then epoch-held (equal ids keep frozen bytes; new ids append); unchanged overlays reuse byte-identical encoded context. Concurrent first Runs share one base build, and compaction/reset clears it. Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Cursor-only cloud/sandbox marketplace surfaces are omitted. `env.workspace_paths` / `process_working_directory` stay on the real git workspace; Cursor's metadata root (`project_folder`, MCP `workspace_project_dir`, terminals/transcripts) is advertised under `<host-cache>/projects/<slug>/` (default `~/.cache/opencode/projects/…`) so dumps like `agent-tools/` do not land in the repo. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
|
|
371
372
|
- **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools` / GetDynamicTools; exec still reconstructs the full OpenCode tool id. That discovery call is Cursor-native (not an OpenCode catalog tool). Oversized catalogs spill through `write_args` into `agent-tools/<uuid>.txt`; `WriteSuccess.path` echoes the requested path so the model receives a real `filePath` instead of an empty spill.
|
|
372
373
|
- **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` → `todowrite`, `read_todos_tool_call` → `todoread`, `create_plan_tool_call` → `todowrite`); the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name. Known-but-unsupported variants are soft-denied on the held Run with a populated typed error/`rejected`/`allowlisted:false` result (or `throw` when the CLI has no typed error shape) so the turn continues; unknown future request fields still fail the Run explicitly rather than receiving a guessed response that could deadlock it.
|
|
373
374
|
- **Progress-only fragments can get one continuation** — if a real tool-enabled turn ends with a short progress fragment ("Checking the workspace", "Looking into it now") and no host tool call, the provider opens one follow-up Run with a synthetic user nudge so the model can call a listed tool or finish the answer. Complete answers that merely start with those verbs are not continued. Title/compaction turns (`allowTools=false`) never continue. If the continuation fails, the original `turn_ended` is still reported as a successful stop.
|
|
@@ -378,6 +379,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
378
379
|
- **Cursor-native AskQuestion reaches you through OpenCode's question tool** — when Cursor asks a clarifying question, the provider translates the interaction into the host `question` tool, so the prompt appears in OpenCode with its real options and your answer is mapped back to Cursor's option ids (an answer you type yourself becomes Cursor's freeform text). Questions Cursor marks as non-blocking are acknowledged immediately and answered later, matching Cursor CLI. If the current agent does not offer `question` — most subagents deny it, and compaction turns have no tools — the request is declined with that reason and the model asks in its reply instead.
|
|
379
380
|
- **Cursor-native SwitchMode follows each OpenCode generation's planning model** — on OpenCode 1.x, advertised `plan_enter` / `plan_exit` tools remain authoritative and Cursor waits for their approve/reject result. OpenCode 2.0 replaced those tools with vendor-maintained primary `plan` / `build` agents, so its plugin applies an approved Cursor mode change through `session.switchAgent` after the owning Run is terminal; leaving plan still uses the advertised `question` gate when no `plan_exit` tool exists. Direct host UI agent switches are also observed. Any real host-agent or stable system-prompt change rotates and reseeds the Cursor conversation instead of resuming an incompatible checkpoint, while title/generation lifecycle prompts remain ephemeral and do not evict the primary cache. Provider-owned Cursor reminders remain the fallback when a host cannot switch agents.
|
|
380
381
|
- **Cursor image generation saves through OpenCode's permission where the host API exposes it** — Cursor generates the image on its servers and then writes it like any other file. On the classic plugin/OCP surfaces, the provider holds raw bytes and asks for the same permissions as a normal write (`external_directory`, then `edit`) before saving byte-for-byte. OpenCode 2.0's public plugin `ToolContext` does not expose a permission-request method, so its entrypoint deliberately does not advertise `cursor_image_save`; generation and binary writes are refused before staging instead of bypassing permissions or exposing an always-failing tool. By default supported-host images land in the Cursor project folder under `assets/`, kept out of the git tree as agent artifacts. Editing an existing image with `reference_image_paths` is not supported yet. The classic implementation is adapted from [`jkalasas/opencode-antigravity-image`](https://github.com/jkalasas/opencode-antigravity-image) (MIT) by jkalasas.
|
|
382
|
+
- **Switching models mid-session reseeds Cursor instead of resuming a stale checkpoint** — a Cursor conversation only contains what Cursor itself produced. When the host's latest assistant turn in a session came from another model (another provider or a local model), the provider starts a fresh Cursor conversation seeded with the full host history, tool results included, rather than resuming the old checkpoint that would hide the other model's work (and has been observed to end Runs with `Cursor API error (code=internal)`). If that history would fill more than 80% of the target model's context, the provider reports a context overflow before opening a Run so the host compacts first.
|
|
381
383
|
- **Cursor-native CreatePlan writes a host plan file, then asks before executing it** — when Cursor raises `create_plan_request_query`, the provider writes plain markdown to your host's own plans directory (`<hostGlobalDataDir>/plans/`, never your repository and never Cursor's `~/.cursor/plans`), with no Cursor YAML frontmatter. Writing the plan does not ask; running it does. If you are in plan mode, the provider prints the plan into the conversation — Cursor sends it over a side channel that would otherwise never display it — and then asks *"Plan at <path> is complete. Would you like to switch to the build agent and start implementing?"* — the same prompt OpenCode's own `plan_exit` uses — and keeps Cursor waiting for your answer. Answer **Yes** and the plan is reported as approved and implementation begins; answer **No** (or dismiss it) and Cursor is told the plan was not accepted, so the model refines it and proposes again instead of starting work. Hosts that advertise their own plan-staging tool run their native review UI instead, and it reports the same approve/refine outcome. Empty args still get the CLI empty-`plan_uri` success ack. Display `create_plan_tool_call` continues to mirror into `todowrite` separately.
|
|
382
384
|
- **Other Cursor-native interaction queries remain headless** — remaining UI/approval *queries* (as distinct from display tool calls and the AskQuestion / SwitchMode / CreatePlan / GenerateImage bridges) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects available web capabilities to executable host tools (`custom_websearch`, `custom_webfetch`); native web/PR/MCP/SCM requests are declined so they remain behind host tool permissions. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
|
|
383
385
|
- **Compaction resets Cursor conversation state without resetting stable workspace context** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool outputs as OpenCode-host observations in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Across both id rotations, the frozen workspace/rules/environment base and its prior encoded-context comparison seed are transferred instead of rebuilt; tools, skills, agents, plugins, and MCP state are still rediscovered, and the complete context is reused only when its bytes remain identical. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state. Debug logs include system-prompt and encoded RequestContext hashes for cache-prefix verification.
|
package/dist/agent-url.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { fetchAgentUrl } from "./transport/connect.js";
|
|
3
|
-
import { trace } from "./debug.js";
|
|
3
|
+
import { errorMessage, trace } from "./debug.js";
|
|
4
4
|
import { CURSOR_API_HOST } from "./shared.js";
|
|
5
5
|
const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
|
|
6
6
|
// In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
|
|
@@ -59,7 +59,7 @@ export async function resolveAgentUrl(token, options = {}) {
|
|
|
59
59
|
return url;
|
|
60
60
|
}
|
|
61
61
|
catch (err) {
|
|
62
|
-
const reason =
|
|
62
|
+
const reason = errorMessage(err);
|
|
63
63
|
trace(`agent-url: GetServerConfig failed (${reason}); no fallback agent host will be used`);
|
|
64
64
|
throw err;
|
|
65
65
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Cursor IDE renews a session once less than this remains (`hir = 1272h`). */
|
|
2
|
+
export declare const SESSION_RENEWAL_WINDOW_MS: number;
|
|
3
|
+
/** Never renew a session token younger than this, whatever its lifetime. */
|
|
4
|
+
export declare const SESSION_MIN_AGE_MS = 3600000;
|
|
5
|
+
export type TokenTimes = {
|
|
6
|
+
issuedAtMs?: number;
|
|
7
|
+
expiresAtMs?: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Issue and expiry times of a Cursor JWT. Cursor session tokens carry the
|
|
11
|
+
* issue time in a `time` claim (seconds, as a string); standard `iat` is used
|
|
12
|
+
* when present instead.
|
|
13
|
+
*/
|
|
14
|
+
export declare function tokenTimes(token: string): TokenTimes;
|
|
15
|
+
/**
|
|
16
|
+
* When a session token is due for renewal: 1272 h before expiry, but never
|
|
17
|
+
* within an hour of issue. Undefined when the token has no readable expiry.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sessionRenewalDueAt(token: string): number | undefined;
|
|
20
|
+
export type SessionTokens = {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
};
|
|
24
|
+
export type SessionRenewal = {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
/** True when `accessToken` differs from the stored one and should be persisted. */
|
|
27
|
+
renewed: boolean;
|
|
28
|
+
/** Set while a failed renewal is backing off: when the next attempt is allowed. */
|
|
29
|
+
retryAt?: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Follow renewals this process already made, so a caller still holding an
|
|
33
|
+
* older stored credential (persisting failed, or a host that persists only
|
|
34
|
+
* later) gets the newest token instead of renewing again.
|
|
35
|
+
*/
|
|
36
|
+
export declare function latestSessionTokens(tokens: SessionTokens): SessionTokens;
|
|
37
|
+
export declare function isSessionRenewalDue(token: string, now?: number): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Return a usable session token, renewing it first when it is due (or when
|
|
40
|
+
* `force` is set after Cursor rejected it). Renewal failures that leave the
|
|
41
|
+
* current token valid keep it; a session Cursor ended, or one that expired
|
|
42
|
+
* and could not be renewed, raises `CursorAuthError`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function renewSessionIfDue(tokens: SessionTokens, options?: {
|
|
45
|
+
baseUrl?: string;
|
|
46
|
+
force?: boolean;
|
|
47
|
+
}): Promise<SessionRenewal>;
|
|
48
|
+
export type ApiKeyToken = {
|
|
49
|
+
accessToken: string;
|
|
50
|
+
/** True when `accessToken` differs from the stored `seed` and should be persisted. */
|
|
51
|
+
renewed: boolean;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* JWT for a raw `crsr_` API key: the cached or `seed` JWT while it is not
|
|
55
|
+
* within five minutes of expiry, otherwise a fresh exchange. Cached per API
|
|
56
|
+
* base URL and key. Never returns the raw key itself.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveApiKeyToken(apiKey: string, options?: {
|
|
59
|
+
baseUrl?: string;
|
|
60
|
+
seed?: string;
|
|
61
|
+
force?: boolean;
|
|
62
|
+
}): Promise<ApiKeyToken>;
|
|
63
|
+
export type AccessTokenRequest = {
|
|
64
|
+
forceRefresh?: boolean;
|
|
65
|
+
};
|
|
66
|
+
/** Host-supplied source of the current Cursor access token. */
|
|
67
|
+
export type AccessTokenProvider = (request?: AccessTokenRequest) => Promise<string>;
|
|
68
|
+
/**
|
|
69
|
+
* Bearer token for a Cursor request. One credential source is used, in this
|
|
70
|
+
* order, and none of them falls back to another:
|
|
71
|
+
*
|
|
72
|
+
* 1. `getAccessToken` — the host resolves (and renews) the token per request.
|
|
73
|
+
* 2. `accessToken` — sent as-is.
|
|
74
|
+
* 3. `apiKey` — a raw `crsr_` key is exchanged and renewed; any other value is
|
|
75
|
+
* a token a host forwarded through the generic API-key slot, sent as-is.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveBearerToken(input: {
|
|
78
|
+
getAccessToken?: AccessTokenProvider;
|
|
79
|
+
accessToken?: string;
|
|
80
|
+
apiKey?: string;
|
|
81
|
+
baseUrl?: string;
|
|
82
|
+
forceRefresh?: boolean;
|
|
83
|
+
}): Promise<string>;
|
|
84
|
+
/** Reset all renewal state (tests). */
|
|
85
|
+
export declare function resetAuthRenewalState(): void;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { CURSOR_API_HOST, TOKEN_EXPIRY_THRESHOLD_S } from "./shared.js";
|
|
2
|
+
import { AuthExchangeError, decodeJwtPayload, exchangeApiKey, isExchangeableApiKey, isExpiringSoon, refreshCursorSession, } from "./auth.js";
|
|
3
|
+
// Failure details quote HTTP status text ("503 Service Unavailable"), and
|
|
4
|
+
// OpenCode's SessionRetry keys off such words in provider error messages.
|
|
5
|
+
import { CursorAuthError, CursorServerError, sanitizeHostTerminalMessage } from "./errors.js";
|
|
6
|
+
import { errorMessage, trace } from "./debug.js";
|
|
7
|
+
// Credential renewal policy. The two Cursor credential kinds are renewed by
|
|
8
|
+
// independent paths and never stand in for each other:
|
|
9
|
+
//
|
|
10
|
+
// - Browser-login session (OAuth): `POST /oauth/token`, the way Cursor's IDE
|
|
11
|
+
// renews it, starting once less than 1272 h of its 60-day life remain.
|
|
12
|
+
// - API key: re-exchange the raw `crsr_` key when the exchanged JWT nears
|
|
13
|
+
// expiry, the way Cursor CLI renews it. Its refresh token is never used.
|
|
14
|
+
//
|
|
15
|
+
// Renewal happens only on demand, when a request needs a token, as OpenCode's
|
|
16
|
+
// own OAuth providers do (a per-request `fetch` that reads `getAuth()`); there
|
|
17
|
+
// is no timer. Both keep the current token through a transient failure while
|
|
18
|
+
// it is still valid, back off between attempts, and latch final failures so
|
|
19
|
+
// they are not retried for the same credential.
|
|
20
|
+
const API_BASE = `https://${CURSOR_API_HOST}`;
|
|
21
|
+
/** Cursor IDE renews a session once less than this remains (`hir = 1272h`). */
|
|
22
|
+
export const SESSION_RENEWAL_WINDOW_MS = 1272 * 3_600_000;
|
|
23
|
+
/** Never renew a session token younger than this, whatever its lifetime. */
|
|
24
|
+
export const SESSION_MIN_AGE_MS = 3_600_000;
|
|
25
|
+
/** A token is still worth sending while it has at least this long left. */
|
|
26
|
+
const USABLE_MARGIN_MS = 30_000;
|
|
27
|
+
const BACKOFF_INITIAL_MS = 30_000;
|
|
28
|
+
const BACKOFF_MAX_MS = 15 * 60_000;
|
|
29
|
+
/** Remember at most this many in-process session renewals. */
|
|
30
|
+
const MAX_SESSION_SUCCESSORS = 32;
|
|
31
|
+
const SIGN_IN_AGAIN = "sign in to Cursor again";
|
|
32
|
+
/**
|
|
33
|
+
* Issue and expiry times of a Cursor JWT. Cursor session tokens carry the
|
|
34
|
+
* issue time in a `time` claim (seconds, as a string); standard `iat` is used
|
|
35
|
+
* when present instead.
|
|
36
|
+
*/
|
|
37
|
+
export function tokenTimes(token) {
|
|
38
|
+
const payload = decodeJwtPayload(token);
|
|
39
|
+
if (!payload)
|
|
40
|
+
return {};
|
|
41
|
+
const seconds = (value) => {
|
|
42
|
+
const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
|
43
|
+
return typeof n === "number" && Number.isFinite(n) ? n * 1000 : undefined;
|
|
44
|
+
};
|
|
45
|
+
const expiresAtMs = seconds(payload.exp);
|
|
46
|
+
const issuedAtMs = seconds(payload.iat) ?? seconds(payload.time);
|
|
47
|
+
return {
|
|
48
|
+
...(issuedAtMs !== undefined ? { issuedAtMs } : {}),
|
|
49
|
+
...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* When a session token is due for renewal: 1272 h before expiry, but never
|
|
54
|
+
* within an hour of issue. Undefined when the token has no readable expiry.
|
|
55
|
+
*/
|
|
56
|
+
export function sessionRenewalDueAt(token) {
|
|
57
|
+
const { issuedAtMs, expiresAtMs } = tokenTimes(token);
|
|
58
|
+
if (expiresAtMs === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
const due = expiresAtMs - SESSION_RENEWAL_WINDOW_MS;
|
|
61
|
+
return issuedAtMs === undefined ? due : Math.max(due, issuedAtMs + SESSION_MIN_AGE_MS);
|
|
62
|
+
}
|
|
63
|
+
function isUsable(token, now) {
|
|
64
|
+
const { expiresAtMs } = tokenTimes(token);
|
|
65
|
+
return expiresAtMs === undefined || expiresAtMs - now > USABLE_MARGIN_MS;
|
|
66
|
+
}
|
|
67
|
+
/** The token that stays valid longer (the first one on a tie or unknown expiry). */
|
|
68
|
+
function longerLived(a, b) {
|
|
69
|
+
const aExp = tokenTimes(a).expiresAtMs;
|
|
70
|
+
const bExp = tokenTimes(b).expiresAtMs;
|
|
71
|
+
return bExp !== undefined && (aExp === undefined || bExp > aExp) ? b : a;
|
|
72
|
+
}
|
|
73
|
+
function isoTime(ms) {
|
|
74
|
+
return ms === undefined ? "unknown" : new Date(ms).toISOString();
|
|
75
|
+
}
|
|
76
|
+
function backoffMs(failures) {
|
|
77
|
+
return Math.min(BACKOFF_MAX_MS, BACKOFF_INITIAL_MS * 2 ** Math.max(0, failures - 1));
|
|
78
|
+
}
|
|
79
|
+
function stateFor(map, key) {
|
|
80
|
+
let state = map.get(key);
|
|
81
|
+
if (!state) {
|
|
82
|
+
state = { failures: 0 };
|
|
83
|
+
map.set(key, state);
|
|
84
|
+
}
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
function recordTransientFailure(state, message) {
|
|
88
|
+
state.failures++;
|
|
89
|
+
state.retryAt = Date.now() + backoffMs(state.failures);
|
|
90
|
+
state.lastFailure = message;
|
|
91
|
+
}
|
|
92
|
+
function recordSuccess(state) {
|
|
93
|
+
state.failures = 0;
|
|
94
|
+
state.retryAt = undefined;
|
|
95
|
+
state.lastFailure = undefined;
|
|
96
|
+
}
|
|
97
|
+
const sessionStates = new Map();
|
|
98
|
+
/** Stored refresh token → newer session token this process obtained for it. */
|
|
99
|
+
const sessionSuccessors = new Map();
|
|
100
|
+
/**
|
|
101
|
+
* Follow renewals this process already made, so a caller still holding an
|
|
102
|
+
* older stored credential (persisting failed, or a host that persists only
|
|
103
|
+
* later) gets the newest token instead of renewing again.
|
|
104
|
+
*/
|
|
105
|
+
export function latestSessionTokens(tokens) {
|
|
106
|
+
let current = tokens;
|
|
107
|
+
for (let hops = 0; hops < MAX_SESSION_SUCCESSORS; hops++) {
|
|
108
|
+
const next = sessionSuccessors.get(current.refreshToken);
|
|
109
|
+
if (!next || next === current.refreshToken)
|
|
110
|
+
break;
|
|
111
|
+
current = { accessToken: next, refreshToken: next };
|
|
112
|
+
}
|
|
113
|
+
return longerLived(tokens.accessToken, current.accessToken) === current.accessToken ? current : tokens;
|
|
114
|
+
}
|
|
115
|
+
function rememberSuccessor(refreshToken, accessToken) {
|
|
116
|
+
sessionSuccessors.delete(refreshToken);
|
|
117
|
+
sessionSuccessors.set(refreshToken, accessToken);
|
|
118
|
+
while (sessionSuccessors.size > MAX_SESSION_SUCCESSORS) {
|
|
119
|
+
const oldest = sessionSuccessors.keys().next().value;
|
|
120
|
+
if (oldest === undefined)
|
|
121
|
+
break;
|
|
122
|
+
sessionSuccessors.delete(oldest);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function isSessionRenewalDue(token, now = Date.now()) {
|
|
126
|
+
const due = sessionRenewalDueAt(token);
|
|
127
|
+
return due !== undefined && now >= due;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Return a usable session token, renewing it first when it is due (or when
|
|
131
|
+
* `force` is set after Cursor rejected it). Renewal failures that leave the
|
|
132
|
+
* current token valid keep it; a session Cursor ended, or one that expired
|
|
133
|
+
* and could not be renewed, raises `CursorAuthError`.
|
|
134
|
+
*/
|
|
135
|
+
export async function renewSessionIfDue(tokens, options = {}) {
|
|
136
|
+
const baseUrl = options.baseUrl ?? API_BASE;
|
|
137
|
+
const base = latestSessionTokens(tokens);
|
|
138
|
+
const fromMemory = base.accessToken !== tokens.accessToken;
|
|
139
|
+
if (!options.force && !isSessionRenewalDue(base.accessToken)) {
|
|
140
|
+
return { accessToken: base.accessToken, renewed: fromMemory };
|
|
141
|
+
}
|
|
142
|
+
const state = stateFor(sessionStates, `${baseUrl}\0${base.refreshToken}`);
|
|
143
|
+
if (!state.terminal && (options.force || state.retryAt === undefined || Date.now() >= state.retryAt)) {
|
|
144
|
+
state.inflight ??= (async () => {
|
|
145
|
+
const result = await refreshCursorSession(base.refreshToken, baseUrl);
|
|
146
|
+
if (result.ok) {
|
|
147
|
+
const { expiresAtMs } = tokenTimes(result.accessToken);
|
|
148
|
+
if (expiresAtMs === undefined) {
|
|
149
|
+
recordTransientFailure(state, "session refresh returned a token without expiry");
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
rememberSuccessor(base.refreshToken, result.accessToken);
|
|
153
|
+
recordSuccess(state);
|
|
154
|
+
trace(`auth: session renewed exp=${isoTime(expiresAtMs)}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (result.kind === "transient") {
|
|
159
|
+
recordTransientFailure(state, result.message);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
state.terminal = new CursorAuthError(result.kind === "policy"
|
|
163
|
+
? `Cursor sign-in policy blocks this login (sign_in_policy_violation); ${SIGN_IN_AGAIN} with an allowed account`
|
|
164
|
+
: `Cursor ended this login session; ${SIGN_IN_AGAIN}`, { code: result.kind === "policy" ? "sign_in_policy_violation" : "session_logout", statusCode: result.status });
|
|
165
|
+
trace(`auth: session renewal final failure kind=${result.kind}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
trace(`auth: session renewal failed (${state.lastFailure}) attempt=${state.failures} ` +
|
|
169
|
+
`retryAt=${isoTime(state.retryAt)} currentExp=${isoTime(tokenTimes(base.accessToken).expiresAtMs)}`);
|
|
170
|
+
})().finally(() => {
|
|
171
|
+
state.inflight = undefined;
|
|
172
|
+
});
|
|
173
|
+
await state.inflight;
|
|
174
|
+
}
|
|
175
|
+
const renewed = latestSessionTokens(base);
|
|
176
|
+
if (renewed.accessToken !== base.accessToken) {
|
|
177
|
+
return { accessToken: renewed.accessToken, renewed: true };
|
|
178
|
+
}
|
|
179
|
+
if (state.terminal)
|
|
180
|
+
throw state.terminal;
|
|
181
|
+
if (!isUsable(base.accessToken, Date.now())) {
|
|
182
|
+
throw new CursorAuthError(`Cursor login expired and could not be renewed (${sanitizeHostTerminalMessage(state.lastFailure ?? "no renewal attempt allowed yet")}); ${SIGN_IN_AGAIN}`, { code: "session_expired" });
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
accessToken: base.accessToken,
|
|
186
|
+
renewed: fromMemory,
|
|
187
|
+
...(state.retryAt !== undefined ? { retryAt: state.retryAt } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// ── API key ──
|
|
191
|
+
const apiKeyStates = new Map();
|
|
192
|
+
/**
|
|
193
|
+
* JWT for a raw `crsr_` API key: the cached or `seed` JWT while it is not
|
|
194
|
+
* within five minutes of expiry, otherwise a fresh exchange. Cached per API
|
|
195
|
+
* base URL and key. Never returns the raw key itself.
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveApiKeyToken(apiKey, options = {}) {
|
|
198
|
+
if (!isExchangeableApiKey(apiKey)) {
|
|
199
|
+
throw new CursorAuthError("Cursor API key must start with crsr_");
|
|
200
|
+
}
|
|
201
|
+
const baseUrl = options.baseUrl ?? API_BASE;
|
|
202
|
+
const state = stateFor(apiKeyStates, `${baseUrl}\0${apiKey}`);
|
|
203
|
+
const seed = options.seed && !isExchangeableApiKey(options.seed) && tokenTimes(options.seed).expiresAtMs !== undefined
|
|
204
|
+
? options.seed
|
|
205
|
+
: undefined;
|
|
206
|
+
if (seed)
|
|
207
|
+
state.token = state.token ? longerLived(state.token, seed) : seed;
|
|
208
|
+
const result = (token) => ({ accessToken: token, renewed: token !== seed });
|
|
209
|
+
if (state.token && !options.force && !isExpiringSoon(state.token, TOKEN_EXPIRY_THRESHOLD_S)) {
|
|
210
|
+
return result(state.token);
|
|
211
|
+
}
|
|
212
|
+
if (!state.terminal && (options.force || state.retryAt === undefined || Date.now() >= state.retryAt)) {
|
|
213
|
+
state.inflight ??= (async () => {
|
|
214
|
+
try {
|
|
215
|
+
const pair = await exchangeApiKey(apiKey, baseUrl);
|
|
216
|
+
state.token = state.token ? longerLived(state.token, pair.accessToken) : pair.accessToken;
|
|
217
|
+
recordSuccess(state);
|
|
218
|
+
trace(`auth: API key exchanged exp=${isoTime(tokenTimes(pair.accessToken).expiresAtMs)}`);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const kind = error instanceof AuthExchangeError ? error.kind : "transient";
|
|
222
|
+
if (kind === "transient") {
|
|
223
|
+
recordTransientFailure(state, errorMessage(error));
|
|
224
|
+
trace(`auth: API key exchange failed (${state.lastFailure}) attempt=${state.failures} retryAt=${isoTime(state.retryAt)}`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const status = error instanceof AuthExchangeError ? error.status : undefined;
|
|
228
|
+
state.terminal = new CursorAuthError(kind === "policy"
|
|
229
|
+
? "Cursor sign-in policy blocks this API key (sign_in_policy_violation); use an allowed account"
|
|
230
|
+
: `Cursor rejected the API key${status ? ` (HTTP ${status})` : ""}; create a new key and sign in with it`, { code: kind === "policy" ? "sign_in_policy_violation" : "api_key_rejected", ...(status ? { statusCode: status } : {}) });
|
|
231
|
+
trace(`auth: API key exchange final failure kind=${kind} status=${status ?? "none"}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
})().finally(() => {
|
|
235
|
+
state.inflight = undefined;
|
|
236
|
+
});
|
|
237
|
+
await state.inflight;
|
|
238
|
+
}
|
|
239
|
+
// A fresh exchange is always usable; an older token only until it expires.
|
|
240
|
+
if (state.token && isUsable(state.token, Date.now()))
|
|
241
|
+
return result(state.token);
|
|
242
|
+
if (state.terminal)
|
|
243
|
+
throw state.terminal;
|
|
244
|
+
throw new CursorServerError(`Cursor API key exchange failed (${sanitizeHostTerminalMessage(state.lastFailure ?? "waiting before the next attempt")}); it is retried automatically`, { transient: true, replaySafe: true });
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Bearer token for a Cursor request. One credential source is used, in this
|
|
248
|
+
* order, and none of them falls back to another:
|
|
249
|
+
*
|
|
250
|
+
* 1. `getAccessToken` — the host resolves (and renews) the token per request.
|
|
251
|
+
* 2. `accessToken` — sent as-is.
|
|
252
|
+
* 3. `apiKey` — a raw `crsr_` key is exchanged and renewed; any other value is
|
|
253
|
+
* a token a host forwarded through the generic API-key slot, sent as-is.
|
|
254
|
+
*/
|
|
255
|
+
export async function resolveBearerToken(input) {
|
|
256
|
+
let token;
|
|
257
|
+
if (input.getAccessToken) {
|
|
258
|
+
token = await input.getAccessToken(input.forceRefresh ? { forceRefresh: true } : undefined);
|
|
259
|
+
}
|
|
260
|
+
else if (input.accessToken) {
|
|
261
|
+
token = input.accessToken;
|
|
262
|
+
}
|
|
263
|
+
else if (input.apiKey) {
|
|
264
|
+
token = isExchangeableApiKey(input.apiKey)
|
|
265
|
+
? (await resolveApiKeyToken(input.apiKey, { baseUrl: input.baseUrl, force: input.forceRefresh })).accessToken
|
|
266
|
+
: input.apiKey;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
throw new CursorAuthError("Cursor provider: no access token or API key provided");
|
|
270
|
+
}
|
|
271
|
+
if (!token)
|
|
272
|
+
throw new CursorAuthError(`Cursor provider: no access token available; ${SIGN_IN_AGAIN}`);
|
|
273
|
+
if (isExchangeableApiKey(token)) {
|
|
274
|
+
// A raw key is never a valid Bearer token; Cursor answers 401.
|
|
275
|
+
throw new CursorAuthError("Cursor provider: a raw crsr_ API key was supplied as an access token; pass it as apiKey");
|
|
276
|
+
}
|
|
277
|
+
return token;
|
|
278
|
+
}
|
|
279
|
+
/** Reset all renewal state (tests). */
|
|
280
|
+
export function resetAuthRenewalState() {
|
|
281
|
+
sessionStates.clear();
|
|
282
|
+
sessionSuccessors.clear();
|
|
283
|
+
apiKeyStates.clear();
|
|
284
|
+
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an API key exchange failed. A policy block (403
|
|
3
|
+
* `sign_in_policy_violation`, as Cursor CLI's `loginWithApiKey` reports it) and
|
|
4
|
+
* an authentication refusal (401/403) are final for that key; anything else —
|
|
5
|
+
* rate limit, timeout, server or network failure — is worth retrying later.
|
|
6
|
+
*/
|
|
7
|
+
export type AuthExchangeFailureKind = "policy" | "rejected" | "transient";
|
|
1
8
|
export declare class AuthExchangeError extends Error {
|
|
2
9
|
cause?: unknown | undefined;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
cause?: unknown | undefined;
|
|
7
|
-
constructor(message: string, cause?: unknown | undefined);
|
|
10
|
+
readonly kind: AuthExchangeFailureKind;
|
|
11
|
+
readonly status?: number | undefined;
|
|
12
|
+
constructor(message: string, cause?: unknown | undefined, kind?: AuthExchangeFailureKind, status?: number | undefined);
|
|
8
13
|
}
|
|
9
14
|
export declare class AuthPollError extends Error {
|
|
10
15
|
cause?: unknown | undefined;
|
|
@@ -17,6 +22,8 @@ export declare function isExpiringSoon(jwt: string, thresholdS?: number): boolea
|
|
|
17
22
|
export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
|
|
18
23
|
/** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
|
|
19
24
|
export declare function decodeJwtExpiryMs(jwt: string): number | null;
|
|
25
|
+
/** A raw Cursor API key (as opposed to a JWT already exchanged from one). */
|
|
26
|
+
export declare function isExchangeableApiKey(value: string): boolean;
|
|
20
27
|
export declare function useAuthToken(token: string): {
|
|
21
28
|
accessToken: string;
|
|
22
29
|
};
|
|
@@ -24,27 +31,36 @@ export type TokenPair = {
|
|
|
24
31
|
accessToken: string;
|
|
25
32
|
refreshToken: string;
|
|
26
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Exchange a raw `crsr_` API key for a short-lived JWT. This is also how an
|
|
36
|
+
* API-key login is renewed: Cursor CLI re-runs the exchange and never uses the
|
|
37
|
+
* returned refresh token (`auth-refresh.ts` → `loginWithApiKey`).
|
|
38
|
+
*/
|
|
27
39
|
export declare function exchangeApiKey(apiKey: string, baseUrl?: string): Promise<TokenPair>;
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
export type SessionRefreshResult = {
|
|
41
|
+
ok: true;
|
|
42
|
+
accessToken: string;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
/**
|
|
46
|
+
* `logout`: Cursor ended the session. `policy`: a sign-in policy blocks
|
|
47
|
+
* it. Both are final for this token. `transient`: try again later.
|
|
48
|
+
*/
|
|
49
|
+
kind: "logout" | "policy" | "transient";
|
|
50
|
+
status?: number;
|
|
51
|
+
message: string;
|
|
52
|
+
};
|
|
31
53
|
/**
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
54
|
+
* Renew a browser-login session token, exactly as Cursor's IDE does
|
|
55
|
+
* (`_performAccessTokenRefresh`): `POST /oauth/token` with a refresh_token
|
|
56
|
+
* grant and the IDE's client id. The response carries no new refresh token;
|
|
57
|
+
* the IDE stores the new access token as both, and so must callers.
|
|
35
58
|
*
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
* doesn't distinguish our OAuth vs. API-key connection methods and may pass
|
|
40
|
-
* an already-issued JWT through the `apiKey` field — hand us a token that's
|
|
41
|
-
* already good to use as-is; POSTing it to the exchange endpoint 401s.
|
|
59
|
+
* A rejected token is not an HTTP error: Cursor answers 200 with
|
|
60
|
+
* `{"access_token":"","shouldLogout":true}`, so the body decides the outcome.
|
|
61
|
+
* Never throws; every failure is a classified result.
|
|
42
62
|
*/
|
|
43
|
-
export declare function
|
|
44
|
-
accessToken?: string;
|
|
45
|
-
apiKey?: string;
|
|
46
|
-
baseUrl?: string;
|
|
47
|
-
}): Promise<string>;
|
|
63
|
+
export declare function refreshCursorSession(refreshToken: string, baseUrl?: string): Promise<SessionRefreshResult>;
|
|
48
64
|
export type PkceParams = {
|
|
49
65
|
verifier: string;
|
|
50
66
|
challenge: string;
|