cursor-opencode-provider 0.7.6 → 0.8.1

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +36 -15
  3. package/SECURITY.md +43 -7
  4. package/dist/agent-url.js +2 -2
  5. package/dist/auth-renewal.d.ts +85 -0
  6. package/dist/auth-renewal.js +284 -0
  7. package/dist/auth.d.ts +38 -22
  8. package/dist/auth.js +79 -96
  9. package/dist/classic-tools.d.ts +15 -0
  10. package/dist/classic-tools.js +45 -0
  11. package/dist/context/build.d.ts +62 -7
  12. package/dist/context/build.js +127 -95
  13. package/dist/context/dynamic-catalog.d.ts +2 -58
  14. package/dist/context/dynamic-catalog.js +16 -161
  15. package/dist/context/epoch.d.ts +6 -4
  16. package/dist/context/epoch.js +22 -10
  17. package/dist/context/frozen.js +42 -15
  18. package/dist/context/index.d.ts +3 -1
  19. package/dist/context/index.js +3 -1
  20. package/dist/context/overlay.d.ts +2 -23
  21. package/dist/context/overlay.js +6 -30
  22. package/dist/context/paths.d.ts +51 -21
  23. package/dist/context/paths.js +78 -27
  24. package/dist/context/rules.d.ts +3 -15
  25. package/dist/context/rules.js +10 -186
  26. package/dist/context/skills-bridge.d.ts +22 -0
  27. package/dist/context/skills-bridge.js +20 -0
  28. package/dist/context/skills.d.ts +31 -8
  29. package/dist/context/skills.js +187 -86
  30. package/dist/debug.d.ts +2 -0
  31. package/dist/debug.js +4 -0
  32. package/dist/errors.d.ts +13 -0
  33. package/dist/errors.js +27 -3
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +119 -15
  36. package/dist/host-plan-file.d.ts +28 -0
  37. package/dist/host-plan-file.js +76 -0
  38. package/dist/image-input.d.ts +18 -0
  39. package/dist/image-input.js +119 -28
  40. package/dist/image-save.d.ts +10 -2
  41. package/dist/image-save.js +35 -23
  42. package/dist/index.d.ts +10 -0
  43. package/dist/language-model.d.ts +86 -25
  44. package/dist/language-model.js +1295 -369
  45. package/dist/model-config.d.ts +9 -0
  46. package/dist/model-config.js +28 -1
  47. package/dist/opencode2/catalog.d.ts +1 -0
  48. package/dist/opencode2/catalog.js +2 -0
  49. package/dist/opencode2/image-save-tool.d.ts +8 -0
  50. package/dist/opencode2/image-save-tool.js +84 -0
  51. package/dist/opencode2/integration.d.ts +30 -7
  52. package/dist/opencode2/integration.js +75 -46
  53. package/dist/opencode2/types.d.ts +15 -0
  54. package/dist/parallel-step.d.ts +65 -0
  55. package/dist/parallel-step.js +102 -0
  56. package/dist/plugin-opencode2.js +58 -56
  57. package/dist/plugin.d.ts +0 -10
  58. package/dist/plugin.js +207 -127
  59. package/dist/pricing-data.d.ts +14 -1
  60. package/dist/pricing-data.js +14 -1
  61. package/dist/protocol/ask-question.d.ts +12 -1
  62. package/dist/protocol/ask-question.js +77 -3
  63. package/dist/protocol/blob-store.js +3 -2
  64. package/dist/protocol/conversation-bind.js +1 -1
  65. package/dist/protocol/conversation-persistence.d.ts +2 -0
  66. package/dist/protocol/conversation-persistence.js +11 -1
  67. package/dist/protocol/conversation-state.d.ts +2 -0
  68. package/dist/protocol/conversation-state.js +19 -0
  69. package/dist/protocol/create-plan.d.ts +47 -25
  70. package/dist/protocol/create-plan.js +35 -24
  71. package/dist/protocol/interactions.d.ts +26 -17
  72. package/dist/protocol/interactions.js +80 -31
  73. package/dist/protocol/messages.js +96 -18
  74. package/dist/protocol/request.d.ts +16 -9
  75. package/dist/protocol/request.js +17 -25
  76. package/dist/protocol/switch-mode.d.ts +66 -27
  77. package/dist/protocol/switch-mode.js +147 -73
  78. package/dist/protocol/token-details.d.ts +2 -0
  79. package/dist/protocol/token-details.js +11 -1
  80. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  81. package/dist/protocol/tool-call-bridge.js +14 -0
  82. package/dist/protocol/tools.d.ts +33 -11
  83. package/dist/protocol/tools.js +165 -103
  84. package/dist/protocol/turn-provenance.d.ts +52 -0
  85. package/dist/protocol/turn-provenance.js +174 -0
  86. package/dist/replay-safety.d.ts +6 -0
  87. package/dist/replay-safety.js +56 -6
  88. package/dist/session.d.ts +44 -2
  89. package/dist/session.js +45 -1
  90. package/dist/shared.d.ts +7 -0
  91. package/dist/shared.js +7 -0
  92. package/dist/todo-store.d.ts +2 -0
  93. package/dist/todo-store.js +19 -0
  94. package/dist/transport/connect.d.ts +2 -0
  95. package/dist/transport/connect.js +23 -6
  96. package/dist/usage.js +23 -6
  97. package/package.json +3 -2
  98. package/dist/context/agents.d.ts +0 -12
  99. package/dist/context/agents.js +0 -136
  100. package/dist/plan-execution-kickoff.d.ts +0 -57
  101. package/dist/plan-execution-kickoff.js +0 -186
@@ -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
- constructor(message: string, cause?: unknown | undefined);
4
- }
5
- export declare class AuthRefreshError extends Error {
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 declare function refreshAccessToken(refreshToken: string, baseUrl?: string): Promise<TokenPair>;
29
- /** Clear the apiKey→JWT cache (tests). */
30
- export declare function clearBearerTokenCache(): void;
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
- * Resolve a Bearer JWT for Cursor API calls. Prefer an already-exchanged
33
- * `accessToken`; otherwise exchange (and cache) from `apiKey`, refreshing
34
- * when the cached JWT is near expiry.
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
- * `apiKey` is only ever a raw exchangeable secret when it has Cursor's
37
- * `crsr_` prefix. Callers that generically forward whatever credential value
38
- * they hold — e.g. a host's package-agnostic "aisdk:" SDK loader, which
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 resolveBearerToken(input: {
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;
package/dist/auth.js CHANGED
@@ -1,23 +1,24 @@
1
- import { CURSOR_API_HOST, CURSOR_WEBSITE_HOST } from "./shared.js";
1
+ import { CURSOR_API_HOST, CURSOR_OAUTH_CLIENT_ID, CURSOR_WEBSITE_HOST } from "./shared.js";
2
2
  import { withAbortDeadline } from "./deadline.js";
3
+ import { errorMessage } from "./debug.js";
3
4
  const API_BASE = `https://${CURSOR_API_HOST}`;
4
5
  const AUTH_REQUEST_TIMEOUT_MS = 5_000;
6
+ /** Cursor IDE aborts its session refresh after 20 s (`_performAccessTokenRefresh`). */
7
+ const SESSION_REFRESH_TIMEOUT_MS = 20_000;
8
+ /** Server error code Cursor's IDE and CLI both surface as a sign-in policy block. */
9
+ const SIGN_IN_POLICY_VIOLATION = "sign_in_policy_violation";
5
10
  export class AuthExchangeError extends Error {
6
11
  cause;
7
- constructor(message, cause) {
12
+ kind;
13
+ status;
14
+ constructor(message, cause, kind = "transient", status) {
8
15
  super(message);
9
16
  this.cause = cause;
17
+ this.kind = kind;
18
+ this.status = status;
10
19
  this.name = "AuthExchangeError";
11
20
  }
12
21
  }
13
- export class AuthRefreshError extends Error {
14
- cause;
15
- constructor(message, cause) {
16
- super(message);
17
- this.cause = cause;
18
- this.name = "AuthRefreshError";
19
- }
20
- }
21
22
  export class AuthPollError extends Error {
22
23
  cause;
23
24
  constructor(message, cause) {
@@ -69,10 +70,19 @@ function base64url(bytes) {
69
70
  .replace(/\//g, "_")
70
71
  .replace(/=+$/, "");
71
72
  }
73
+ /** A raw Cursor API key (as opposed to a JWT already exchanged from one). */
74
+ export function isExchangeableApiKey(value) {
75
+ return value.startsWith("crsr_");
76
+ }
72
77
  // ── Mode A: pass-through auth token ──
73
78
  export function useAuthToken(token) {
74
79
  return { accessToken: token };
75
80
  }
81
+ /**
82
+ * Exchange a raw `crsr_` API key for a short-lived JWT. This is also how an
83
+ * API-key login is renewed: Cursor CLI re-runs the exchange and never uses the
84
+ * returned refresh token (`auth-refresh.ts` → `loginWithApiKey`).
85
+ */
76
86
  export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
77
87
  return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthExchangeError("API key exchange timed out"), async (signal) => {
78
88
  let res;
@@ -91,7 +101,18 @@ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
91
101
  throw new AuthExchangeError("API key exchange request failed", cause);
92
102
  }
93
103
  if (!res.ok) {
94
- throw new AuthExchangeError(`API key exchange failed: ${res.status} ${res.statusText}`);
104
+ let errorCode;
105
+ if (res.status === 403) {
106
+ errorCode = (await res.json().catch(() => undefined))?.error;
107
+ }
108
+ // Only an authentication refusal says the key itself is bad; rate
109
+ // limits, timeouts, and server errors are worth another attempt.
110
+ const kind = errorCode === SIGN_IN_POLICY_VIOLATION
111
+ ? "policy"
112
+ : res.status === 401 || res.status === 403 ? "rejected" : "transient";
113
+ throw new AuthExchangeError(kind === "policy"
114
+ ? `API key exchange blocked by Cursor sign-in policy (${SIGN_IN_POLICY_VIOLATION})`
115
+ : `API key exchange failed: ${res.status} ${res.statusText}`, undefined, kind, res.status);
95
116
  }
96
117
  let body;
97
118
  try {
@@ -106,97 +127,59 @@ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
106
127
  return { accessToken: body.accessToken, refreshToken: body.refreshToken };
107
128
  });
108
129
  }
109
- export async function refreshAccessToken(refreshToken, baseUrl = API_BASE) {
110
- return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthRefreshError("Token refresh timed out"), async (signal) => {
111
- let res;
112
- try {
113
- res = await fetch(`${baseUrl}/auth/token`, {
114
- method: "POST",
115
- headers: { "Content-Type": "application/json" },
116
- body: JSON.stringify({ refreshToken }),
117
- signal,
118
- });
119
- }
120
- catch (cause) {
121
- throw new AuthRefreshError("Token refresh request failed", cause);
122
- }
123
- if (!res.ok) {
124
- throw new AuthRefreshError(`Token refresh failed: ${res.status} ${res.statusText}`);
125
- }
126
- let body;
127
- try {
128
- body = await res.json();
129
- }
130
- catch (cause) {
131
- throw new AuthRefreshError("Token refresh returned malformed JSON", cause);
132
- }
133
- if (typeof body.accessToken !== "string" || typeof body.refreshToken !== "string") {
134
- throw new AuthRefreshError("Refresh response missing tokens");
135
- }
136
- return { accessToken: body.accessToken, refreshToken: body.refreshToken };
137
- });
138
- }
139
- // Cache JWTs obtained via API-key exchange so doStream doesn't re-exchange
140
- // on every turn when the caller only supplied `apiKey`.
141
- const _apiKeyTokenCache = new Map();
142
- const inflightBearer = new Map();
143
- /** Clear the apiKey→JWT cache (tests). */
144
- export function clearBearerTokenCache() {
145
- _apiKeyTokenCache.clear();
146
- inflightBearer.clear();
147
- }
148
130
  /**
149
- * Resolve a Bearer JWT for Cursor API calls. Prefer an already-exchanged
150
- * `accessToken`; otherwise exchange (and cache) from `apiKey`, refreshing
151
- * when the cached JWT is near expiry.
131
+ * Renew a browser-login session token, exactly as Cursor's IDE does
132
+ * (`_performAccessTokenRefresh`): `POST /oauth/token` with a refresh_token
133
+ * grant and the IDE's client id. The response carries no new refresh token;
134
+ * the IDE stores the new access token as both, and so must callers.
152
135
  *
153
- * `apiKey` is only ever a raw exchangeable secret when it has Cursor's
154
- * `crsr_` prefix. Callers that generically forward whatever credential value
155
- * they hold — e.g. a host's package-agnostic "aisdk:" SDK loader, which
156
- * doesn't distinguish our OAuth vs. API-key connection methods and may pass
157
- * an already-issued JWT through the `apiKey` field — hand us a token that's
158
- * already good to use as-is; POSTing it to the exchange endpoint 401s.
136
+ * A rejected token is not an HTTP error: Cursor answers 200 with
137
+ * `{"access_token":"","shouldLogout":true}`, so the body decides the outcome.
138
+ * Never throws; every failure is a classified result.
159
139
  */
160
- export async function resolveBearerToken(input) {
161
- if (input.accessToken)
162
- return input.accessToken;
163
- if (!input.apiKey) {
164
- throw new Error("Cursor provider: no access token or API key provided");
165
- }
166
- if (!input.apiKey.startsWith("crsr_"))
167
- return input.apiKey;
168
- const baseUrl = input.baseUrl ?? API_BASE;
169
- const cached = _apiKeyTokenCache.get(input.apiKey);
170
- if (cached && !isExpiringSoon(cached.accessToken)) {
171
- return cached.accessToken;
172
- }
173
- const inflightKey = `${baseUrl}\0${input.apiKey}`;
174
- const existing = inflightBearer.get(inflightKey);
175
- if (existing)
176
- return existing;
177
- async function refreshOrExchange() {
178
- if (cached) {
179
- try {
180
- const refreshed = await refreshAccessToken(cached.refreshToken, baseUrl);
181
- _apiKeyTokenCache.set(input.apiKey, refreshed);
182
- return refreshed.accessToken;
140
+ export async function refreshCursorSession(refreshToken, baseUrl = API_BASE) {
141
+ try {
142
+ return await withAbortDeadline(SESSION_REFRESH_TIMEOUT_MS, () => new Error("session refresh timed out"), async (signal) => {
143
+ const res = await fetch(`${baseUrl}/oauth/token`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ "x-cursor-client-type": "cli",
148
+ },
149
+ body: JSON.stringify({
150
+ grant_type: "refresh_token",
151
+ client_id: CURSOR_OAUTH_CLIENT_ID,
152
+ refresh_token: refreshToken,
153
+ }),
154
+ signal,
155
+ });
156
+ if (!res.ok) {
157
+ return {
158
+ ok: false,
159
+ kind: "transient",
160
+ status: res.status,
161
+ message: `session refresh failed: ${res.status} ${res.statusText}`,
162
+ };
183
163
  }
184
- catch {
185
- // Fall through to a fresh exchange.
164
+ const body = await res.json().catch(() => undefined);
165
+ if (body?.shouldLogout === true) {
166
+ return body.error === SIGN_IN_POLICY_VIOLATION
167
+ ? { ok: false, kind: "policy", status: res.status, message: `session blocked by Cursor sign-in policy (${SIGN_IN_POLICY_VIOLATION})` }
168
+ : { ok: false, kind: "logout", status: res.status, message: "Cursor ended the session (shouldLogout)" };
186
169
  }
187
- }
188
- const pair = await exchangeApiKey(input.apiKey, baseUrl);
189
- _apiKeyTokenCache.set(input.apiKey, pair);
190
- return pair.accessToken;
191
- }
192
- const pending = refreshOrExchange();
193
- inflightBearer.set(inflightKey, pending);
194
- try {
195
- return await pending;
170
+ // The IDE would store an empty token here; treat it as a failed attempt.
171
+ if (typeof body?.access_token !== "string" || body.access_token === "") {
172
+ return { ok: false, kind: "transient", status: res.status, message: "session refresh returned no access token" };
173
+ }
174
+ return { ok: true, accessToken: body.access_token };
175
+ });
196
176
  }
197
- finally {
198
- if (inflightBearer.get(inflightKey) === pending)
199
- inflightBearer.delete(inflightKey);
177
+ catch (cause) {
178
+ return {
179
+ ok: false,
180
+ kind: "transient",
181
+ message: `session refresh request failed: ${errorMessage(cause)}`,
182
+ };
200
183
  }
201
184
  }
202
185
  // ── Mode C: PKCE browser login ──
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Classic plugin tools built with the host's own `tool()` helper when it can
3
+ * be found. Lives outside `plugin.ts` because OpenCode 1.x calls every export
4
+ * of a plugin module as a plugin (`getLegacyPlugins`), so that module may
5
+ * export `CursorPlugin` only.
6
+ */
7
+ export declare function loadClassicTools(options?: {
8
+ importModule?: (specifier: string) => Promise<{
9
+ tool?: any;
10
+ }>;
11
+ configDirs?: string[];
12
+ }): Promise<{
13
+ webSearch: any;
14
+ imageSave: any;
15
+ }>;
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { opencodeGlobalConfigDirs } from "./context/paths.js";
4
+ import { createOpenCodeWebSearchTool, openCodeWebSearchTool } from "./web-search-tool.js";
5
+ import { createCursorImageSaveTool, cursorImageSaveTool } from "./image-save-tool.js";
6
+ /**
7
+ * Classic plugin tools built with the host's own `tool()` helper when it can
8
+ * be found. Lives outside `plugin.ts` because OpenCode 1.x calls every export
9
+ * of a plugin module as a plugin (`getLegacyPlugins`), so that module may
10
+ * export `CursorPlugin` only.
11
+ */
12
+ export async function loadClassicTools(options = {}) {
13
+ const configDir = options.configDirs?.[0] ?? opencodeGlobalConfigDirs()[0];
14
+ const candidates = [
15
+ ...(configDir
16
+ ? [path.join(configDir, "node_modules", "@opencode-ai", "plugin", "dist", "index.js")]
17
+ : []),
18
+ "@opencode-ai/plugin",
19
+ ];
20
+ const importModule = options.importModule ?? ((specifier) => import(specifier));
21
+ // The global config copy is the host-owned installation; bare import is a fallback.
22
+ for (const candidate of candidates) {
23
+ try {
24
+ const specifier = path.win32.isAbsolute(candidate) && !path.isAbsolute(candidate)
25
+ ? new URL(`file:///${candidate.replaceAll("\\", "/")}`).href
26
+ : path.isAbsolute(candidate)
27
+ ? pathToFileURL(candidate).href
28
+ : candidate;
29
+ const module = await importModule(specifier);
30
+ if (typeof module.tool === "function" && module.tool.schema) {
31
+ const factory = { tool: module.tool, schema: module.tool.schema };
32
+ return {
33
+ webSearch: createOpenCodeWebSearchTool(factory),
34
+ imageSave: createCursorImageSaveTool(factory),
35
+ };
36
+ }
37
+ }
38
+ catch {
39
+ // Try the next host-owned/normal resolution location.
40
+ }
41
+ }
42
+ // `tool()` is an identity helper; the fallback definitions use plain JSON
43
+ // Schema accepted by OpenCode's legacy registry when no Zod helper is present.
44
+ return { webSearch: openCodeWebSearchTool, imageSave: cursorImageSaveTool };
45
+ }