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

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 +165 -9
  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"
@@ -1509,18 +1514,61 @@ async function agentURL(tok, fresh) {
1509
1514
  }
1510
1515
 
1511
1516
  // 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) {
1517
+ // status, or an error the request never got past. A Run HTTP/2 can't open
1518
+ // goes over HTTP/1.1 (openH1). When the failure says HTTP/2 itself can't be
1519
+ // had here (h2Blocked), the Runs after it go straight to HTTP/1.1 for
1520
+ // H2.retryAfter, or until a region error sends one back (answerOf); a
1521
+ // connection that failed (refused, no network) or a stream the server
1522
+ // refused leaves the next Run to try HTTP/2 again.
1523
+ let h1Until = 0
1524
+ async function open(base, headers, signal) {
1525
+ if (Date.now() >= h1Until) {
1526
+ const o = await openH2(base, headers, signal)
1527
+ if (!o.h2Failed) return o
1528
+ if (o.blocked) h1Until = Date.now() + H2.retryAfter
1529
+ }
1530
+ return openH1(headers, signal)
1531
+ }
1532
+
1533
+ const H2 = {
1534
+ wait: 15_000, // ms an HTTP/2 Run waits for its response's head
1535
+ retryAfter: 10 * 60_000, // ms the Runs after one found HTTP/2 blocked stay on HTTP/1.1
1536
+ }
1537
+
1538
+ // NET are the failures of a connection itself, which say nothing of HTTP/2
1539
+ const NET = ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "ECONNRESET", "ETIMEDOUT", "ECONNABORTED", "EPIPE"]
1540
+
1541
+ // h2Blocked is whether a failure to open an HTTP/2 Run says HTTP/2 itself
1542
+ // can't be had: no head in H2.wait, the "h2 is not supported" magpie's
1543
+ // host gives a tunnel that can't carry it, a server or proxy that won't
1544
+ // negotiate it (ALPN), an HTTP/2 protocol error. Anything else, a failed
1545
+ // connection above all, is not.
1546
+ function h2Blocked(e) {
1547
+ if (e?.h2Timeout) return true
1548
+ const code = String(e?.code ?? "")
1549
+ const text = `${code} ${e?.message ?? ""}`
1550
+ if (NET.some((c) => text.includes(c))) return false
1551
+ // a stream the server refused or reset: HTTP/2 itself got through
1552
+ if (code === "ERR_HTTP2_STREAM_ERROR") return false
1553
+ return code.startsWith("ERR_HTTP2") || /h2 is not supported|alpn|nghttp2|protocol error/i.test(text)
1554
+ }
1555
+
1556
+ // openH2 is open over HTTP/2. A Run that fails before its response, other
1557
+ // than by being cancelled, says h2Failed, and blocked when h2Blocked says
1558
+ // so of the failure.
1559
+ function openH2(base, headers, signal) {
1514
1560
  return new Promise((resolve) => {
1515
1561
  const u = new URL(base)
1516
1562
  const session = http2.connect(u.origin)
1517
1563
  let done = false
1518
- const fail = (e) => {
1564
+ const fail = (e, h2Failed = true) => {
1519
1565
  if (done) return
1520
1566
  done = true
1567
+ clearTimeout(timer)
1521
1568
  session.destroy()
1522
- resolve({ error: { status: 502, message: e?.message ?? String(e) } })
1569
+ resolve({ error: { status: 502, message: e?.message ?? String(e) }, h2Failed, blocked: h2Failed && h2Blocked(e) })
1523
1570
  }
1571
+ const timer = setTimeout(() => fail(Object.assign(new Error(`no answer over HTTP/2 in ${H2.wait / 1000}s`), { h2Timeout: true })), H2.wait)
1524
1572
  session.on("error", fail)
1525
1573
  const req = session.request({
1526
1574
  ":method": "POST",
@@ -1528,15 +1576,120 @@ function open(base, headers, signal) {
1528
1576
  ...headers,
1529
1577
  })
1530
1578
  req.on("error", fail)
1579
+ // a stream reset before its head (RST_STREAM, REFUSED_STREAM) may close
1580
+ // it with no error said
1581
+ 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
1582
  req.once("response", (h) => {
1532
1583
  if (done) return
1533
1584
  done = true
1585
+ clearTimeout(timer)
1534
1586
  resolve({ session, req, status: Number(h[":status"]) })
1535
1587
  })
1536
- signal?.addEventListener("abort", () => fail(new Error("the request was cancelled")), { once: true })
1588
+ signal?.addEventListener("abort", () => fail(new Error("the request was cancelled"), false), { once: true })
1537
1589
  })
1538
1590
  }
1539
1591
 
1592
+ const H1 = { appendWait: 60_000 } // ms a BidiAppend may take before the Run fails, as cursor-agent's appendTimeoutMs
1593
+
1594
+ // connectCode is Connect's code for an HTTP status that came with no
1595
+ // Connect error of its own
1596
+ function connectCode(status) {
1597
+ return { 400: "invalid_argument", 401: "unauthenticated", 403: "permission_denied", 429: "resource_exhausted" }[status] ?? "unavailable"
1598
+ }
1599
+
1600
+ // openH1 is open over HTTP/1.1, as Cursor's clients run a Run without
1601
+ // HTTP/2: what runOnce writes (a Connect frame each time) goes up as
1602
+ // BidiAppend calls in turn, numbered from 0, and what RunSSE sends down is
1603
+ // the stream it reads, Connect's frames as the HTTP/2 Run's. Its response
1604
+ // is a 200 at once; an error Cursor says later, or a call that fails,
1605
+ // ends the stream with Connect's end frame, which runOnce reads as the
1606
+ // Run's own.
1607
+ function openH1(headers, signal) {
1608
+ const ac = new AbortController()
1609
+ signal?.addEventListener("abort", () => ac.abort(), { once: true })
1610
+ const id = headers["x-request-id"]
1611
+ const h = { ...headers, "accept-encoding": "identity", "x-cursor-streaming": "true" }
1612
+ let ended = false
1613
+ let seq = 0
1614
+ let chain = Promise.resolve()
1615
+ const req = new Duplex({
1616
+ read() {},
1617
+ write(chunk, _enc, cb) {
1618
+ // runOnce writes one whole frame each time
1619
+ const msg = Buffer.from(chunk).subarray(5)
1620
+ const n = seq++
1621
+ chain = chain.then(() => (ended ? undefined : append(msg, n)))
1622
+ cb()
1623
+ },
1624
+ })
1625
+ const end = (body) => {
1626
+ if (ended || req.destroyed) return
1627
+ ended = true
1628
+ const f = frame(Buffer.from(body))
1629
+ f[0] = 2
1630
+ req.push(f)
1631
+ req.push(null)
1632
+ ac.abort()
1633
+ }
1634
+ const failed = async (res) => {
1635
+ const text = await res.text().catch(() => "")
1636
+ let e
1637
+ try {
1638
+ e = JSON.parse(text)
1639
+ } catch {}
1640
+ end(JSON.stringify({ error: typeof e?.code === "string" ? e : { code: connectCode(res.status), message: text.trim() || `HTTP ${res.status}` } }))
1641
+ }
1642
+ // append sends one message up. One that gets no answer by its deadline
1643
+ // (more time for a large one) ends the Run, as cursor-agent's
1644
+ // bidi_append_deadline_exceeded does: the messages after it wait on it.
1645
+ const append = async (msg, n) => {
1646
+ const body = pb().str(1, msg.toString("hex")).bytes(2, pb().str(1, id).done()).varint(3, n).done()
1647
+ const ms = H1.appendWait + Math.ceil(body.length / 65536) * 1000
1648
+ const deadline = AbortSignal.timeout(ms)
1649
+ try {
1650
+ const res = await fetch(API + "/aiserver.v1.BidiService/BidiAppend", {
1651
+ method: "POST",
1652
+ headers: { ...h, "content-type": "application/proto", "connect-protocol-version": "1" },
1653
+ body,
1654
+ signal: AbortSignal.any([ac.signal, deadline]),
1655
+ })
1656
+ if (!res.ok) return failed(res)
1657
+ await res.arrayBuffer()
1658
+ } catch (e) {
1659
+ if (ac.signal.aborted) return
1660
+ if (deadline.aborted) return end(JSON.stringify({ error: { code: "deadline_exceeded", message: `BidiAppend got no answer in ${Math.round(ms / 1000)}s` } }))
1661
+ end(JSON.stringify({ error: { code: "unavailable", message: `BidiAppend: ${e?.message ?? e}` } }))
1662
+ }
1663
+ }
1664
+ ;(async () => {
1665
+ try {
1666
+ const res = await fetch(API + "/agent.v1.AgentService/RunSSE", {
1667
+ method: "POST",
1668
+ headers: { ...h, "content-type": "application/connect+proto", "connect-protocol-version": "1" },
1669
+ body: frame(pb().str(1, id).done()),
1670
+ signal: ac.signal,
1671
+ })
1672
+ if (!res.ok) return failed(res)
1673
+ for await (const c of res.body) {
1674
+ if (ended || req.destroyed) return
1675
+ req.push(Buffer.from(c))
1676
+ }
1677
+ if (!ended && !req.destroyed) {
1678
+ ended = true
1679
+ req.push(null)
1680
+ }
1681
+ } catch (e) {
1682
+ if (!ac.signal.aborted) end(JSON.stringify({ error: { code: "unavailable", message: `RunSSE: ${e?.message ?? e}` } }))
1683
+ }
1684
+ })()
1685
+ const close = () => {
1686
+ ended = true
1687
+ ac.abort()
1688
+ req.destroy()
1689
+ }
1690
+ return Promise.resolve({ session: { close, destroy: close }, req: Object.assign(req, { close }), status: 200, h1: true })
1691
+ }
1692
+
1540
1693
  // runOnce is one Run of the chat on the agent API at base: an async
1541
1694
  // iterator of the answer's pieces, or the error it failed with before any.
1542
1695
  async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
@@ -1615,7 +1768,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1615
1768
  if (head.done) return book.done(), { error: { status: 502, message: "an empty reply" } }
1616
1769
  if (head.value.error) {
1617
1770
  await wrapped.return()
1618
- return { error: head.value.error }
1771
+ return { error: head.value.error, h1: !!o.h1 }
1619
1772
  }
1620
1773
  return {
1621
1774
  events: (async function* () {
@@ -1883,9 +2036,12 @@ async function answerOf(auth, chat, signal, session = "") {
1883
2036
  let r = await once()
1884
2037
  if (r.error && regional(r.error.message)) {
1885
2038
  // the team moved, or the config was kept from before: once more with
1886
- // what the config says now
2039
+ // what the config says now. A Run that went over HTTP/1.1 went to api2,
2040
+ // not the region's agent host: once more over HTTP/2 there.
2041
+ const viaH1 = !!r.h1
2042
+ h1Until = 0
1887
2043
  const fresh = await agentURL(tok, true)
1888
- if (fresh !== base) {
2044
+ if (fresh !== base || viaH1) {
1889
2045
  base = fresh
1890
2046
  r = await once()
1891
2047
  }
@@ -2041,4 +2197,4 @@ export async function CursorAuthPlugin() {
2041
2197
  }
2042
2198
 
2043
2199
  // 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 }
2200
+ 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.3",
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",