@magpie-community/opencode-cursor-auth 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,11 +13,13 @@ An account is a Cursor access token. There are three ways to get one:
13
13
  plugin polls `api2.cursor.sh/auth/poll` until the browser has signed in.
14
14
  The account is named by its email (`DashboardService/GetMe`).
15
15
  - **cursor-agent's sign-in**: takes the account `cursor-agent login`
16
- signed in to. Its token is read on every request from where the CLI
17
- keeps it: the macOS keychain (`cursor-access-token`), else its
18
- `auth.json` (`~/.cursor/`, `$XDG_CONFIG_HOME/cursor/` or
19
- `%APPDATA%\Cursor\`). The plugin never writes it; when it has less than
20
- 5 minutes left, `cursor-agent status` is run, which renews it.
16
+ signed in to. Its token is read from where the CLI keeps it: the macOS
17
+ keychain (`cursor-access-token`), else its `auth.json` (`~/.cursor/`,
18
+ `$XDG_CONFIG_HOME/cursor/` or `%APPDATA%\Cursor\`). A keychain read is
19
+ kept for 10 seconds, and one the keychain refused (the user said no to
20
+ its dialog) is not asked again for 10 minutes; signing in reads it
21
+ afresh. The plugin never writes it; when it has less than 5 minutes
22
+ left, `cursor-agent status` is run, which renews it.
21
23
  - **Cursor API key**: a key from cursor.com/dashboard → Integrations. It is
22
24
  exchanged for a token at `/auth/exchange_user_api_key`, as the CLI does
23
25
  with `CURSOR_API_KEY`, and exchanged again when the token runs out.
package/index.mjs CHANGED
@@ -63,7 +63,7 @@ const exists = (p) => stat(p).then((s) => !s.isDirectory(), () => false)
63
63
  function run(file, args, { timeout = 10_000, env } = {}) {
64
64
  return new Promise((resolve) => {
65
65
  execFile(file, args, { timeout, env: env ?? process.env, maxBuffer: 8 << 20 }, (err, stdout) =>
66
- resolve({ ok: !err, out: String(stdout ?? "") }))
66
+ resolve({ ok: !err, out: String(stdout ?? ""), code: typeof err?.code === "number" ? err.code : err ? -1 : 0 }))
67
67
  })
68
68
  }
69
69
 
@@ -117,12 +117,48 @@ function authFile() {
117
117
  return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "cursor", "auth.json")
118
118
  }
119
119
 
120
+ // TOKEN_TTL is how long a token read from the Keychain is taken as it: a
121
+ // sign-in reads it afresh, one made in cursor-agent is seen within it.
122
+ // KEYCHAIN_DENIED is how long a read the user may have been asked about,
123
+ // and answered no to, is not asked again: with the item's trust list
124
+ // holding no `security`, every ask can show the dialog again. Both as the
125
+ // built-in's cursorKeychainToken (magpie #746, #2408).
126
+ const TOKEN_TTL = 10_000
127
+ const KEYCHAIN_DENIED = 10 * 60_000
128
+ const keychain = { tok: "", at: 0, ask: 0, busy: null, now: () => Date.now(), read: (args) => run("security", args) }
129
+
130
+ // keychainToken is cursor-agent's token in the Keychain, "" for none; fresh
131
+ // reads it whatever was read last. Reads go one at a time, so callers at
132
+ // once share one `security` run (and one dialog).
133
+ function keychainToken(fresh = false) {
134
+ const go = async () => {
135
+ const now = keychain.now()
136
+ const ttl = !keychain.tok && now - keychain.ask < KEYCHAIN_DENIED ? KEYCHAIN_DENIED : TOKEN_TTL
137
+ if (!fresh && now - keychain.at < ttl) return keychain.tok
138
+ const r = await keychain.read(["find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w"])
139
+ keychain.tok = ""
140
+ keychain.at = keychain.now()
141
+ if (r.ok) {
142
+ keychain.tok = r.out.trim()
143
+ keychain.ask = 0
144
+ } else if (r.code !== 44) {
145
+ // 44 is "no such item"; anything else may have been a dialog the
146
+ // user said no to, or a keychain that refused
147
+ keychain.ask = keychain.at
148
+ } else keychain.ask = 0
149
+ return keychain.tok
150
+ }
151
+ const p = (keychain.busy ?? Promise.resolve()).then(go, go)
152
+ keychain.busy = p.catch(() => {})
153
+ return p
154
+ }
155
+
120
156
  // cliToken is the access token cursor-agent signed in with; read, never
121
- // written.
122
- async function cliToken() {
157
+ // written. fresh reads the Keychain whatever was read last.
158
+ async function cliToken(fresh = false) {
123
159
  if (GOOS === "darwin") {
124
- const r = await run("security", ["find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w"])
125
- if (r.ok && r.out.trim()) return r.out.trim()
160
+ const t = await keychainToken(fresh)
161
+ if (t) return t
126
162
  }
127
163
  try {
128
164
  return JSON.parse(await readFile(authFile(), "utf8"))?.accessToken ?? ""
@@ -163,7 +199,7 @@ async function cliTokenNow() {
163
199
  if (exe) {
164
200
  cliRefresh ??= run(exe, ["status"], { timeout: 30_000 }).finally(() => (cliRefresh = null))
165
201
  await cliRefresh
166
- tok = await cliToken()
202
+ tok = await cliToken(true)
167
203
  }
168
204
  // the built-in's words (provider.CursorToken)
169
205
  if (!tok) throw new AuthError("Cursor isn't signed in; sign in from magpie's Providers page or run `cursor-agent login`")
@@ -332,7 +368,7 @@ async function cliSignIn() {
332
368
  instructions: "Uses the account `cursor-agent login` signed in to.",
333
369
  method: "auto",
334
370
  async callback() {
335
- const tok = await cliToken()
371
+ const tok = await cliToken(true)
336
372
  const exp = expiry(tok)
337
373
  if (!tok) return { type: "failed", error: "cursor-agent isn't signed in: run `cursor-agent login`" }
338
374
  if (exp && exp <= Date.now()) return { type: "failed", error: "cursor-agent's sign-in has run out: run `cursor-agent login` again" }
@@ -1209,7 +1245,9 @@ function conversation(chat, tools) {
1209
1245
  pending.splice(i, 1)
1210
1246
  const text = textOf(m.content)
1211
1247
  size += text.length
1212
- results.push(result(id, text, false))
1248
+ // a failed tool's result says so, as the built-in's cursorResult
1249
+ // told Cursor isError
1250
+ results.push(result(id, text, m.is_error === true || m.isError === true))
1213
1251
  }
1214
1252
  continue
1215
1253
  }
@@ -2618,12 +2656,12 @@ async function answerOf(auth, chat, signal, session = "") {
2618
2656
  let response = native ? await runNative({ ...args, native }) : await runOnce(args)
2619
2657
  const rebuild = async () => {
2620
2658
  const fallback = await runOnce(args)
2621
- fallback.diagnostic = "stateless; bootstrap=imported-history; reason=missing-batch-count"
2659
+ fallback.diagnostic = "stateless; bootstrap=imported-history; reason=" + (response.error?.continuityReason || "missing-batch-count")
2622
2660
  return fallback
2623
2661
  }
2624
- // A missing native batch count may be retried once, only before Chat
2625
- // output is exposed or any native result was submitted. Never repeat an
2626
- // uncertain tool result write or duplicate a progressive stream's output.
2662
+ // A missing or conflicting native batch count may be retried once,
2663
+ // only before Chat output is exposed or any native result was submitted.
2664
+ // Never repeat an uncertain tool write or progressive stream output.
2627
2665
  if (response.error?.retentionIneligible && !signal?.aborted) response = await rebuild()
2628
2666
  else if (native) response.rebuild = rebuild
2629
2667
  response.diagnostic ??= "stateless; bootstrap=imported-history; reason=" + (prepared.fallback || "retention capacity or reservation unavailable")
@@ -2681,8 +2719,10 @@ async function answerOf(auth, chat, signal, session = "") {
2681
2719
  for await (const e of events) {
2682
2720
  if (e.error) {
2683
2721
  if (!e.error.retentionIneligible || !r.rebuild || signal?.aborted) return errorResponse(e.error)
2722
+ const reason = e.error.continuityReason
2684
2723
  r = await r.rebuild()
2685
2724
  if (r.error) return errorResponse(r.error)
2725
+ if (reason) r.diagnostic = "stateless; bootstrap=imported-history; reason=" + reason
2686
2726
  events = r.events
2687
2727
  responseHeaders["X-Magpie-Cursor-Continuity"] = r.diagnostic
2688
2728
  retry = true
@@ -2828,4 +2868,4 @@ export async function CursorAuthPlugin() {
2828
2868
  }
2829
2869
 
2830
2870
  // for tests
2831
- export const _internal = { nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
2871
+ export const _internal = { keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
@@ -675,7 +675,13 @@ class NativeSession {
675
675
  }
676
676
  listed(count) {
677
677
  if (!Number.isSafeInteger(count) || count < 1) invalid("invalid native tool count", 502)
678
- if (this.segment.expected != null && this.segment.expected !== count) invalid("inconsistent native tool count", 502)
678
+ if (this.segment.expected != null && this.segment.expected !== count) {
679
+ // Conflicting counts leave the native step boundary ambiguous. Retire
680
+ // this owner; Chat may rebuild from history only if no native result
681
+ // was submitted to this Run.
682
+ this.fail({ status: 502, message: "inconsistent native tool count", retentionIneligible: !this.resultSubmitted, continuityReason: "inconsistent-batch-count" })
683
+ return
684
+ }
679
685
  this.segment.expected = count
680
686
  if (this.state === "awaiting-results") return // an identical retransmission
681
687
  this.waitForBatch()
@@ -876,7 +882,10 @@ class NativeSession {
876
882
  } else if (u.num === 14) {
877
883
  if (!this.segment.content && !this.segment.calls.length) invalid("an empty reply (Cursor ended the turn without text or a tool call)", 502)
878
884
  this.terminalSeen(uf)
879
- } else if (u.num === 27) this.listed(num(uf, 1))
885
+ } else if (u.num === 27) {
886
+ this.listed(num(uf, 1))
887
+ if (this.error) return
888
+ }
880
889
  }
881
890
  break
882
891
  case 2: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Cursor subscriptions (Pro, Pro+, Ultra, Teams) in OpenCode and magpie, through the API cursor-agent talks to",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",