cursor-opencode-provider 0.7.5 → 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.
Files changed (60) hide show
  1. package/README.md +9 -7
  2. package/dist/agent-url.js +2 -2
  3. package/dist/auth-renewal.d.ts +85 -0
  4. package/dist/auth-renewal.js +284 -0
  5. package/dist/auth.d.ts +38 -22
  6. package/dist/auth.js +79 -96
  7. package/dist/classic-tools.d.ts +15 -0
  8. package/dist/classic-tools.js +45 -0
  9. package/dist/context/build.d.ts +42 -5
  10. package/dist/context/build.js +96 -92
  11. package/dist/context/dynamic-catalog.d.ts +3 -28
  12. package/dist/context/dynamic-catalog.js +25 -130
  13. package/dist/context/epoch.d.ts +6 -4
  14. package/dist/context/epoch.js +22 -10
  15. package/dist/context/frozen.js +37 -9
  16. package/dist/context/index.d.ts +1 -1
  17. package/dist/context/index.js +1 -1
  18. package/dist/context/overlay.d.ts +2 -23
  19. package/dist/context/overlay.js +6 -30
  20. package/dist/context/paths.d.ts +7 -4
  21. package/dist/context/paths.js +13 -9
  22. package/dist/context/rules.d.ts +3 -15
  23. package/dist/context/rules.js +10 -186
  24. package/dist/debug.d.ts +2 -0
  25. package/dist/debug.js +4 -0
  26. package/dist/errors.d.ts +13 -0
  27. package/dist/errors.js +27 -3
  28. package/dist/host-agent-mode.js +2 -2
  29. package/dist/image-save.js +3 -3
  30. package/dist/index.d.ts +10 -0
  31. package/dist/language-model.d.ts +59 -8
  32. package/dist/language-model.js +458 -118
  33. package/dist/opencode2/integration.d.ts +30 -7
  34. package/dist/opencode2/integration.js +75 -46
  35. package/dist/plan-execution-kickoff.js +2 -2
  36. package/dist/plugin-opencode2.js +10 -16
  37. package/dist/plugin.d.ts +0 -10
  38. package/dist/plugin.js +138 -109
  39. package/dist/protocol/blob-store.js +3 -2
  40. package/dist/protocol/conversation-bind.js +1 -1
  41. package/dist/protocol/conversation-persistence.d.ts +2 -0
  42. package/dist/protocol/conversation-persistence.js +11 -1
  43. package/dist/protocol/conversation-state.d.ts +2 -0
  44. package/dist/protocol/conversation-state.js +19 -0
  45. package/dist/protocol/create-plan.js +2 -1
  46. package/dist/protocol/messages.js +22 -4
  47. package/dist/protocol/request.d.ts +11 -9
  48. package/dist/protocol/request.js +15 -25
  49. package/dist/protocol/tools.d.ts +17 -10
  50. package/dist/protocol/tools.js +53 -93
  51. package/dist/protocol/turn-provenance.d.ts +52 -0
  52. package/dist/protocol/turn-provenance.js +174 -0
  53. package/dist/session.d.ts +9 -1
  54. package/dist/shared.d.ts +7 -0
  55. package/dist/shared.js +7 -0
  56. package/package.json +1 -1
  57. package/dist/context/agents.d.ts +0 -12
  58. package/dist/context/agents.js +0 -136
  59. package/dist/context/skills.d.ts +0 -11
  60. package/dist/context/skills.js +0 -110
@@ -1,4 +1,5 @@
1
- import type { CredentialValue, IntegrationDomain, IntegrationDraft } from "./types.js";
1
+ import { type AccessTokenRequest } from "../auth-renewal.js";
2
+ import type { CredentialOAuth, CredentialValue, IntegrationDomain, IntegrationDraft } from "./types.js";
2
3
  /**
3
4
  * Integration registration for the OpenCode 2.0 plugin — the replacement for the
4
5
  * classic plugin's `auth` hook (`methods` + `loader`).
@@ -9,15 +10,37 @@ import type { CredentialValue, IntegrationDomain, IntegrationDraft } from "./typ
9
10
  export declare const CURSOR_OAUTH_METHOD_ID = "oauth";
10
11
  /** Env vars that can supply a Cursor API key without running /connect. */
11
12
  export declare const CURSOR_ENV_NAMES: string[];
13
+ /**
14
+ * Host-driven session renewal (`POST /oauth/token`, as Cursor's IDE does).
15
+ * OpenCode 2.0 calls this when the stored `expires` is within five minutes
16
+ * (`packages/core/src/integration.ts`, `connection.resolve`) and persists the
17
+ * result. A session this process already renewed in memory is handed over
18
+ * instead of renewed again. A transient failure returns the unchanged session
19
+ * with `expires` moved to the next allowed attempt, so the host keeps working
20
+ * and asks again later; only a session Cursor ended, or one that expired
21
+ * unrenewed, throws.
22
+ */
23
+ export declare function refreshOAuth(credential: CredentialOAuth): Promise<CredentialOAuth>;
12
24
  /** Register the Cursor integration and its three connection methods. */
13
25
  export declare function applyCursorIntegration(draft: IntegrationDraft): void;
14
26
  /**
15
- * Turn a stored credential into a Cursor access token.
27
+ * Turn a stored credential into a Cursor access token. The two credential
28
+ * kinds are handled separately and never substitute for each other.
29
+ *
30
+ * - `oauth`: the session token, renewed in memory once it is due by Cursor's
31
+ * IDE window (the host itself refreshes only in the last five minutes; it
32
+ * then persists the renewal through `refreshOAuth`).
33
+ * - `key`: a raw `crsr_…` API key (from /connect or `CURSOR_API_KEY`),
34
+ * exchanged for a short-lived JWT and re-exchanged near its expiry. Any
35
+ * other value is an already-issued token and is used as-is.
16
36
  *
17
- * OAuth credentials already hold a JWT (the host refreshes them via `refresh`
18
- * above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
19
- * to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
37
+ * Throws `CursorAuthError` when no usable token can be produced.
38
+ */
39
+ export declare function accessTokenFromCredential(credential: CredentialValue | undefined, request?: AccessTokenRequest): Promise<string>;
40
+ /**
41
+ * Current access token of the active Cursor connection, for a Run. Throws a
42
+ * `CursorAuthError` that says why when there is none.
20
43
  */
21
- export declare function accessTokenFromCredential(credential: CredentialValue | undefined): Promise<string | undefined>;
22
- /** Resolve the active Cursor connection into an access token, if any. */
44
+ export declare function requireCursorAccessToken(integration: IntegrationDomain, request?: AccessTokenRequest): Promise<string>;
45
+ /** Access token of the active Cursor connection, or undefined (model discovery). */
23
46
  export declare function resolveCursorAccessToken(integration: IntegrationDomain): Promise<string | undefined>;
@@ -1,6 +1,9 @@
1
1
  import { CURSOR_WEBSITE_HOST } from "../shared.js";
2
2
  import { cursorApiBaseURL } from "../plugin-core.js";
3
- import { buildLoginUrl, decodeJwtExpiryMs, exchangeApiKey, generatePkceChallenge, generatePkceParams, isExpiringSoon, pollForTokens, refreshAccessToken, } from "../auth.js";
3
+ import { buildLoginUrl, decodeJwtExpiryMs, generatePkceChallenge, generatePkceParams, isExchangeableApiKey, pollForTokens, } from "../auth.js";
4
+ import { renewSessionIfDue, resolveApiKeyToken, } from "../auth-renewal.js";
5
+ import { CursorAuthError, CursorProviderError } from "../errors.js";
6
+ import { errorMessage } from "../debug.js";
4
7
  import { CURSOR_INTEGRATION_ID } from "./catalog.js";
5
8
  /**
6
9
  * Integration registration for the OpenCode 2.0 plugin — the replacement for the
@@ -15,6 +18,13 @@ export const CURSOR_ENV_NAMES = ["CURSOR_API_KEY"];
15
18
  function websiteURL() {
16
19
  return process.env.CURSOR_WEBSITE_URL ?? `https://${CURSOR_WEBSITE_HOST}`;
17
20
  }
21
+ /** `expires` of a session credential: the JWT's own expiry, as OpenCode's built-in OAuth integrations store it. */
22
+ function sessionExpires(token) {
23
+ return decodeJwtExpiryMs(token) ?? Date.now();
24
+ }
25
+ function sessionTokensOf(credential) {
26
+ return { accessToken: credential.access, refreshToken: credential.refresh || credential.access };
27
+ }
18
28
  /** Browser (PKCE) login: open URL, then poll until Cursor hands back tokens. */
19
29
  async function authorizeOAuth() {
20
30
  const params = generatePkceParams();
@@ -30,23 +40,38 @@ async function authorizeOAuth() {
30
40
  methodID: CURSOR_OAUTH_METHOD_ID,
31
41
  access: result.accessToken,
32
42
  refresh: result.refreshToken,
33
- expires: decodeJwtExpiryMs(result.accessToken) ?? Date.now(),
43
+ expires: sessionExpires(result.accessToken),
34
44
  })),
35
45
  };
36
46
  }
37
47
  /**
38
- * Renew an expiring Cursor JWT. The host calls this lazily when the stored
39
- * credential is close to expiry, so it must not assume it runs on every request.
48
+ * Host-driven session renewal (`POST /oauth/token`, as Cursor's IDE does).
49
+ * OpenCode 2.0 calls this when the stored `expires` is within five minutes
50
+ * (`packages/core/src/integration.ts`, `connection.resolve`) and persists the
51
+ * result. A session this process already renewed in memory is handed over
52
+ * instead of renewed again. A transient failure returns the unchanged session
53
+ * with `expires` moved to the next allowed attempt, so the host keeps working
54
+ * and asks again later; only a session Cursor ended, or one that expired
55
+ * unrenewed, throws.
40
56
  */
41
- async function refreshOAuth(credential) {
42
- const tokens = await refreshAccessToken(credential.refresh, cursorApiBaseURL());
57
+ export async function refreshOAuth(credential) {
58
+ const renewal = await renewSessionIfDue(sessionTokensOf(credential), { baseUrl: cursorApiBaseURL() });
59
+ const methodID = credential.methodID || CURSOR_OAUTH_METHOD_ID;
60
+ const metadata = credential.metadata === undefined ? {} : { metadata: credential.metadata };
61
+ if (renewal.renewed) {
62
+ return {
63
+ type: "oauth",
64
+ methodID,
65
+ access: renewal.accessToken,
66
+ refresh: renewal.accessToken,
67
+ expires: sessionExpires(renewal.accessToken),
68
+ ...metadata,
69
+ };
70
+ }
43
71
  return {
44
- type: "oauth",
45
- methodID: credential.methodID || CURSOR_OAUTH_METHOD_ID,
46
- access: tokens.accessToken,
47
- refresh: tokens.refreshToken,
48
- expires: decodeJwtExpiryMs(tokens.accessToken) ?? Date.now(),
49
- ...(credential.metadata === undefined ? {} : { metadata: credential.metadata }),
72
+ ...credential,
73
+ methodID,
74
+ expires: renewal.retryAt ?? sessionExpires(credential.access),
50
75
  };
51
76
  }
52
77
  /** Register the Cursor integration and its three connection methods. */
@@ -75,48 +100,52 @@ export function applyCursorIntegration(draft) {
75
100
  });
76
101
  }
77
102
  /**
78
- * Turn a stored credential into a Cursor access token.
103
+ * Turn a stored credential into a Cursor access token. The two credential
104
+ * kinds are handled separately and never substitute for each other.
79
105
  *
80
- * OAuth credentials already hold a JWT (the host refreshes them via `refresh`
81
- * above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
82
- * to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
106
+ * - `oauth`: the session token, renewed in memory once it is due by Cursor's
107
+ * IDE window (the host itself refreshes only in the last five minutes; it
108
+ * then persists the renewal through `refreshOAuth`).
109
+ * - `key`: a raw `crsr_…` API key (from /connect or `CURSOR_API_KEY`),
110
+ * exchanged for a short-lived JWT and re-exchanged near its expiry. Any
111
+ * other value is an already-issued token and is used as-is.
112
+ *
113
+ * Throws `CursorAuthError` when no usable token can be produced.
83
114
  */
84
- export async function accessTokenFromCredential(credential) {
85
- if (!credential)
86
- return undefined;
87
- if (credential.type === "oauth") {
88
- if (credential.access && !isExpiringSoon(credential.access))
89
- return credential.access;
90
- if (!credential.refresh)
91
- return credential.access || undefined;
92
- try {
93
- return (await refreshOAuth(credential)).access;
94
- }
95
- catch {
96
- // Fall back to the existing token; the call may still succeed.
97
- return credential.access || undefined;
98
- }
115
+ export async function accessTokenFromCredential(credential, request = {}) {
116
+ const force = request.forceRefresh === true;
117
+ if (credential?.type === "oauth") {
118
+ return (await renewSessionIfDue(sessionTokensOf(credential), { baseUrl: cursorApiBaseURL(), force })).accessToken;
99
119
  }
100
- if (credential.type === "key") {
101
- // Already a JWT (e.g. exchanged by an earlier run) — use it directly.
102
- if (!credential.key.startsWith("crsr_"))
120
+ if (credential?.type === "key") {
121
+ if (!isExchangeableApiKey(credential.key))
103
122
  return credential.key;
104
- try {
105
- return (await exchangeApiKey(credential.key, cursorApiBaseURL())).accessToken;
106
- }
107
- catch {
108
- return undefined;
109
- }
123
+ return (await resolveApiKeyToken(credential.key, { baseUrl: cursorApiBaseURL(), force })).accessToken;
110
124
  }
111
- return undefined;
125
+ throw new CursorAuthError("No Cursor login found; connect Cursor first", { code: "no_credential" });
112
126
  }
113
- /** Resolve the active Cursor connection into an access token, if any. */
114
- export async function resolveCursorAccessToken(integration) {
127
+ /**
128
+ * Current access token of the active Cursor connection, for a Run. Throws a
129
+ * `CursorAuthError` that says why when there is none.
130
+ */
131
+ export async function requireCursorAccessToken(integration, request = {}) {
132
+ let credential;
115
133
  try {
116
134
  const connection = await integration.connection.active(CURSOR_INTEGRATION_ID);
117
- if (!connection)
118
- return undefined;
119
- return await accessTokenFromCredential(await integration.connection.resolve(connection));
135
+ credential = connection ? await integration.connection.resolve(connection) : undefined;
136
+ }
137
+ catch (error) {
138
+ // The host's resolve runs our refreshOAuth; keep its classified error.
139
+ if (error instanceof CursorProviderError)
140
+ throw error;
141
+ throw new CursorAuthError(`Cursor login could not be loaded (${errorMessage(error)})`, { cause: error });
142
+ }
143
+ return accessTokenFromCredential(credential, request);
144
+ }
145
+ /** Access token of the active Cursor connection, or undefined (model discovery). */
146
+ export async function resolveCursorAccessToken(integration) {
147
+ try {
148
+ return await requireCursorAccessToken(integration);
120
149
  }
121
150
  catch {
122
151
  return undefined;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { trace } from "./debug.js";
14
+ import { errorMessage, trace } from "./debug.js";
15
15
  /** Upstream `PlanExitTool` wording — keep verbatim so behaviour matches OpenCode. */
16
16
  export function createPlanExecutionKickoffText(planPath) {
17
17
  const where = planPath.trim() || "the plan";
@@ -149,7 +149,7 @@ export async function flushPlanExecutionKickoff(sessionID, options = {}) {
149
149
  return true;
150
150
  }
151
151
  catch (error) {
152
- const detail = error instanceof Error ? error.message : String(error);
152
+ const detail = errorMessage(error);
153
153
  const message = `The approved plan could not start execution: ${detail}. ` +
154
154
  "The plan remains active and can be retried.";
155
155
  state.status = "failed";
@@ -7,7 +7,7 @@ import { sessionActivity } from "./activity.js";
7
7
  import { fetchOpenCodeWebSearchText, parseExaWebSearchResults, } from "./web-tools.js";
8
8
  import { captureCursorShellResult, cursorShellEnvForCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
9
9
  import { applyCursorProviderInventory, CURSOR_INTEGRATION_ID } from "./opencode2/catalog.js";
10
- import { applyCursorIntegration, resolveCursorAccessToken } from "./opencode2/integration.js";
10
+ import { applyCursorIntegration, requireCursorAccessToken, resolveCursorAccessToken, } from "./opencode2/integration.js";
11
11
  import { exposeDirectMcpTools, rememberDirectMcpNamespaces } from "./opencode2/mcp-direct.js";
12
12
  import { registerTodoTools } from "./opencode2/todo-tools.js";
13
13
  import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
@@ -140,26 +140,20 @@ const plugin = {
140
140
  let sourceConnection;
141
141
  // ── Credentials ─────────────────────────────────────────
142
142
  await track(ctx.integration.transform(applyCursorIntegration));
143
- let cachedToken;
144
143
  let tokenInflight;
145
144
  /**
146
- * Cache only a *successful* resolution.
145
+ * Current token for model discovery and endpoint warmup, or undefined.
147
146
  *
148
- * On a fresh install `setup()` runs before the user has connected, so the
149
- * first attempt necessarily returns nothing. Memoizing that would pin the
150
- * plugin to "no credentials" for the whole process and models would never
151
- * load, even after a successful /connect.
147
+ * Resolved every time (concurrent callers share one attempt) and never
148
+ * memoized: on a fresh install `setup()` runs before the user has
149
+ * connected, and a long-running daemon outlives any single token. Both
150
+ * the host resolve and the renewal layer are cheap until a renewal is due.
152
151
  */
153
152
  const accessToken = async () => {
154
- if (cachedToken)
155
- return cachedToken;
156
153
  tokenInflight ??= resolveCursorAccessToken(ctx.integration).finally(() => {
157
154
  tokenInflight = undefined;
158
155
  });
159
- const token = await tokenInflight;
160
- if (token)
161
- cachedToken = token;
162
- return token;
156
+ return tokenInflight;
163
157
  };
164
158
  const refreshSourceConnection = async () => {
165
159
  try {
@@ -210,10 +204,11 @@ const plugin = {
210
204
  return;
211
205
  if (!isCursorPackage(event.package, event.model.providerID))
212
206
  return;
213
- const token = await accessToken();
214
207
  event.sdk = createSdk({
215
208
  name: event.model.providerID || CURSOR_PROVIDER_ID,
216
- ...(token ? { accessToken: token } : {}),
209
+ // Resolved per Run open: this SDK lives as long as the daemon, so
210
+ // a token captured here would outlive its expiry.
211
+ getAccessToken: (request) => requireCursorAccessToken(ctx.integration, request),
217
212
  // Static fallback only. This hook fires once per model/package, not
218
213
  // per session, and 2.0 runs one daemon across many projects — the
219
214
  // real per-request directory comes from `x-opencode-directory`
@@ -467,7 +462,6 @@ const plugin = {
467
462
  retry.unref?.();
468
463
  void ensureModels().catch(() => { });
469
464
  const onCredentialSwitch = () => {
470
- cachedToken = undefined;
471
465
  modelsLoaded = false;
472
466
  credentialGeneration++;
473
467
  };
package/dist/plugin.d.ts CHANGED
@@ -1,12 +1,2 @@
1
1
  import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
- export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
3
- export declare function loadClassicTools(options?: {
4
- importModule?: (specifier: string) => Promise<{
5
- tool?: any;
6
- }>;
7
- configDirs?: string[];
8
- }): Promise<{
9
- webSearch: any;
10
- imageSave: any;
11
- }>;
12
2
  export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
package/dist/plugin.js CHANGED
@@ -1,57 +1,45 @@
1
- import path from "node:path";
2
- import { pathToFileURL } from "node:url";
3
1
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, } from "./shared.js";
4
2
  import { cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled } from "./plugin-core.js";
5
- import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
3
+ import { pollForTokens, exchangeApiKey, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs, isExchangeableApiKey } from "./auth.js";
4
+ import { renewSessionIfDue, resolveApiKeyToken, } from "./auth-renewal.js";
5
+ import { CursorAuthError } from "./errors.js";
6
+ import { errorMessage, trace } from "./debug.js";
6
7
  import { readCache, discoverModels, isCacheFresh } from "./models.js";
7
- // Re-exported for API compatibility: tests and downstream code import these
8
- // from "./plugin.js". Canonical home is ./model-config.ts, which stays free of
9
- // host plugin imports so the OpenCode 2.0 entrypoint can share it.
10
8
  import { modelsToConfig } from "./model-config.js";
11
- export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
12
- import { opencodeGlobalCacheDir, opencodeGlobalConfigDirs } from "./context/paths.js";
9
+ import { loadClassicTools } from "./classic-tools.js";
10
+ import { opencodeGlobalCacheDir } from "./context/paths.js";
13
11
  import { readStoredAuth } from "./context/auth-store.js";
14
12
  import { resolveAgentUrl } from "./agent-url.js";
15
13
  import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
16
14
  import { sessionActivity } from "./activity.js";
17
- import { createOpenCodeWebSearchTool, openCodeWebSearchTool } from "./web-search-tool.js";
18
- import { createCursorImageSaveTool, cursorImageSaveTool } from "./image-save-tool.js";
19
15
  import { createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
20
16
  import { dispatchHostEventBridge } from "./host-event-bridge.js";
21
17
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
22
- export async function loadClassicTools(options = {}) {
23
- const configDir = options.configDirs?.[0] ?? opencodeGlobalConfigDirs()[0];
24
- const candidates = [
25
- ...(configDir
26
- ? [path.join(configDir, "node_modules", "@opencode-ai", "plugin", "dist", "index.js")]
27
- : []),
28
- "@opencode-ai/plugin",
29
- ];
30
- const importModule = options.importModule ?? ((specifier) => import(specifier));
31
- // The global config copy is the host-owned installation; bare import is a fallback.
32
- for (const candidate of candidates) {
33
- try {
34
- const specifier = path.win32.isAbsolute(candidate) && !path.isAbsolute(candidate)
35
- ? new URL(`file:///${candidate.replaceAll("\\", "/")}`).href
36
- : path.isAbsolute(candidate)
37
- ? pathToFileURL(candidate).href
38
- : candidate;
39
- const module = await importModule(specifier);
40
- if (typeof module.tool === "function" && module.tool.schema) {
41
- const factory = { tool: module.tool, schema: module.tool.schema };
42
- return {
43
- webSearch: createOpenCodeWebSearchTool(factory),
44
- imageSave: createCursorImageSaveTool(factory),
45
- };
46
- }
47
- }
48
- catch {
49
- // Try the next host-owned/normal resolution location.
50
- }
18
+ /**
19
+ * Raw `crsr_` key behind an API-key login: under `metadata.apiKey` (saved at
20
+ * login, and merged there from the prompt inputs by OpenCode's CLI), or as
21
+ * `key` itself when OpenCode stored the typed key without our exchange.
22
+ */
23
+ function storedApiKey(auth) {
24
+ const fromMetadata = auth.metadata?.apiKey;
25
+ if (typeof fromMetadata === "string" && isExchangeableApiKey(fromMetadata))
26
+ return fromMetadata;
27
+ return isExchangeableApiKey(auth.key) ? auth.key : undefined;
28
+ }
29
+ function sessionTokens(auth) {
30
+ // Cursor's IDE refreshes with the stored refresh token and then stores the
31
+ // new access token as both; an empty refresh field means the same.
32
+ return { accessToken: auth.access, refreshToken: auth.refresh || auth.access };
33
+ }
34
+ /** Whether `latest` is still the credential a renewal started from. */
35
+ function isSameCredential(latest, started) {
36
+ if (latest.type === "oauth" && started.type === "oauth") {
37
+ return latest.access === started.access && latest.refresh === started.refresh;
51
38
  }
52
- // `tool()` is an identity helper; the fallback definitions use plain JSON
53
- // Schema accepted by OpenCode's legacy registry when no Zod helper is present.
54
- return { webSearch: openCodeWebSearchTool, imageSave: cursorImageSaveTool };
39
+ if (latest.type === "api" && started.type === "api") {
40
+ return latest.key === started.key && storedApiKey(latest) === storedApiKey(started);
41
+ }
42
+ return false;
55
43
  }
56
44
  export async function CursorPlugin(input) {
57
45
  const cacheDir = opencodeGlobalCacheDir();
@@ -76,13 +64,7 @@ export async function CursorPlugin(input) {
76
64
  });
77
65
  }
78
66
  : undefined);
79
- // Last access token successfully resolved in this plugin instance. Config's
80
- // loadModels can only read OpenCode's durable store (auth.json /
81
- // OPENCODE_AUTH_CONTENT); auth.loader gets live credentials via getAuth().
82
- // Those usually match, but after a refresh where persistAuthBestEffort fails,
83
- // getAuth() may still see the old credentials while we already hold a usable
84
- // token here — keep it so the loader can still discover models.
85
- let sessionAccessToken;
67
+ let lastPersistAttempt;
86
68
  async function persistAuth(body) {
87
69
  await input.client.auth.set({
88
70
  path: { id: CURSOR_PROVIDER_ID },
@@ -110,64 +92,93 @@ export async function CursorPlugin(input) {
110
92
  * and config share the same underlying credentials when possible.
111
93
  */
112
94
  async function authForLoader(getAuth) {
113
- return (await getAuth()) ?? (await authFromStore());
95
+ // getAuth is also called long after the loader returned (per-Run token
96
+ // resolution); never let a host-side failure there hide the durable store.
97
+ return (await getAuth().catch(() => undefined)) ?? (await authFromStore());
114
98
  }
115
- async function resolveAccessToken(auth) {
116
- if (auth.type === "api") {
117
- let accessToken = auth.key;
118
- const refreshToken = auth.metadata?.refreshToken;
119
- // API-key exchange returns a short-lived JWT stored as `key`. Refresh
120
- // it the same way as OAuth when it is expiring / already expired.
121
- if (refreshToken && isExpiringSoon(auth.key)) {
122
- try {
123
- const newTokens = await refreshAccessToken(refreshToken, apiBaseURL);
124
- accessToken = newTokens.accessToken;
125
- await persistAuthBestEffort({
126
- type: "api",
127
- key: newTokens.accessToken,
128
- metadata: {
129
- ...auth.metadata,
130
- refreshToken: newTokens.refreshToken,
131
- },
132
- });
133
- }
134
- catch {
135
- // refresh failed — keep the existing key; the next call may still work
136
- }
137
- }
138
- if (accessToken)
139
- sessionAccessToken = accessToken;
140
- return accessToken;
99
+ /**
100
+ * Persist a renewed credential unless the stored one changed meanwhile (a
101
+ * re-login, or another process's renewal): never overwrite newer state.
102
+ */
103
+ async function persistRenewal(started, next, readCurrent) {
104
+ // One attempt per renewed token: when the write fails (read-only store,
105
+ // injected OPENCODE_AUTH_CONTENT) later Runs keep the in-memory token
106
+ // instead of re-reading and re-writing the store on every turn.
107
+ const token = next.type === "oauth" ? next.access : next.type === "api" ? next.key : undefined;
108
+ if (token === undefined || token === lastPersistAttempt)
109
+ return;
110
+ lastPersistAttempt = token;
111
+ const latest = await readCurrent().catch(() => undefined);
112
+ if (!latest || !isSameCredential(latest, started)) {
113
+ trace("auth: stored Cursor credential changed during renewal; keeping the stored one");
114
+ return;
141
115
  }
142
- if (auth.type === "oauth") {
143
- if (!isExpiringSoon(auth.access)) {
144
- sessionAccessToken = auth.access;
145
- return auth.access;
146
- }
147
- if (!auth.refresh)
148
- return undefined;
149
- try {
150
- const newTokens = await refreshAccessToken(auth.refresh, apiBaseURL);
151
- // Preserve optional OAuth fields (v2 Auth / plugin may carry these).
152
- const extras = auth;
153
- // Use the new token even if persisting back to OpenCode fails.
154
- await persistAuthBestEffort({
155
- type: "oauth",
156
- access: newTokens.accessToken,
157
- refresh: newTokens.refreshToken,
158
- expires: decodeJwtExpiryMs(newTokens.accessToken) ?? Date.now(),
159
- ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
160
- ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
161
- });
162
- sessionAccessToken = newTokens.accessToken;
163
- return newTokens.accessToken;
164
- }
165
- catch {
166
- return undefined;
167
- }
116
+ await persistAuthBestEffort(next);
117
+ }
118
+ /** Browser-login session: renew when due (or forced) and persist the result. */
119
+ async function resolveSession(auth, readCurrent, force = false) {
120
+ const renewal = await renewSessionIfDue(sessionTokens(auth), { baseUrl: apiBaseURL, force });
121
+ if (renewal.renewed) {
122
+ // Preserve optional OAuth fields (v2 Auth / plugin may carry these).
123
+ const extras = auth;
124
+ await persistRenewal(auth, {
125
+ type: "oauth",
126
+ access: renewal.accessToken,
127
+ refresh: renewal.accessToken,
128
+ expires: decodeJwtExpiryMs(renewal.accessToken) ?? Date.now(),
129
+ ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
130
+ ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
131
+ }, readCurrent);
132
+ }
133
+ return renewal;
134
+ }
135
+ /** API-key login: re-exchange the stored raw key when the JWT nears expiry. */
136
+ async function resolveApiKeyLogin(auth, readCurrent, force = false) {
137
+ const apiKey = storedApiKey(auth);
138
+ if (!apiKey) {
139
+ // Saved by an older version that kept only the exchanged JWT and its
140
+ // refresh token. Cursor renews API-key logins only by exchanging the key.
141
+ if (!isExpiringSoon(auth.key, 30))
142
+ return auth.key;
143
+ throw new CursorAuthError("This Cursor API-key login was saved without the key, so it cannot be renewed; sign in again with the API key", { code: "api_key_missing" });
144
+ }
145
+ const token = await resolveApiKeyToken(apiKey, {
146
+ baseUrl: apiBaseURL,
147
+ ...(isExchangeableApiKey(auth.key) ? {} : { seed: auth.key }),
148
+ force,
149
+ });
150
+ if (token.renewed) {
151
+ const { refreshToken: _unused, ...metadata } = auth.metadata ?? {};
152
+ await persistRenewal(auth, {
153
+ type: "api",
154
+ key: token.accessToken,
155
+ metadata: { ...metadata, apiKey },
156
+ }, readCurrent);
168
157
  }
158
+ return token.accessToken;
159
+ }
160
+ /**
161
+ * Current access token for the stored credential. The two credential kinds
162
+ * are handled by separate paths and never substitute for each other.
163
+ */
164
+ async function resolveAccessToken(auth, readCurrent, force = false) {
165
+ if (auth.type === "oauth")
166
+ return (await resolveSession(auth, readCurrent, force)).accessToken;
167
+ if (auth.type === "api")
168
+ return resolveApiKeyLogin(auth, readCurrent, force);
169
169
  return undefined;
170
170
  }
171
+ /** Best-effort token for startup work (model discovery, endpoint warmup). */
172
+ async function startupAccessToken(auth, readCurrent) {
173
+ try {
174
+ return await resolveAccessToken(auth, readCurrent);
175
+ }
176
+ catch (error) {
177
+ // Surfaced again, with the same message, when a Run asks for a token.
178
+ trace(`auth: no usable Cursor token at startup (${errorMessage(error)})`);
179
+ return undefined;
180
+ }
181
+ }
171
182
  async function loadModels() {
172
183
  const cached = await readCache(cacheDir);
173
184
  if (cached?.models.length && isCacheFresh(cached)) {
@@ -178,7 +189,7 @@ export async function CursorPlugin(input) {
178
189
  // or old-schema caches here so this process materializes the new model set.
179
190
  const auth = await authFromStore();
180
191
  if (auth) {
181
- const accessToken = await resolveAccessToken(auth);
192
+ const accessToken = await startupAccessToken(auth, authFromStore);
182
193
  if (accessToken) {
183
194
  try {
184
195
  const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
@@ -352,7 +363,9 @@ export async function CursorPlugin(input) {
352
363
  type: "success",
353
364
  key: result.accessToken,
354
365
  provider: CURSOR_PROVIDER_ID,
355
- metadata: { refreshToken: result.refreshToken },
366
+ // Keep the raw key: Cursor renews an API-key login only by
367
+ // exchanging the key again (its refresh token is never used).
368
+ metadata: { apiKey },
356
369
  };
357
370
  }
358
371
  catch {
@@ -362,10 +375,11 @@ export async function CursorPlugin(input) {
362
375
  },
363
376
  ],
364
377
  async loader(getAuth) {
365
- const auth = await authForLoader(getAuth);
366
- // Prefer credentials from getAuth/store; if refresh already succeeded in
367
- // loadModels but persist failed, fall back to the in-memory session token.
368
- const accessToken = (auth ? await resolveAccessToken(auth) : undefined) ?? sessionAccessToken;
378
+ const readCurrent = () => authForLoader(getAuth);
379
+ const auth = await readCurrent();
380
+ // Model discovery and endpoint warmup need a token: like any request,
381
+ // that renews a due session (or expiring API-key JWT) first.
382
+ const accessToken = auth ? await startupAccessToken(auth, readCurrent) : undefined;
369
383
  if (accessToken) {
370
384
  // Skip when config already filled a fresh cache (avoids a second
371
385
  // AvailableModels round-trip + background refresh on cold start).
@@ -384,8 +398,23 @@ export async function CursorPlugin(input) {
384
398
  telemetryEnabled: cursorGetServerConfigTelemetryEnabled(),
385
399
  }).catch(() => { });
386
400
  }
401
+ // Asked for on every Run open: reads the live credential (so a
402
+ // re-login applies without a restart), renews it when due, persists the
403
+ // renewal. This is how OpenCode's own OAuth providers hand over
404
+ // credentials (codex, xai, copilot: a `fetch` that calls getAuth() per
405
+ // request): no token is placed in the options, which OpenCode serves
406
+ // unredacted from /provider; a function is dropped there.
407
+ const getAccessToken = async (request) => {
408
+ const current = await readCurrent();
409
+ const token = current
410
+ ? await resolveAccessToken(current, readCurrent, request?.forceRefresh === true)
411
+ : undefined;
412
+ if (!token)
413
+ throw new CursorAuthError("No Cursor login found; sign in to Cursor", { code: "no_credential" });
414
+ return token;
415
+ };
387
416
  return {
388
- ...(accessToken ? { accessToken } : {}),
417
+ getAccessToken,
389
418
  workspaceRoot: input.directory,
390
419
  cacheDir,
391
420
  };