@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.
- package/README.md +12 -0
- package/index.mjs +171 -11
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|