@magpie-community/opencode-cursor-auth 0.2.16 → 0.2.18

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.
@@ -94,7 +96,10 @@ Tool results can continue a retained Run in the same plugin process. The owner
94
96
  uses the same session/cache key and first user message as `conversation_id`, so
95
97
  Task subagents sharing their parent's session can run independently. Retention
96
98
  waits at most 120 seconds for a result batch and 15 minutes for a Run's total
97
- lifetime. The pool holds at most 8 owners per account and 32 per process, with
99
+ lifetime. A continuation arriving in the last minute of that lifetime rebuilds
100
+ from supplied history before submitting results to the old Run. The absolute
101
+ deadline still applies to an active segment; it is never extended by activity.
102
+ The pool holds at most 8 owners per account and 32 per process, with
98
103
  bounded history, blobs, arguments, and pending writes. When pending writes
99
104
  reach their bound (a long history asks for its blobs at once, and a slow link
100
105
  drains them slowly), the Run waits for the link to drain, and fails only if it
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
  }
@@ -2828,4 +2866,4 @@ export async function CursorAuthPlugin() {
2828
2866
  }
2829
2867
 
2830
2868
  // 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 }
2869
+ 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 }
@@ -381,6 +381,14 @@ export class NativeSessionPool {
381
381
  return { owner, batch, replay: true, segment: batch.next }
382
382
  }
383
383
  if (owner.state !== "awaiting-results" || owner.segment !== batch || !batch.clean) return fallback("owner busy")
384
+ // A retained Run's absolute timer spans all tool roundtrips. Rebuild
385
+ // near its deadline before writing this batch: otherwise a new Chat
386
+ // segment can lose its transport after already exposing output.
387
+ const reserve = Math.min(60_000, owner.limits.lifetime / 10)
388
+ if (owner.expiresAt - Date.now() <= reserve) {
389
+ owner.fail({ status: 410, message: "native Run retired before lifetime deadline" }, "expired")
390
+ return fallback("owner near lifetime deadline")
391
+ }
384
392
  return { owner, batch, results, encoded, digest, chat }
385
393
  } catch (e) {
386
394
  if (!(e instanceof NativeSessionError)) throw e
@@ -463,7 +471,14 @@ class NativeSession {
463
471
  this.terminal = false
464
472
  this.segment = new Segment(this, prepared.chat)
465
473
  this.retain(bytes(prepared.chat))
466
- this.lifetimeTimer = later(() => this.fail({ status: 410, message: "native Run lifetime expired" }, "expired"), this.limits.lifetime)
474
+ this.expireIn(this.limits.lifetime)
475
+ }
476
+ // expireIn sets the Run's absolute deadline ms from now: from its start,
477
+ // the whole lifetime; a test brings it in after the first answer
478
+ expireIn(ms) {
479
+ clearTimeout(this.lifetimeTimer)
480
+ this.expiresAt = Date.now() + ms
481
+ this.lifetimeTimer = later(() => this.fail({ status: 410, message: "native Run lifetime expired" }, "expired"), ms)
467
482
  }
468
483
  retain(n) {
469
484
  if (this.retainedBytes + n > this.limits.outputBytes) invalid("native output/history retention limit reached", 503)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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",