@magpie-community/opencode-cursor-auth 0.2.2 → 0.2.4

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 (3) hide show
  1. package/README.md +12 -0
  2. package/index.mjs +171 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,18 @@ plugin's `fetch` answers them on Cursor's agent API:
38
38
  - Each request is one `agent.v1.AgentService/Run`, a Connect stream both
39
39
  ways over HTTP/2, with the CLI's headers (`x-cursor-client-type: cli`,
40
40
  its version, privacy mode on).
41
+ - Where HTTP/2 can't open a Run (an error before its response, or none in
42
+ 15 s: a proxy or network that blocks HTTP/2), it goes as Cursor's clients
43
+ run it without HTTP/2: `agent.v1.AgentService/RunSSE` down and a
44
+ `aiserver.v1.BidiService/BidiAppend` for each client message up, over
45
+ HTTP/1.1 on `api2.cursor.sh`, through the proxy magpie gives the request.
46
+ When the failure says HTTP/2 itself can't be had (no head in 15 s,
47
+ "h2 is not supported", no HTTP/2 in ALPN, a protocol error), the Runs
48
+ after it go straight to HTTP/1.1 for 10 minutes; a failed connection
49
+ (refused, no network) or a stream the server refused leaves the next Run
50
+ to try HTTP/2 again. A region error over HTTP/1.1 sends the Run back to
51
+ HTTP/2 at the region's agent host. A BidiAppend with no answer in 60 s
52
+ (more for a large one) ends the Run, as cursor-agent does.
41
53
  - The whole conversation goes each time, as AI SDK messages kept as blobs
42
54
  the server asks for. The caller's tools are MCP tools, listed in the
43
55
  system prompt; the model calls them through Cursor's `CallDynamicTool`,
package/index.mjs CHANGED
@@ -11,6 +11,10 @@
11
11
  // CURSOR_API_KEY.
12
12
  //
13
13
  // Requests go to AgentService/Run, a Connect stream both ways over HTTP/2.
14
+ // Where HTTP/2 can't open one (a proxy or network that blocks it), the Run
15
+ // goes as Cursor's clients run it without HTTP/2: its replies down one
16
+ // server stream, AgentService/RunSSE, and each client message up as a call
17
+ // of its own, BidiService/BidiAppend, both over HTTP/1.1 on api2.
14
18
  // Cursor keeps a conversation on its client: each message of the prompt is
15
19
  // an AI SDK message in JSON, a blob named by its sha256, which the server
16
20
  // asks the client for as it reads them. So each request goes whole, as such
@@ -27,6 +31,7 @@
27
31
  import http2 from "node:http2"
28
32
  import { STATUS_CODES } from "node:http"
29
33
  import { gunzipSync } from "node:zlib"
34
+ import { Duplex } from "node:stream"
30
35
  import { createHash, randomBytes, randomUUID } from "node:crypto"
31
36
  import { execFile } from "node:child_process"
32
37
  import { readFile, stat, readdir, realpath } from "node:fs/promises"
@@ -400,8 +405,12 @@ async function usage(tok, ids) {
400
405
  return bucket.has(model) || firstParty(model)
401
406
  }
402
407
  // Auto is always one, so the pool's list is never empty (which would
403
- // count every model)
404
- const pool = [...new Set(["auto", ...ids, ...(data.autoBucketModels ?? [])])].filter(inPool)
408
+ // count every model). A model listed at its context sizes (grok-4.7@256k)
409
+ // is still asked for by its bare name (grok-4.7) where it was picked
410
+ // before the list had sizes, and runs as its default, so the pool names
411
+ // that too (Will on Discord)
412
+ const asked = ids.flatMap((id) => [id, id.replace(/@[^@]*$/, "")])
413
+ const pool = [...new Set(["auto", ...asked, ...(data.autoBucketModels ?? [])])].filter(inPool)
405
414
  const num = (v) => (typeof v === "number" ? v : 0)
406
415
  // the two pools fit the line; the total goes in its tooltip
407
416
  return {
@@ -1509,18 +1518,61 @@ async function agentURL(tok, fresh) {
1509
1518
  }
1510
1519
 
1511
1520
  // open starts a Run at base: the session, the stream, and the response's
1512
- // status, or an error the request never got past.
1513
- function open(base, headers, signal) {
1521
+ // status, or an error the request never got past. A Run HTTP/2 can't open
1522
+ // goes over HTTP/1.1 (openH1). When the failure says HTTP/2 itself can't be
1523
+ // had here (h2Blocked), the Runs after it go straight to HTTP/1.1 for
1524
+ // H2.retryAfter, or until a region error sends one back (answerOf); a
1525
+ // connection that failed (refused, no network) or a stream the server
1526
+ // refused leaves the next Run to try HTTP/2 again.
1527
+ let h1Until = 0
1528
+ async function open(base, headers, signal) {
1529
+ if (Date.now() >= h1Until) {
1530
+ const o = await openH2(base, headers, signal)
1531
+ if (!o.h2Failed) return o
1532
+ if (o.blocked) h1Until = Date.now() + H2.retryAfter
1533
+ }
1534
+ return openH1(headers, signal)
1535
+ }
1536
+
1537
+ const H2 = {
1538
+ wait: 15_000, // ms an HTTP/2 Run waits for its response's head
1539
+ retryAfter: 10 * 60_000, // ms the Runs after one found HTTP/2 blocked stay on HTTP/1.1
1540
+ }
1541
+
1542
+ // NET are the failures of a connection itself, which say nothing of HTTP/2
1543
+ const NET = ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "ECONNRESET", "ETIMEDOUT", "ECONNABORTED", "EPIPE"]
1544
+
1545
+ // h2Blocked is whether a failure to open an HTTP/2 Run says HTTP/2 itself
1546
+ // can't be had: no head in H2.wait, the "h2 is not supported" magpie's
1547
+ // host gives a tunnel that can't carry it, a server or proxy that won't
1548
+ // negotiate it (ALPN), an HTTP/2 protocol error. Anything else, a failed
1549
+ // connection above all, is not.
1550
+ function h2Blocked(e) {
1551
+ if (e?.h2Timeout) return true
1552
+ const code = String(e?.code ?? "")
1553
+ const text = `${code} ${e?.message ?? ""}`
1554
+ if (NET.some((c) => text.includes(c))) return false
1555
+ // a stream the server refused or reset: HTTP/2 itself got through
1556
+ if (code === "ERR_HTTP2_STREAM_ERROR") return false
1557
+ return code.startsWith("ERR_HTTP2") || /h2 is not supported|alpn|nghttp2|protocol error/i.test(text)
1558
+ }
1559
+
1560
+ // openH2 is open over HTTP/2. A Run that fails before its response, other
1561
+ // than by being cancelled, says h2Failed, and blocked when h2Blocked says
1562
+ // so of the failure.
1563
+ function openH2(base, headers, signal) {
1514
1564
  return new Promise((resolve) => {
1515
1565
  const u = new URL(base)
1516
1566
  const session = http2.connect(u.origin)
1517
1567
  let done = false
1518
- const fail = (e) => {
1568
+ const fail = (e, h2Failed = true) => {
1519
1569
  if (done) return
1520
1570
  done = true
1571
+ clearTimeout(timer)
1521
1572
  session.destroy()
1522
- resolve({ error: { status: 502, message: e?.message ?? String(e) } })
1573
+ resolve({ error: { status: 502, message: e?.message ?? String(e) }, h2Failed, blocked: h2Failed && h2Blocked(e) })
1523
1574
  }
1575
+ const timer = setTimeout(() => fail(Object.assign(new Error(`no answer over HTTP/2 in ${H2.wait / 1000}s`), { h2Timeout: true })), H2.wait)
1524
1576
  session.on("error", fail)
1525
1577
  const req = session.request({
1526
1578
  ":method": "POST",
@@ -1528,15 +1580,120 @@ function open(base, headers, signal) {
1528
1580
  ...headers,
1529
1581
  })
1530
1582
  req.on("error", fail)
1583
+ // a stream reset before its head (RST_STREAM, REFUSED_STREAM) may close
1584
+ // it with no error said
1585
+ req.once("close", () => fail(Object.assign(new Error(`the HTTP/2 stream closed before its response (code ${req.rstCode ?? "?"})`), { code: "ERR_HTTP2_STREAM_ERROR" })))
1531
1586
  req.once("response", (h) => {
1532
1587
  if (done) return
1533
1588
  done = true
1589
+ clearTimeout(timer)
1534
1590
  resolve({ session, req, status: Number(h[":status"]) })
1535
1591
  })
1536
- signal?.addEventListener("abort", () => fail(new Error("the request was cancelled")), { once: true })
1592
+ signal?.addEventListener("abort", () => fail(new Error("the request was cancelled"), false), { once: true })
1537
1593
  })
1538
1594
  }
1539
1595
 
1596
+ const H1 = { appendWait: 60_000 } // ms a BidiAppend may take before the Run fails, as cursor-agent's appendTimeoutMs
1597
+
1598
+ // connectCode is Connect's code for an HTTP status that came with no
1599
+ // Connect error of its own
1600
+ function connectCode(status) {
1601
+ return { 400: "invalid_argument", 401: "unauthenticated", 403: "permission_denied", 429: "resource_exhausted" }[status] ?? "unavailable"
1602
+ }
1603
+
1604
+ // openH1 is open over HTTP/1.1, as Cursor's clients run a Run without
1605
+ // HTTP/2: what runOnce writes (a Connect frame each time) goes up as
1606
+ // BidiAppend calls in turn, numbered from 0, and what RunSSE sends down is
1607
+ // the stream it reads, Connect's frames as the HTTP/2 Run's. Its response
1608
+ // is a 200 at once; an error Cursor says later, or a call that fails,
1609
+ // ends the stream with Connect's end frame, which runOnce reads as the
1610
+ // Run's own.
1611
+ function openH1(headers, signal) {
1612
+ const ac = new AbortController()
1613
+ signal?.addEventListener("abort", () => ac.abort(), { once: true })
1614
+ const id = headers["x-request-id"]
1615
+ const h = { ...headers, "accept-encoding": "identity", "x-cursor-streaming": "true" }
1616
+ let ended = false
1617
+ let seq = 0
1618
+ let chain = Promise.resolve()
1619
+ const req = new Duplex({
1620
+ read() {},
1621
+ write(chunk, _enc, cb) {
1622
+ // runOnce writes one whole frame each time
1623
+ const msg = Buffer.from(chunk).subarray(5)
1624
+ const n = seq++
1625
+ chain = chain.then(() => (ended ? undefined : append(msg, n)))
1626
+ cb()
1627
+ },
1628
+ })
1629
+ const end = (body) => {
1630
+ if (ended || req.destroyed) return
1631
+ ended = true
1632
+ const f = frame(Buffer.from(body))
1633
+ f[0] = 2
1634
+ req.push(f)
1635
+ req.push(null)
1636
+ ac.abort()
1637
+ }
1638
+ const failed = async (res) => {
1639
+ const text = await res.text().catch(() => "")
1640
+ let e
1641
+ try {
1642
+ e = JSON.parse(text)
1643
+ } catch {}
1644
+ end(JSON.stringify({ error: typeof e?.code === "string" ? e : { code: connectCode(res.status), message: text.trim() || `HTTP ${res.status}` } }))
1645
+ }
1646
+ // append sends one message up. One that gets no answer by its deadline
1647
+ // (more time for a large one) ends the Run, as cursor-agent's
1648
+ // bidi_append_deadline_exceeded does: the messages after it wait on it.
1649
+ const append = async (msg, n) => {
1650
+ const body = pb().str(1, msg.toString("hex")).bytes(2, pb().str(1, id).done()).varint(3, n).done()
1651
+ const ms = H1.appendWait + Math.ceil(body.length / 65536) * 1000
1652
+ const deadline = AbortSignal.timeout(ms)
1653
+ try {
1654
+ const res = await fetch(API + "/aiserver.v1.BidiService/BidiAppend", {
1655
+ method: "POST",
1656
+ headers: { ...h, "content-type": "application/proto", "connect-protocol-version": "1" },
1657
+ body,
1658
+ signal: AbortSignal.any([ac.signal, deadline]),
1659
+ })
1660
+ if (!res.ok) return failed(res)
1661
+ await res.arrayBuffer()
1662
+ } catch (e) {
1663
+ if (ac.signal.aborted) return
1664
+ if (deadline.aborted) return end(JSON.stringify({ error: { code: "deadline_exceeded", message: `BidiAppend got no answer in ${Math.round(ms / 1000)}s` } }))
1665
+ end(JSON.stringify({ error: { code: "unavailable", message: `BidiAppend: ${e?.message ?? e}` } }))
1666
+ }
1667
+ }
1668
+ ;(async () => {
1669
+ try {
1670
+ const res = await fetch(API + "/agent.v1.AgentService/RunSSE", {
1671
+ method: "POST",
1672
+ headers: { ...h, "content-type": "application/connect+proto", "connect-protocol-version": "1" },
1673
+ body: frame(pb().str(1, id).done()),
1674
+ signal: ac.signal,
1675
+ })
1676
+ if (!res.ok) return failed(res)
1677
+ for await (const c of res.body) {
1678
+ if (ended || req.destroyed) return
1679
+ req.push(Buffer.from(c))
1680
+ }
1681
+ if (!ended && !req.destroyed) {
1682
+ ended = true
1683
+ req.push(null)
1684
+ }
1685
+ } catch (e) {
1686
+ if (!ac.signal.aborted) end(JSON.stringify({ error: { code: "unavailable", message: `RunSSE: ${e?.message ?? e}` } }))
1687
+ }
1688
+ })()
1689
+ const close = () => {
1690
+ ended = true
1691
+ ac.abort()
1692
+ req.destroy()
1693
+ }
1694
+ return Promise.resolve({ session: { close, destroy: close }, req: Object.assign(req, { close }), status: 200, h1: true })
1695
+ }
1696
+
1540
1697
  // runOnce is one Run of the chat on the agent API at base: an async
1541
1698
  // iterator of the answer's pieces, or the error it failed with before any.
1542
1699
  async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
@@ -1615,7 +1772,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1615
1772
  if (head.done) return book.done(), { error: { status: 502, message: "an empty reply" } }
1616
1773
  if (head.value.error) {
1617
1774
  await wrapped.return()
1618
- return { error: head.value.error }
1775
+ return { error: head.value.error, h1: !!o.h1 }
1619
1776
  }
1620
1777
  return {
1621
1778
  events: (async function* () {
@@ -1883,9 +2040,12 @@ async function answerOf(auth, chat, signal, session = "") {
1883
2040
  let r = await once()
1884
2041
  if (r.error && regional(r.error.message)) {
1885
2042
  // the team moved, or the config was kept from before: once more with
1886
- // what the config says now
2043
+ // what the config says now. A Run that went over HTTP/1.1 went to api2,
2044
+ // not the region's agent host: once more over HTTP/2 there.
2045
+ const viaH1 = !!r.h1
2046
+ h1Until = 0
1887
2047
  const fresh = await agentURL(tok, true)
1888
- if (fresh !== base) {
2048
+ if (fresh !== base || viaH1) {
1889
2049
  base = fresh
1890
2050
  r = await once()
1891
2051
  }
@@ -2041,4 +2201,4 @@ export async function CursorAuthPlugin() {
2041
2201
  }
2042
2202
 
2043
2203
  // for tests
2044
- export const _internal = { STEP_WAIT, stepUsage, 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 }
2204
+ export const _internal = { resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, H2, H1, h2Blocked, open, openH1, STEP_WAIT, stepUsage, 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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",