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.
- package/CHANGELOG.md +79 -0
- package/README.md +36 -15
- package/SECURITY.md +43 -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 +62 -7
- package/dist/context/build.js +127 -95
- package/dist/context/dynamic-catalog.d.ts +2 -58
- package/dist/context/dynamic-catalog.js +16 -161
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +42 -15
- package/dist/context/index.d.ts +3 -1
- package/dist/context/index.js +3 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +51 -21
- package/dist/context/paths.js +78 -27
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +31 -8
- package/dist/context/skills.js +187 -86
- 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.d.ts +53 -4
- package/dist/host-agent-mode.js +119 -15
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +119 -28
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +35 -23
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +86 -25
- package/dist/language-model.js +1295 -369
- package/dist/model-config.d.ts +9 -0
- package/dist/model-config.js +28 -1
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +58 -56
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +207 -127
- package/dist/pricing-data.d.ts +14 -1
- package/dist/pricing-data.js +14 -1
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- 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.d.ts +47 -25
- package/dist/protocol/create-plan.js +35 -24
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +96 -18
- package/dist/protocol/request.d.ts +16 -9
- package/dist/protocol/request.js +17 -25
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +4 -0
- package/dist/protocol/tool-call-bridge.js +14 -0
- package/dist/protocol/tools.d.ts +33 -11
- package/dist/protocol/tools.js +165 -103
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/replay-safety.d.ts +6 -0
- package/dist/replay-safety.js +56 -6
- package/dist/session.d.ts +44 -2
- package/dist/session.js +45 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +2 -0
- package/dist/transport/connect.js +23 -6
- package/dist/usage.js +23 -6
- package/package.json +3 -2
- package/dist/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/plan-execution-kickoff.d.ts +0 -57
- 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
|
-
|
|
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;
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
150
|
-
* `
|
|
151
|
-
*
|
|
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
|
-
*
|
|
154
|
-
* `
|
|
155
|
-
*
|
|
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
|
|
161
|
-
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|