diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5284921 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# docs/overlay-baseline.patch is a verbatim preserved evidence artifact: a unified diff +# represents a blank source line as a context line containing a single space, which +# `git diff --check` flags as trailing whitespace. This is intentional — never strip or +# reflow the file, or git apply fidelity is lost. +docs/overlay-baseline.patch -whitespace diff --git a/.gitignore b/.gitignore index 62ccde4..4d58f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.tsbuildinfo .DS_Store +.goopspec/ diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md new file mode 100644 index 0000000..3176556 --- /dev/null +++ b/docs/cursor-hang-root-cause.md @@ -0,0 +1,506 @@ +# Cursor Model Hang — Root Cause Analysis + +Status: **Evidence baseline, lifecycle map, and terminal-contract runtime evidence complete.** This +document opens the hang investigation: it preserves the speculative uncommitted overlay that +predated this investigation, classifies every hunk with an explicit disposition, records +baseline-vs-overlay behavior, and then (Part 2) enumerates every open-SSE path and termination +route with source-linked matrices. No behavioral fix is implemented here; the matrices exist +so the termination-contract work lands directly against a complete map. + +## Grounding fact: nothing upstream rescues a stuck request + +OpenCode configures the OpenAI-compatible provider with **no timeout of any kind** — no +header timeout, no chunk timeout, no overall request timeout (`timeout: false` is forced +into the fetch; `headerTimeout` is not set for this provider class). Consequences: + +- A request that stalls before response headers is a true hang — no error is ever surfaced. +- A streaming SSE response that never terminates is a true hang — OpenCode accepts a + truncated body as `other` and keeps waiting. +- The plugin proxy owns the entire hang-bounding and termination contract. Any hunk that + merely lengthens a bound or resets on activity without establishing a terminal guarantee + does not fix a hang; it postpones the failure. + +## Overlay provenance + +Before this investigation began, the working tree carried **uncommitted, untested** +speculative edits authored outside any controlled process: + +- `src/proxy.ts` — +81/-13 (timeout bumps in `src/h2-bridge.mjs` were expected to interact; + proxy-side watchdog added) +- `src/h2-bridge.mjs` — +14/-6 (timeout bumps, defensive guards, stderr passthrough) + +The complete, verbatim pre-disposition diff is preserved in +[`docs/overlay-baseline.patch`](./overlay-baseline.patch). Every line removed below remains +recoverable and reviewable from that artifact. Nothing was silently discarded. + +> **Whitespace exemption (read before "tidying"):** `docs/overlay-baseline.patch` is excluded +> from git's whitespace checks via `.gitattributes` (`-whitespace`). A unified diff encodes a +> blank source line as a context line containing a single space, so `git diff --check` flags +> the file by design. Never reflow, reformat, or strip trailing whitespace from this artifact — +> doing so corrupts the evidence and breaks `git apply` fidelity. + +## Baseline status at inherited tip + +Wave 1 Task 1.1 re-verified the inherited stability floor at tip +`1ffca147b461ea044e041626eb1ccc7465169526` (`1ffca14`) on +`chore/stability-baseline`. The repository gates completed as follows: + +| Check | Result | +|---|---| +| `npx tsc -p tsconfig.json --noEmit` | PASS — no errors | +| `bun test/smoke.ts` | PASS — 48 required assertions passed; 2 documented quarantines remain | +| `bun run build` | PASS | +| `git diff --check` | PASS for edited documentation; `overlay-baseline.patch` remains a verbatim evidence artifact and is not reformatted | + +The smoke suite writes cache data under `~/.cache/opencode-cursor` and uses a +local fake Cursor backend; it does not contact Cursor. This record accepts the +inherited tip as the baseline floor before new stability behavior is changed. + +The overlay artifact is preserved evidence and **MUST NOT be reapplied**. Its +three-way disposition is: **kept/already landed** — stderr draining, sanitized +errors, defensive close/destroy guards; **rejected** — fixed 30→60s connect and +120→180s idle timeout bumps; **replaced** — the 90s raw-byte watchdog, +superseded by semantic-progress plus H2 liveness. See the adjacent +[`overlay-baseline.note.md`](./overlay-baseline.note.md); the patch itself was +not modified. + +## Hunk inventory and disposition + +| # | File | Location | Change | Apparent intent | Hang mechanism affected | Verdict | +|---|---|---|---|---|---|---| +| H2-1 | `src/h2-bridge.mjs` | initial `setTimeout` | 30s → 60s + "peak load" comment | Give a slow server more time to accept the connection | None identified. Only postpones the error surfaced when the initial connect stalls; a stalled connect still hangs for 60s instead of 30s before the bridge self-terminates. | **Drop** (restore 30s) | +| H2-2 | `src/h2-bridge.mjs` | `resetTimeout` | 120s → 180s + "Grok 4.5" comment | Avoid killing a slow model during long thinking/tool pauses | None effective. This idle timer resets on **every** H2 data frame **and** every stdin write — the proxy writes a `ClientHeartbeat` every 5s, so heartbeat traffic defeats it indefinitely. It can never bound the leading unhandled-message-trickle hang. | **Drop** (restore 120s) | +| H2-3 | `src/h2-bridge.mjs` | `killBridge` | `client.destroy()` → `try { client.destroy(); } catch {}` | Defensive: a throw must not mask the `process.exit(1)` | None (defensive only). Harmless and correct; keeps the terminal exit unconditional. | **Keep** | +| H2-4 | `src/h2-bridge.mjs` | `client.on("error")` | `() =>` → `(err)` + write `err.message` to stderr | Surface silently-swallowed client errors to the parent | None directly (exit behavior unchanged), but it is **load-bearing for diagnosis**: with `stderr: "ignore"` these errors were invisible. Emission must become environment-gated, structured, and redacted. | **Keep, then rework** (redaction/diagnostics pass) | +| H2-5 | `src/h2-bridge.mjs` | `h2Stream.on("error")` | `() =>` → `(err)` + stderr write + `try { client.close(); } catch {}` | Surface stream errors; guard the close against throws | None directly (exit behavior unchanged); the guarded close is a correct defensive improvement. | **Keep** (guarded close) / **Keep, then rework** (stderr emission) | +| P-1 | `src/proxy.ts` | `spawnBridge` | `stderr: "ignore"` → `stderr: "pipe"` + continuous parent drain loop | Stop swallowing bridge errors; keep the child unblocked | **Load-bearing for backpressure**: a child writing to an undrained stderr pipe can block on the pipe and stall the whole request. The drain makes the pipe safe. Emission is unredacted today; must become gated/structured/redacted. | **Keep, then rework** | +| P-2 | `src/proxy.ts` | `Bun.serve` `idleTimeout` comment | Comment-only edit ("Grok 4.5" wording) | Cosmetic | None. | **Drop** (restore original comment) | +| P-3 | `src/proxy.ts` | `createBridgeStreamResponse` | 90s raw-byte watchdog (`setInterval` poll, `lastDataTime`, wrapped `processChunk` resetting on **any** inbound data) + timeout error/stop/done sequence | Force-close SSE after 90s without data; catch silent H2 close, silent disconnect, model stall | **Defeated by its own input.** It resets on any inbound byte, so server heartbeat/checkpoint/query trickle keeps a stalled request alive indefinitely. It can also kill a *legitimately slow* model that thinks silently for >90s (the exact Grok 4.5 behavior the overlay author worried about). Raw-byte activity must never defer termination forever, and slow-but-progressing traffic must never be terminated — this watchdog fails both sides of the requirement. | **Drop** (replaced by a semantic-progress/transport-liveness detector in the hardening pass) | +| P-4 | `src/proxy.ts` | `bridge.onClose` | `clearInterval(watchdog)` added | Cleanup for P-3 | None once P-3 is gone. | **Drop** (remove with P-3) | +| P-5 | `src/proxy.ts` | `bridge.onClose` | Restructure `} else if (code !== 0) {` into `} else { if (code !== 0) {...} }` + extended comments | Claim the `code === 0 && mcpExecReceived` case must keep the bridge alive for resume | **Behaviorally identical to baseline**: both forms error-close only when `mcpExecReceived && code !== 0`; both do nothing when `code === 0`. The new comments document pre-existing behavior, not a fix. Non-zero bridge-close handling must be reworked into one idempotent explicit-error terminal path (race-safe across frame/stream/process/timeout closes) — that belongs to the hardening pass, **not here**. | **Rework — deferred** (restore baseline shape now; reference below) | + +Net disposition: **4 hunks kept** (H2-3, H2-4, H2-5 guarded close, P-1), **5 hunks dropped or +restored** (H2-1, H2-2, P-2, P-3, P-4), **1 hunk deferred** (P-5). The kept hunks are exactly +the ones that are load-bearing or harmless: stderr draining (backpressure safety), defensive +close guards, and error passthrough (diagnosis). The dropped hunks are exactly the ones that +either lengthen a hang without fixing it or make termination decisions from raw bytes. + +## Clean-baseline vs overlay behavior + +Both trees (overlay and clean baseline) satisfy the repository verification: + +| Check | With overlay | Clean baseline | +|---|---|---| +| `git diff --check` | pass | pass | +| `npx tsc -p tsconfig.json --noEmit` | pass (no errors) | pass (no errors) | +| `bun test/smoke.ts` | 7/7 pass | 7/7 pass | + +> `bun test/smoke.ts` writes conversation cache data under `~/.cache/opencode-cursor` as a +> side effect. The suite exercises the plugin against a local fake Connect/HTTP2 backend; it +> does not contact Cursor. + +Behavioral difference (code-level, from the stream-termination enumeration): + +- **Baseline (HEAD):** no proxy-side watchdog. Open-SSE hangs are bounded only by the bridge + idle timer (120s), which heartbeat traffic defeats. A silent-stall can hang until OpenCode + gives up (never, for this provider) or the OS/network intervenes. +- **Overlay:** adds a 90s proxy watchdog that fires only after 90s with no inbound bytes, and + raises the bridge idle to 180s. For the leading hang candidates — unhandled server message + families (heartbeat/checkpoint/query trickle), missing end-stream, silent parse failures — + the watchdog **still never fires** because any trickle resets it, and it **can** kill a slow + model during a >90s silent thinking window. Net: it converted one unbounded hang class into + two unchanged classes plus a new false-termination risk. +- **After disposition (current tree):** identical to baseline plus the kept overlay hunks + (stderr pipe + drain, guarded destroys/closes, error passthrough). Termination behavior is + exactly baseline; observability is strictly better. + +## Known hang mechanisms (for reference) + +Analysis prior to this document identified these confirmed code-level defects, ordered by +likelihood of causing the reported hang (full detail in the workflow research notes): + +1. **Unbounded token refresh** — `refreshCursorToken` fetches the exchange endpoint with no + abort signal inside the pre-header request path; a stalled exchange means `Bun.serve` + never returns headers. +2. **Silent truncation reported as success** — bridge crash / H2 close without a Connect + end-stream can emit a clean `finish_reason: "stop"`, which in a tool loop drives OpenCode + retries (busy-loop). +3. **Empty end-stream misclassified as error** — `parseConnectEndStream` runs `JSON.parse` + on a zero-byte body, so valid clean completions surface as visible errors. +4. **Heartbeat-defeated idle timer** — the bridge idle timer resets on every stdin write and + H2 data frame; the proxy's 5s client heartbeat keeps it from ever firing. +5. **No bound in the non-streaming path** — `collectFullResponse` awaits bridge close with no + timeout, so a dead-silent bridge hangs it indefinitely. + +The dropped overlay hunks address **none** of these; the kept hunks improve observability of +1–5 without altering their behavior. Fixes land in later passes with harness evidence. + +## Deferred work references + +- **Non-zero bridge-close terminal path (was P-5):** rework `bridge.onClose` so bridge exit + and HTTP/2 session/stream closure without a Connect end-stream always reach one idempotent + explicit-error terminal sequence, never a clean `stop`, and never a duplicate terminal — + while preserving the valid `mcpExecReceived` tool-call pause (SSE already closed by the + tool-call frame; bridge retained for resume). Implemented in the hardening pass. +- **Semantic-progress stall detector (replaces P-3):** termination must be driven by the + absence of *semantic* progress (output deltas, tool transitions, terminal frames) and by + transport-liveness signals (HTTP/2 PING, session/stream lifecycle), never by raw-byte + elapsed time. Implemented in the hardening pass. + +## Safe lifecycle diagnostics + +Set `CURSOR_PROXY_DIAGNOSTICS=1` to emit one-line JSON lifecycle records to stderr. The default +is disabled. Every record contains a per-request `requestId` and monotonic `elapsedMs`; bridge +records carry the same ID and include the parent elapsed time at spawn for cross-process ordering. + +The stage inventory is: `auth_access_start`, `auth_access_complete`, refresh start/complete/fail, +`proxy_entry`, `bridge_spawn`, `bridge_start`, `h2_connect`, `connect_stream`, first +`bridge_data`, first `connect_frame`, `end_stream`, `message_dispatch`, `tool_pause`, +`tool_resume`, first `sse_emission`, and `terminal`. Conditional tool stages appear only for +tool-call requests. Bridge timeout kills are distinguishable: `terminal` with +`errorName: "Timeout:connect"` (initial 30s connect bound) or `"Timeout:idle"` (120s activity +bound) versus `errorName` from a client/stream error and `exitCode` from normal exit. See +Part 2 §2.8 for the instrumentation added in this pass. + +Diagnostics use a strict allowlist: source, stage, request ID, elapsed time, byte length, message +case, interaction case, tool count, status/exit code, and error class. They never serialize auth +tokens or headers, cookies, prompt/message text, tool arguments/results, or blob/checkpoint IDs. +The parent always drains child stderr even while diagnostics are disabled, preventing pipe +backpressure from stalling the bridge. + +## Evidence sources + +- Field notes: unhandled server-message families / raw-byte timer resets (leading hang + hypothesis); every stream-termination path; timers/watchdogs and reset semantics; + error-swallowing inventory; root-cause synthesis of the five defects; no-timeout provider + semantics; bridge keying/liveness risks. +- Source locations: `src/proxy.ts` `createBridgeStreamResponse` / `parseConnectEndStream` / + `collectFullResponse` / `spawnBridge`; `src/h2-bridge.mjs` timer and error handlers; + `src/auth.ts` `refreshCursorToken`. +- Upstream: opencode-cursor issue #33 (busy loop after H2 socket close); opencode issues on + provider timeout behavior; hardened-provider prior art (PING keepalive, stall budgets). + +--- + +# Part 2 — Lifecycle and termination evidence map + +This part enumerates every path that can leave an OpenCode SSE stream open, classified from +direct source reading of `src/proxy.ts`, `src/h2-bridge.mjs`, `src/native-tools.ts`, and the +generated `src/proto/agent_pb.ts`. It is the specification input for the termination-contract +work (which must make every request reach exactly one terminal outcome) and for the +controllable Connect/HTTP2 harness (which must exercise each row below that is marked as +needing runtime evidence). **No behavioral change is made here** — the only edits in this pass +are the diagnostic additions in §2.8. + +## Classification legend + +Every row carries one of: + +| Class | Meaning | +|---|---| +| **terminates cleanly** | The path provably closes the SSE with `[DONE]` and no error content. | +| **terminates with error** | The path provably closes the SSE with an explicit error/stop sequence. | +| **HANGS** | The path can leave the request pending indefinitely, or bounded only by OS-level truncation. | +| **unknown-needs-runtime-evidence** | Source reading fixes the *mechanism*, but the trigger depends on server or runtime behavior that the controllable fixture must confirm. | + +Classification basis is noted per row: **source-proven** (directly verifiable in the code +above) versus **runtime** (the code path is fixed, but the server-side sequence that activates +it is not yet captured). + +## 2.1 Lifecycle matrix — streaming request, entry to completion + +Each stage cites where it is reached in `src/proxy.ts` unless noted. + +| # | Stage | Where | What happens | Outcome on stall here | +|---|---|---|---|---| +| L1 | Request entry | `Bun.serve` fetch, `proxy.ts:522-537` | URL parsed, method/path routed | 404 for unknown path | +| L2 | Diagnostic context + `proxy_entry` | `proxy.ts:536-538` | per-request correlation ID starts | n/a | +| L3 | Body parse | `proxy.ts:539` | `req.json()` → `ChatCompletionRequest` | 500 via outer catch (`proxy.ts:547-556`) | +| L4 | `auth_access_start` → token provider → `auth_access_complete` | `proxy.ts:543-545` | `proxyAccessTokenProvider()` awaited; the provider calls `resolveDiskAccessToken` (`src/index.ts:77-92`) which may call `refreshCursorToken` (`src/auth.ts:143-177`) | **HANGS pre-header** if the refresh fetch stalls — `refreshCursorToken` has no timeout (§2.7 M4); no Response is ever returned | +| L5 | Message parse / key derivation | `proxy.ts:590-609` | `parseMessages`, `deriveBridgeKey`/`deriveConversationKey` | 400 for no user message (`proxy.ts:594-604`) | +| L6 | Bridge reuse check | `proxy.ts:610-624` | live tool-resume, dead-bridge cleanup, or fresh request | see §2.6 | +| L7 | Conversation state load/persist | `proxy.ts:633-646` | memory map, disk-cache fallback, TTL eviction | n/a | +| L8 | Request build | `proxy.ts:650-655` | `buildCursorRequest` → protobuf `AgentClientMessage` | n/a | +| L9 | `handleStreamingResponse` → `startBridge` | `proxy.ts:1635-1648` → `1621-1633` | `spawnBridge` (`proxy.ts:306-430`), writes config + initial Connect frame, arms 5s heartbeat interval (`proxy.ts:1631`) | child boot failure → `bridge.onClose` (see T4/T5) | +| L10 | Bridge boot | `h2-bridge.mjs:82-107` | config read, `http2.connect` (`109`), 30s connect timer armed (`115`) | timeout kill → exit 1 (§2.3 T-T1) | +| L11 | H2 connect + stream open | `h2-bridge.mjs:109-148` | `connect` → `h2_connect` (`110`), `client.request` → `connect_stream` (`147-148`) | connect error → `terminal` + exit 1 (`130-134`) | +| L12 | SSE response start | `proxy.ts:1409-1423` | `createBridgeStreamResponse` returns `new Response(stream, SSE_HEADERS)` (`1613`) | headers sent; OpenCode now waits for body | +| L13 | First H2 data | `h2-bridge.mjs:151-155` | `bridge_data` + `resetTimeout` + forward to stdout | — | +| L14 | Connect frame parse | `proxy.ts:1475-1490` | `createConnectFrameParser` (`976-996`) → `connect_frame` → `fromBinary` | silent skip on parse failure (§2.5 F2) | +| L15 | Message dispatch | `proxy.ts:1483-1553` | `processServerMessage` (`1068-1102`) → per-case handling (§2.4) | ignored cases produce no SSE output (§2.4) | +| L16 | Text/thinking emission | `proxy.ts:1490-1498` | `onText` → `sendSSE` → `sse_emission` (`1426-1430`) | — | +| L17 | KV round-trip | `proxy.ts:1142-1173` | `getBlob`/`setBlob` → `sendFrame` → `bridge.write` | unhandled KV case → server waits (see M-row KV) | +| L18 | Checkpoint update | `proxy.ts:1536-1546` | `conversationCheckpointUpdate` → persist blob/checkpoint | n/a | +| L19 | Tool pause | `proxy.ts:1499-1535` | `mcpExec` → `tool_pause` → `tool_calls` SSE → `[DONE]` + `closeController` (`1533-1534`); bridge retained in `activeBridges` (`1523-1530`) | SSE closes; bridge leaks if never resumed (§2.2 T8) | +| L20 | Connect end-stream | `proxy.ts:1558-1580` | `end_stream` diagnostic; clean end-stream is a no-op (`1567`), error end-stream emits error + stop + usage + `[DONE]` + close + cleanup (`1568-1579`) | clean no-op relies on the bridge-exit cascade (§2.2 T1) | +| L21 | Bridge close | `proxy.ts:1585-1613` | `onClose(code)` → conversation persist → clean or error terminal (§2.2 T4/T5) | — | +| L22 | SSE close | `proxy.ts:1435-1440` | `closeController` → `terminal` + `controller.close()` | — | + +**Non-streaming variant:** `handleNonStreamingResponse` (`proxy.ts:1730-1757`) awaits +`collectFullResponse` (`1768-1842`), which resolves **only** on `bridge.onClose` (`1823-1840`). +Its Connect end-stream handler is a no-op `() => {}` (`1820`). A silent bridge therefore hangs +the whole request pre-header (§2.2 T10). + +## 2.2 Terminal matrix — every way a stream can end + +| # | Trigger | Source route | Outcome | Classification | +|---|---|---|---|---| +| T1 | Connect end-stream with no `error` (clean, e.g. `{}`) | `parseConnectEndStream` → `null` (`proxy.ts:947-956`); handler `if (endError)` no-op (`1567`) | SSE closes only through the bridge-exit cascade T4. If the server ends the H2 stream, fine; if the server sends the envelope but keeps the stream open, nothing terminates. | **terminates cleanly via cascade / HANGS if stream not closed** — source-proven no-op; server behavior runtime | +| T2 | Connect end-stream with `error` JSON | `parseConnectEndStream` → `Error` (`950-955`); error branch (`1567-1579`) | Error content chunk + `stop` + usage + `[DONE]` + close + `bridge.end()` + map/timer cleanup | **terminates with error** — source-proven | +| T3 | Empty-body end-stream (zero bytes) | `JSON.parse("")` throws (`948-949`) → false `Error` (`957-958`) | Same error branch as T2. Per Connect spec a zero-byte end-stream means success, so a valid clean completion surfaces as an error. | **terminates with error (false positive)** — source-proven | +| T4 | Bridge exit code 0 (H2 stream `end` → `client.close()` → `exit(0)` after 100ms flush) | `h2-bridge.mjs:160-166`; `onClose` `proxy.ts:1585-1593` | If no exec received: clean `stop` + usage + `[DONE]` + close (`1594-1601`). If exec received: no SSE action (bridge retained for resume). | **terminates cleanly** — source-proven | +| T5 | Bridge exit code ≠ 0 (connect timeout, idle timeout, client error, stream error) | `h2-bridge.mjs:115/120` (killBridge), `130-134` (client error), `168-174` (stream error); `onClose` `proxy.ts:1585-1593` | If no exec received: **clean** `stop` + usage + `[DONE]` (`1594-1601`) — non-zero exit reported as clean success. If exec received: error `stop` + `[DONE]` + stale-entry delete (`1602-1609`). The clean branch matches upstream issue #33's silent-truncation/busy-loop symptom. | **terminates with error masked as clean success** — source-proven | +| T6 | H2 GOAWAY / session `close` without stream `end` or `error` | no `client.on("close")` handler in `h2-bridge.mjs`; stream may emit `error` (→ T5) or nothing | If nothing: idle timer never fires because the proxy's 5s heartbeat resets it via stdin (`proxy.ts:1631` → `h2-bridge.mjs:194-196`). Streaming SSE bounded only by Bun's 255s connection idle (`proxy.ts:521`) → truncated `other` body. | **HANGS (bounded ~255s streaming) / unknown-needs-runtime-evidence** — missing session-close handler source-proven; Node event sequence on GOAWAY runtime | +| T7 | Client abort (OpenCode cancels the SSE response) | `ReadableStream` has no `cancel()` handler (`proxy.ts:1422-1611`); `sendSSE` has no error guard (`1426-1430`) | Bridge not torn down; heartbeat interval keeps running (`1631`); bridge/H2 session leak until server ends the stream or process stop. OpenCode already aborted, so not an OpenCode-side hang — but a bridge/process leak and stale `activeBridges` entry. | **client-side terminates; bridge leaks** — source-proven leak; Bun enqueue-after-cancel behavior runtime | +| T8 | Tool pause with no resume | `proxy.ts:1523-1535`; no TTL/eviction for `activeBridges` (only `stopProxy` clears, `578-582`) | SSE closes cleanly; bridge + heartbeat timer + H2 session retained forever if the client never sends tool results. | **SSE terminates cleanly; bridge leaks indefinitely** — source-proven | +| T9 | Partial/malformed Connect frame at stream end | parser buffers partial data and waits (`proxy.ts:980-995`); child exit discards the pending buffer silently (reader loop ends `402-405`) → T4/T5 | Any text in the partial frame is lost; still reported clean (code 0) or masked error (code ≠ 0). | **silent truncation** — source-proven drop; frequency runtime | +| T10 | Silent stall (server sends nothing, never closes) | no data → no `resetTimeout` from H2, but the proxy's own 5s heartbeat resets the idle timer via stdin every 5s (`proxy.ts:1631` → `h2-bridge.mjs:194-196`) | Streaming: bounded only by Bun's 255s connection idle (`proxy.ts:521`) → truncated `other`. Pre-header (auth refresh L4, or non-streaming collect L-NS) and heartbeat-trickle stalls: **no bound at all**. | **HANGS** — source-proven (see §2.3 T-T2/T-T4) | + +## 2.3 Timer matrix + +| # | Timer | Where | Armed by | Reset by | On fire | Cleared by | Defeated by heartbeat? | +|---|---|---|---|---|---|---|---| +| T-T1 | Bridge initial connect bound (30s) | `h2-bridge.mjs:115` | bridge boot | *not* reset (one-shot until first `resetTimeout`) | `killBridge` → destroy + `exit(1)` (`123-127`) | first data/stdin write (`155`/`194-196`), stream `end`/`error`, client error | No — one-shot. Bounds a stalled connect, then surfaces as masked clean success (T5) | +| T-T2 | Bridge activity/idle bound (120s) | `h2-bridge.mjs:117-120` | first `resetTimeout` | **any** H2 data (`154-155`) and **any** stdin write (`194-196`) | `killBridge` → destroy + `exit(1)` | stream `end`/`error`, client error | **Yes — defeated indefinitely.** Proxy writes a ClientHeartbeat every 5s (`proxy.ts:1631`); server heartbeats also reset it. Effectively never fires while the proxy lives | +| T-T3 | Proxy client heartbeat interval (5s) | `proxy.ts:1631` | `startBridge` | n/a | writes a Connect `ClientHeartbeat` frame to the bridge | `bridge.onClose`, end-stream error branch, `stopProxy` | This is the *cause* of T-T2's defeat, not a termination bound | +| T-T4 | Bun.serve connection idle (255s max) | `proxy.ts:521` | server start | bytes flowing **on the SSE connection** | closes the HTTP connection → truncated SSE body (`other` error in OpenCode) | SSE writes | Not "defeated" but irrelevant to heartbeat trickle: ignored messages produce no SSE bytes, so the connection stays idle and this is the *only* streaming-stall bound | +| T-T5 | Unary RPC timeout (default 5s) | `proxy.ts:456-462` | `callCursorUnaryRpc` | n/a | kills the bridge proc; resolves with `timedOut: true` | bridge `onClose` | Not in the streaming path; discovery only | +| T-T6 | Conversation memory TTL (30min) | `proxy.ts:179` | conversation store | `lastAccessMs` updates | evicts `conversationStates` entry | n/a | Unrelated to request termination | +| T-T7 | Conversation disk TTL (7d) | `proxy.ts:183` | disk snapshot | — | prunes stale files at proxy start (`517`) | n/a | Unrelated | +| T-T8 | Non-streaming collect bound | **none** — `collectFullResponse` awaits `bridge.onClose` (`proxy.ts:1823-1840`) with no timeout and a no-op end-stream handler (`1820`) | — | — | — | — | Its only possible bound is T-T2, which is defeated. Pre-header HANG | +| T-T9 | Proxy-side stall watchdog | **none in baseline** — the 90s raw-byte watchdog (overlay P-3) was dropped (Part 1) | — | — | — | — | n/a | + +## 2.4 Message matrix + +Cases read from the generated `src/proto/agent_pb.ts`. "Ignored" means the message is decoded +successfully and then no branch matches — no SSE output, no response frame, no terminal. + +### AgentServerMessage — 6 cases (`agent_pb.ts:3666-3709`), dispatch in `proxy.ts:1079-1101` + +| Case | proto field | Disposition | Consequence of ignoring (where applicable) | +|---|---|---|---| +| `interactionUpdate` | 1 | **handled** → `handleInteractionUpdate` (`proxy.ts:1081-1082`) | — | +| `execServerMessage` | 2 | **handled** → `handleExecMessage` (`proxy.ts:1085-1092`, `1175-1353`) | — | +| `execServerControlMessage` | 5 | **ignored** — no branch in `processServerMessage`. Carries `abort` (`agent_pb.ts:3567-3574`) | A server-side abort signal is dropped. If the server aborts its turn and then waits for the client to acknowledge/close, the stream never terminates. Whether Cursor sends this in the failing models is runtime. | +| `conversationCheckpointUpdate` | 3 | **handled** (`proxy.ts:1093-1101`): token details + checkpoint persist | — | +| `kvServerMessage` | 4 | **handled** → `handleKvMessage` (`proxy.ts:1142-1173`) for `getBlobArgs`/`setBlobArgs` only | Other KV cases fall through silently; if the server blocks on an unanswered KV request the stream stalls. Cursor uses only the two blob cases today — runtime to confirm none others occur. | +| `interactionQuery` | 7 | **ignored** — no branch. Carries web-search/ask-question/switch-mode/exa/plan/VM queries (`agent_pb.ts:3300-3350`) | The proxy never answers with `interactionResponse` (the client-side schema case exists at `agent_pb.ts:3629-3635`). If the server waits for an interaction response, the request HANGS; if it proceeds, the feature is silently broken. Runtime to confirm whether these fire in the failing models. | + +### InteractionUpdate — 17 cases (`agent_pb.ts:3157-3277`), dispatch in `proxy.ts:1104-1123` + +| Case | proto field | Disposition | Consequence of ignoring | +|---|---|---|---| +| `textDelta` | 1 | **handled** → content SSE (`proxy.ts:1111-1113`) | — | +| `partialToolCall` | 7 | ignored — by comment policy (`1120-1122`); tool calls flow through exec messages | Safe while Cursor uses exec-based tools; if any model emits interaction tool calls instead, the call is never surfaced and output can stall. Runtime. | +| `toolCallDelta` | 15 | ignored (same policy) | same as above | +| `toolCallStarted` | 2 | ignored (same policy) | same as above | +| `toolCallCompleted` | 3 | ignored (same policy) | same as above | +| `thinkingDelta` | 4 | **handled** → reasoning SSE (`proxy.ts:1114-1116`) | — | +| `thinkingCompleted` | 5 | ignored | minor; no semantic output | +| `userMessageAppended` | 6 | ignored | Model-side transcript append (e.g. ask-question flows) is dropped; if the server waits on a relayed user response the stream can stall. Runtime. | +| `tokenDelta` | 8 | **handled** → usage (`proxy.ts:1117-1119`) | — | +| `summary` | 9 | ignored | long-context compaction text is dropped from the transcript; not a hang by itself | +| `summaryStarted` | 10 | ignored | minor | +| `summaryCompleted` | 11 | ignored | minor | +| `shellOutputDelta` | 12 | ignored | background-shell stream output not relayed; proxy rejects `backgroundShellSpawnArgs` (`proxy.ts:1301-1316`) so likely unused. Runtime. | +| `heartbeat` | 13 | ignored — but its transport arrival resets the bridge idle timer via `h2Stream.on("data")` (`h2-bridge.mjs:154-155`) | **The trickle hazard**: server heartbeats keep the only bridge-side bound (T-T2) from firing while producing zero semantic progress. | +| `turnEnded` | 14 | **ignored** | If the server signals turn completion here and does not immediately end the H2 stream, the proxy never terminates — a leading hang candidate. Whether Cursor sends `turnEnded` and whether it precedes stream end is runtime; the silent drop is source-proven. | +| `stepStarted` | 16 | ignored | minor | +| `stepCompleted` | 17 | ignored | minor | + +Summary: **3 of 17** `InteractionUpdate` cases produce output (`textDelta`, `thinkingDelta`, +`tokenDelta`); **4 of 6** `AgentServerMessage` cases are dispatched. All other cases are +silently discarded while any of them — even `heartbeat` — resets the bridge idle timer. + +### ExecServerMessage handling (`proxy.ts:1175-1353`, cases at `agent_pb.ts:6882-7002`) + +Every known exec case is answered: `requestContextArgs` builds context; `mcpArgs` pauses for a +tool result; native args (`readArgs`, `lsArgs`, `grepArgs`, `writeArgs`, `deleteArgs`, +`shellArgs`/`shellStreamArgs`, `backgroundShellSpawnArgs`, `writeShellStdinArgs`, `fetchArgs`, +`diagnosticsArgs`) are redirected or rejected; `listMcpResourcesExecArgs`, +`readMcpResourceExecArgs`, `recordScreenArgs`, `computerUseArgs` get an empty `McpResult` +(`proxy.ts:1339-1349`). **Unknown exec cases** log `[proxy] unhandled exec` and send nothing +(`1351-1353`) — the server waits on an unanswered exec → stall risk. Runtime to confirm any +newer exec case reaches the wire. + +## 2.5 Parse/write failure matrix + +| # | Failure | Where | What actually happens | Classification | +|---|---|---|---|---| +| F1 | Partial Connect frame at stream end | `proxy.ts:980-995` buffers; reader loop exits at `402-405` | pending bytes discarded silently; cascade to T4/T5 | **silent truncation** — source-proven | +| F2 | `fromBinary` protobuf parse failure | `proxy.ts:1554-1556` catch → skip; also `1812-1814` | frame silently dropped, stream stays open; if the dropped frame was terminal → HANG | **HANGS (potential)** — source-proven silent drop | +| F3 | End-stream body not valid JSON / empty | `proxy.ts:947-960` | false `Error` → T3 error terminal | **terminates with error (false positive)** — source-proven | +| F4 | Unknown message case decodes fine | `proxy.ts:1079-1101` falls through | silently ignored (§2.4) | **HANGS (potential)** — source-proven | +| F5 | `bridge.write` to a dead/exiting child | `proxy.ts:411-413` try/catch | throw swallowed; frame lost silently → resumed stream may wait forever | **HANGS (potential)** — source-proven | +| F6 | Write to closed/destroyed H2 stream | `h2-bridge.mjs:194-196` guard skip | frame dropped silently; process stays alive so the parent believes the bridge is healthy | **HANGS (potential)** — source-proven | +| F7 | SSE enqueue on a cancelled/errored stream | `proxy.ts:1426-1430` no guard; no `cancel()` handler | after client abort the bridge is never torn down (§2.2 T7) | **leak** — source-proven; Bun enqueue-after-cancel behavior runtime | +| F8 | Child stdout pipe write backpressure | `h2-bridge.mjs:27-32` | `process.stdout.write` without drain handling; parent drains continuously (`proxy.ts:334-369`), so risk is low | **low risk** — source-proven | + +## 2.6 Resume matrix + +Bridge retention starts at tool pause (`proxy.ts:1523-1530`); lookup and eligibility at +`proxy.ts:610-624`; actual resume at `handleToolResultResume` (`proxy.ts:1651-1732`). + +| # | Scenario | Route | Outcome | Classification | +|---|---|---|---|---| +| R1 | Live healthy resume | `activeBridges.get` → `alive` true → `handleToolResultResume` (`proxy.ts:615-617`) | mcpResult/native frames sent per `pendingExec` (`1661-1721`); a new `createBridgeStreamResponse` streams the rest (`1723-1727`) | **terminates cleanly when transport healthy** — harness-confirmed: the paused bridge received its matching result and emitted `resumed-correctly` plus `[DONE]`. | +| R2 | Process-alive but transport-dead bridge resume | eligibility checks only `alive` = process existence (`proxy.ts:410`, `615`) | writes silently dropped at F6 (`h2-bridge.mjs:194-196`) → resumed SSE never receives data → HANG (bounded ~255s streaming truncation) | **HANGS (risk)** — liveness gap source-proven. The real H2 close fixture does **not** reproduce this exact state: the child exits, `alive` becomes false, and the proxy takes the fresh-request fallback. An alive child with a closed stream remains runtime-deferred. | +| R3 | Dead-bridge resume (process exited) | `alive` false → cleanup + `bridge.end()` + fresh request (`proxy.ts:619-624`) with `resumeAction` over reconstructed history (`909-911`) | context preserved; fresh bridge path | **terminates via fresh path** — source-proven | +| R4 | Bridge-key collision | `deriveBridgeKey` = sha256(model + first 200 chars of first user message) (`proxy.ts:1374-1381`) | two conversations with the same model+opening map to one `activeBridges` slot; the second pause overwrites the first entry without ending the first bridge (`1523-1530`) and `pendingExecs` are shared | **REPRODUCES** — harness-confirmed: resuming conversation A after conversation B paused sent A's result to B; B observed `resumed-with-wrong-result` and a clean `[DONE]`. Required behavior is per-conversation bridge isolation or an explicit error, never cross-conversation routing. | +| R5 | Dropped write during resume (child mid-exit race) | F5/F6 | result frame lost silently → resumed stream never progresses | **HANGS (risk)** — source-proven. The H2 close fixture does **not** reproduce a silent write: the child exits before resume and the proxy starts a fresh request. The process-alive write-drop race remains runtime-deferred. | +| R6 | Pause with no resume (abandoned tool call) | no TTL on `activeBridges`; only `stopProxy` clears (`578-582`) | bridge + heartbeat + H2 session leak indefinitely (§2.2 T8) | **leak** — source-proven | + +## 2.7 Verdicts on the five known mechanisms + +Status is against direct source reading in this part. + +1. **`parseConnectEndStream` clean no-op + empty-body false error — CONFIRMED (source).** + `proxy.ts:947-960` returns `null` for JSON without `error`; the caller is `if (endError)` + (`proxy.ts:1567`) so a clean end-stream is a no-op and termination depends entirely on the + bridge-exit cascade (T4). A zero-byte body makes `JSON.parse` throw → false error (T3). +2. **Unhandled message families + raw-byte timer resets — CONFIRMED (source).** 4 of 6 + `AgentServerMessage` and 3 of 17 `InteractionUpdate` cases are dispatched (§2.4); + `turnEnded` and `heartbeat` are dropped, and **any** inbound byte plus the proxy's own 5s + heartbeat defeats the only bridge-side idle bound (T-T2). The *hang outcome* of dropped + `turnEnded`/`interactionQuery` depends on whether the server waits (runtime), but the + silent drops themselves are source-proven. +3. **Non-zero bridge exit reported as clean success — CONFIRMED (source).** + `proxy.ts:1594-1601` emits `finish_reason: "stop"` + `[DONE]` for `code !== 0` whenever no + exec was received; matches upstream issue #33. +4. **Unbounded token refresh — CONFIRMED (source).** `refreshCursorToken` + (`auth.ts:143-177`) fetches with no `AbortSignal`/timeout and is awaited before headers in + the streaming path (`proxy.ts:543-545`) and in `collectFullResponse`'s caller — a stalled + exchange is a pre-header hang. +5. **Bridge reuse checks only process aliveness — CONFIRMED (source).** Eligibility is + `activeBridge.bridge.alive` (`proxy.ts:615`), which is `!exited` (`proxy.ts:410`); a live + child with a dead H2 session/stream accepts resume and silently drops writes + (`h2-bridge.mjs:194-196`). The failure *frequency* needs runtime evidence, but the + liveness gap itself is source-proven. + +**Mechanisms that still require runtime evidence from the controllable fixture:** +whether Cursor sends `turnEnded` (and whether it precedes stream end), `execServerControlMessage +abort`, or `interactionQuery` cases in the failing models; the server's behavior after a clean +end-stream envelope (does the H2 stream always end?); Node's event sequence on GOAWAY/session +close (T6); and Bun's SSE enqueue behavior after client abort (T7). + +## 2.8 Instrumentation gaps closed in this pass + +Only two diagnostics-only edits were made while mapping; both are gated, allowlisted, and +change no termination or protocol behavior: + +1. **`end_stream` stage** (`src/proxy.ts:1560-1564`, stage/field plumbing in + `src/auth.ts:24,30,66`). Previously the Connect end-stream event was invisible: a clean + end-stream produced no diagnostic and an empty/failed one was only visible through the + resulting error terminal. The new event carries `byteLength` and `status: "clean"|"error"` + only — no payload — so an operator can now distinguish "end-stream received cleanly but + stream still open" (no `terminal` after it) from "no end-stream at all". +2. **Bridge timeout-kill cause** (`src/h2-bridge.mjs:115-127`). Timeout kills previously + exited with **no** bridge-side diagnostic, so a 30s connect timeout, a 120s idle timeout, + a client error, and a stream error were indistinguishable in the proxy's `terminal` + record. `killBridge` now emits `terminal` with `errorName: "Timeout:connect"` or + `"Timeout:idle"` plus `exitCode: 1`. + +## 2.9 Diagnostic capture instructions + +**Enable:** set `CURSOR_PROXY_DIAGNOSTICS=1` in the environment of the OpenCode process (the +flag is read at `src/auth.ts:33-35`). Diagnostics are off by default; the child stderr pipe is +always drained regardless of the flag so disabling cannot reintroduce backpressure. + +**Reproduce a hang:** with the flag set, start OpenCode with a Cursor model, send a request +that exhibits the symptom, and wait past the stall. Capture the process stderr to a file: +`opencode 2>cursor-diag.log`. + +**Correlation:** every line for one request shares the same `requestId`. Bridge lines carry +`parentElapsedMs` (the proxy-side elapsed time at spawn) alongside their own `elapsedMs`, so +proxy and child records can be ordered across the process boundary. + +**Read the output to identify the stalled stage:** + +| Last stage(s) observed | Interpretation | +|---|---| +| `auth_access_start` … no `auth_access_complete` | stalled in the token provider / `refreshCursorToken` — pre-header hang (M4) | +| `bridge_spawn` … no `bridge_start` | child failed to boot or read config | +| `bridge_start` … no `h2_connect` | stalled connecting to `api2.cursor.sh`; expect `terminal exitCode 1` after 30s (`errorName: "Timeout:connect"`), then the masked-clean T5 path | +| `h2_connect` + `connect_stream` … no `bridge_data` | server accepted the stream but sends nothing — **the silent-stall signature** (T10); heartbeats keep the bridge timer defeated, so only Bun's 255s idle (streaming) or nothing (pre-header) bounds it | +| repeated `bridge_data` … no `connect_frame`/`message_dispatch` | bytes flow but no valid Connect frame parses (F1/F2) | +| repeated `message_dispatch` with `interactionCase: "heartbeat"`/`"turnEnded"`/`"stepCompleted"` and no `textDelta` | ignored-message trickle — semantic progress absent while timers keep resetting (§2.4 heartbeat/turnEnded rows) | +| `tool_pause` … no `tool_resume` | tool-call pause awaiting results (T8) | +| `end_stream` with `status: "clean"` … no `terminal` afterwards | clean end-stream was a no-op and the stream is being kept open (T1) — check whether the bridge ever exits | +| `terminal` with `exitCode: 1` (or `errorName: "Timeout:idle"`) followed by a clean `sse_emission` + `terminal` | masked non-zero bridge failure reported as clean success (T5) | +| `terminal` with `exitCode: 0` after `end_stream` | the normal clean cascade (T1→T4) | + +**Emitted-only-allowlist reminder:** stages, request ID, elapsed time, byte lengths, message +case names, tool counts, status/exit codes, and error class names. Tokens, authorization +headers, prompt text, tool arguments/results, and blob/checkpoint IDs are never serialized +(`src/auth.ts:48-67`; parent drain filter `src/proxy.ts:344-365`). + +## 2.10 Runtime reproduction verdicts + +The local fake Connect/HTTP2 backend now drives the proxy through isolated terminal sequences. +Each subprocess and fixture owns a deadline; the full smoke suite completes in about 7.8 seconds. +The intentionally failing assertions are quarantined in `test/smoke.ts`: they report their +observed baseline without making the smoke command fail. The fixture cannot prove what a live +Cursor server chooses to send; it can prove the proxy's response to each wire sequence below. + +| Candidate | Verdict | Harness evidence | +|---|---|---| +| Valid JSON end-stream is a no-op | **DOES NOT REPRODUCE as an open client SSE** | A `{}` end-stream followed by the local H2 lifecycle produced `[DONE]` through the bridge-exit cascade. The parser/caller no-op remains source-proven, but this fixture cannot keep the Node bridge open after that sequence, so it does not establish an externally visible hang. | +| Empty end-stream body | **REPRODUCES** | The clean zero-byte terminal produced `[Error: Failed to parse Connect end stream]`; the quarantined assertion requires a clean completion. | +| Missing end-stream | **REPRODUCES** | A text frame followed by transport EOF produced `[DONE]` and `finish_reason:"stop"`, with no error content. The required explicit protocol error assertion fails. | +| Non-zero bridge exit | **REPRODUCES** | An HTTP/2 protocol reset produced `[DONE]` plus `finish_reason:"stop"`, without error content. This is the false-success path reported upstream. | +| Malformed terminal frame | **REPRODUCES** | A terminal frame with an advertised length larger than its payload produced clean `[DONE]`, with no error content. The client receives silent truncation rather than a protocol error. | +| Heartbeat-only trickle | **REPRODUCES** | Repeated valid heartbeat frames generated no semantic SSE content. The fixture deadline finally forced transport closure, after which the proxy emitted clean `[DONE]` rather than an error. Because both bridge activity resets occur on every inbound byte/write, an unbounded producer can repeat this sequence indefinitely; the test fixture bounds the proof at one second so CI cannot hang. | +| `turnEnded` | **REPRODUCES as silent loss; live Cursor semantics remain deferred** | A valid `turnEnded` frame generated zero semantic SSE bytes, then the fixture deadline forced a clean `[DONE]` transport-close cascade. This proves local dropping and false success, not that real Cursor uses `turnEnded` as its terminal signal or sends it in this order. | +| Slow semantic progress | **PASSES** | Three delayed text deltas (`part-1` through `part-3`) all reached SSE and completed with `[DONE]` without an error. Any later liveness fix must preserve this behavior. | + +These results resolve the terminal-matrix runtime rows for T1, T3, T5, T9, and the +heartbeat/`turnEnded` portions of T10 as harness-confirmed. T6's precise Node GOAWAY/session +event ordering remains **unknown-needs-runtime-evidence**, since the local fixture cannot claim +that its event sequence matches Cursor's production transport. + +## 2.11 Resume, refresh, and non-streaming reproduction verdicts + +The same bounded local fixture now exercises the tool-call continuation boundary. Expected +defects remain quarantined in the smoke output; a fresh request and a healthy continuation remain +ordinary passing assertions. + +| Candidate | Verdict | Harness evidence and required behavior | +|---|---|---| +| Live resume | **DOES NOT REPRODUCE** | A paused bridge received its matching tool result and emitted `resumed-correctly` followed by `[DONE]`. Later liveness work must preserve this path. | +| Dead resume | **DOES NOT REPRODUCE for a real H2 close** | Destroying the fixture session caused the Node child to exit. On resume, `alive` was false and the proxy started a fresh request, which returned a new `tool_calls` response. The exact process-alive/stream-dead state is still source-proven as a risk but runtime-deferred; correct behavior there is fresh fallback or an explicit error, never a silent write. | +| Bridge-key collision | **REPRODUCES** | Two paused conversations sharing the same model and first user-message prefix occupied one key. Resuming the first delivered its result to the second bridge, observed as `resumed-with-wrong-result` and clean `[DONE]`. Correct behavior requires distinct conversation identity in the bridge key or an explicit collision error. | +| Dropped resume write | **DOES NOT REPRODUCE for a real H2 close** | The close fixture exits the child before the proxy writes, so the fresh-request fallback wins. F5/F6 still prove that a process-alive write can be swallowed; a transport-dead-but-process-alive fixture is required to make that externally observable. Correct behavior is an explicit caller error and cleanup/fresh fallback. | +| Stalled token refresh | **REPRODUCES** | A local refresh endpoint accepted the request and never replied. After 125ms, the proxy request had returned no headers (`headersReturned=false`). Correct behavior is a bounded refresh failure before a client can wait indefinitely. | +| Non-streaming collection | **REPRODUCES** | A heartbeat-trickle fixture never ended the bridge; after 125ms the `stream:false` request still had no headers (`headersReturned=false`). Correct behavior is a bounded explicit terminal response independent of streaming SSE handling. | + +The bounded suite remains safe: subprocesses exit after recording each 125ms observation, and +the fake server closes every tracked session and stream. No production source seam was added for +these reproductions. + +## 2.12 Terminal-contract evidence and remaining mechanism verdicts + +The streaming boundary now has one idempotent terminal owner. It flushes buffered output, emits +the error (when any), `stop`, usage, and one `[DONE]`, then releases the heartbeat, bridge-map +entry, child stdin, and SSE controller. A client cancellation takes the same resource-release +path without attempting a second SSE sequence. A tool-call pause is intentionally different: it +closes the current SSE response after `tool_calls`, but retains the live bridge and heartbeat for +the follow-up tool result. If that retained bridge later exits, its map entry is removed rather +than being treated as resumable. + +Complete Connect frames that fail protobuf decoding are now terminal protocol errors. This does +not make ordinary forward-compatible traffic fatal: protobuf preserves unknown fields, and known +but unsupported message families are explicitly classified. A decode exception means the payload +is structurally invalid, so continuing could silently discard the only terminal signal. Likewise, +a rejected parent-to-bridge write throws into the terminal owner; a write attempted after the +bridge H2 stream is closed makes the child exit non-zero rather than being silently discarded. + +| Originally theorized mechanism | Runtime verdict | Evidence and present disposition | +|---|---|---| +| Empty Connect end-stream is an error | **Refuted after fix** | The zero-byte fixture completes cleanly with exactly one `[DONE]`. | +| Missing end-stream / abrupt H2 close becomes clean success or retry fodder | **Confirmed before fix; closed** | Missing-end, stream-reset, and abrupt-session-close fixtures now emit one explicit error and one `[DONE]`; no empty success is observed. This closes the local boundary implicated by issue #33. | +| Malformed Connect terminal or protobuf message is silently lost | **Confirmed before fix; closed** | Partial/malformed terminal and malformed protobuf fixtures each reach one explicit error terminal. | +| Bridge write failure is silently dropped | **Confirmed by source; propagation covered** | A rejected KV response write propagates out of dispatch into the terminal path; the bridge also exits non-zero when stdin reaches a closed H2 stream. The process-alive, transport-dead resume race remains deferred to the resume-liveness work. | +| Tool-result pause must retain a live bridge | **Confirmed and preserved** | The healthy tool-resume fixture still returns `resumed-correctly`; pause is not terminal cleanup. | +| Heartbeat/checkpoint traffic can stand in for semantic progress | **Confirmed before classification; not a terminal-contract result** | Fixtures classify heartbeat/turn-ended traffic as non-progress and report transport closure explicitly. Stall detection itself remains separate work. | +| Unbounded refresh and non-streaming collection | **Confirmed and deferred** | Their quarantined bounded observations still show no headers at 125ms; no timeout, watchdog, or deadline was added here. | +| Bridge-key collision | **Confirmed and deferred** | The quarantined same-opening-conversation assertion still reproduces cross-routing; key derivation was not changed. | diff --git a/docs/grok-4-6-provenance.md b/docs/grok-4-6-provenance.md new file mode 100644 index 0000000..efb750a --- /dev/null +++ b/docs/grok-4-6-provenance.md @@ -0,0 +1,165 @@ +# Grok 4.6 Research Provenance and Prior-Art Survey + +Status: **Record of verified Grok 4.6 facts, explicit uncertainty, and prior-art verdicts.** +Evidence was gathered on 2026-08-12 by the workflow's researcher and scout (Field Notes below); +repository gates were re-verified by Wave 1 Task 1.1 at the inherited tip. This document satisfies +must-have **MH-1** and **MH-2**. The origin PR #34 cancellation verdict and client-abort lifecycle +evidence are recorded in [§8](#8-origin-pr-34-verdict-adapted-client-abort-lifecycle). + +## 1. Verified Grok 4.6 facts + +Each row records the value, the authoritative source, and the Field Note that verified it. + +| Fact | Value | Source | +|---|---|---| +| xAI API model id | `grok-4.6` | https://docs.x.ai/developers/release-notes (August 2026 entry); https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Cursor-facing surface | "Grok 4.6" with **256K default context**, capabilities **Agent + Thinking** | https://docs.cursor.com/models; `fn_20260812_vaamzb8z` | +| xAI direct-API context window | 500,000 tokens | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Input modalities | text and image | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Output modality | text only; **no stated output limit** | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Short-context pricing per 1M tokens | $2.00 input / $6.00 output (below 200k prompt tokens; $0.50 cached-read) | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z`; `fn_20260812_o13isq9e` | +| Reasoning effort | low, medium, high (default), through **xhigh** | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | + +Additional verified context from the same sources: knowledge cutoff February 1, 2026; Responses API +and Chat Completions API; tools function calling, web search, X search, and code execution; +distribution includes the xAI API, Grok Build, Cursor (all plans), OpenRouter, Vercel, and +Cloudflare (`fn_20260812_vaamzb8z`). + +**Metadata surfaces.** Cursor-facing values (256K default context, Agent + Thinking) come from +`docs.cursor.com/models` and describe what Cursor's Connect API serves. xAI direct-API values (500K +context, pricing, reasoning effort) come from `docs.x.ai`. The plugin proxies Cursor's Connect API, +so catalog limits should mirror the Cursor-facing 256K value; the 500K API discrepancy is documented +rather than hidden (`fn_20260812_vaamzb8z`, `fn_20260812_o13isq9e`). The existing `/grok/i` cost +pattern already routes `grok-4.6` to the `grok-4.20` key (`fn_20260812_uejn0r60`), whose $2/$6 rates +match Grok 4.5/4.6 short-context pricing; `grok-4.20` itself is a different xAI model at $1.25/$2.50 +(`fn_20260812_o13isq9e`, https://docs.x.ai/developers/models/grok-4.20-experimental-beta-0304). + +## 2. Explicit uncertainty + +These items are **not** asserted as verified. Each is labelled with its confidence grade from the +source Field Note. + +- **Cursor-facing model id string: UNVERIFIED (inference).** Presumed `grok-4.6`, matching the xAI + id and Cursor's docs path `/docs/models/grok-4-6`, but not yet confirmed against a live + `GetUsableModels` response (`fn_20260812_vaamzb8z`, "INFERENCE / UNCERTAIN"). Spec assumption A1 + and the risk table treat a differing id as an open risk; live confirmation is scheduled for Wave 5. + The fallback catalog entry is a best-effort resilience path, not the authoritative discovery path. +- **Billing pool: NO CLAIM MADE.** Whether Grok 4.6 on Cursor draws from the first-party pool or the + API/third-party pool is UNCONFIRMED. Launch-day Reddit reports are anecdotal and internally + inconsistent with Cursor's own grok page + (https://www.reddit.com/r/cursor/comments/1vmmfgc/grok_46_thoughts_usage/, INFERENCE grade, + `fn_20260812_vaamzb8z`). No plugin metadata depends on a pool, so this document makes no + billing-pool claim. +- **Rollout cadence: uncertain.** Cursor's changelog had no dedicated Grok 4.6 entry as of the + research date even though the models page lists the model; rollout may be gradual + (`fn_20260812_vaamzb8z`). + +## 3. Fork survey: no prior art to cherry-pick + +Origin `ephraimduncan/opencode-cursor` plus **24 surveyed forks** were searched via GitHub API fork, +commit, and code-search queries run 2026-08-12. Result: **zero `grok-4.6` code hits anywhere** +(`fn_20260812_gz125m33`). Independent corroboration: `gh search commits "grok 4.6"` and +`gh search issues "grok 4.6"` returned empty; the 50-item origin PR list contains no Grok PRs; six +local remote-only branches carry zero grok references in `src/`; origin/main (`a37a6ba`, v0.1.1) +fallback models contain only `grok-code-fast-1` (`fn_20260812_zyc1iqd0`). + +Verdict: **no cherry-pick exists.** Grok 4.6 catalog work is greenfield for this workflow. Upstream +issue #33, the acceptance-defining hang report, remains open (`fn_20260812_gz125m33`, +https://github.com/ephraimduncan/opencode-cursor/issues/33). + +## 4. Adapted prior art: tanushshukla a067e099 + +Commit [a067e099](https://github.com/tanushshukla/opencode-cursor/commit/a067e099) +("fix: end OpenAI stream on Cursor turnEnded", +203/-30 in `proxy.ts`) is recorded as **adapted +prior art, with attribution**, for two behaviors (`fn_20260812_gz125m33`, `fn_20260812_b0tcb20c`): + +- `turnEnded` as a clean terminal: finalize the SSE stream with `finish_reason: stop` plus `[DONE]`, + tear down the parked bridge, and guard the later close handler against double-reporting. +- `interactionQuery` answers with typed rejections (web search / ask / plan / exa / VM) so the model + can continue instead of waiting forever. + +**Behavioral adoption is evidence-gated, not committed here.** This branch currently treats +`turnEnded` as non-terminal (`src/proxy.ts:1294-1296`) and errors on `interactionQuery` +(`src/proxy.ts:1233-1237`) (`fn_20260812_b0tcb20c`). Whether to adopt the fork's behavior is decided +by Wave 3 deterministic fixtures plus isolated live observation, per spec assumption A7; any +adaptation must preserve this fork's stricter abnormal-path contract, which the upstream commit does +not have (`fn_20260812_b0tcb20c`; BLUEPRINT, "Prior art and provenance"). This section records +provenance only. + +## 5. Rejected upstream work: origin PR #36 + +Origin PR [#36](https://github.com/ephraimduncan/opencode-cursor/pull/36) +("fix: run on Node runtime (Desktop sidecar)", intellectronica, +19577/-97) replaces `Bun.*` with +`node:http` + `child_process` and changes module format. **Rejected as orthogonal**: the Bun runtime +is this workflow's constraint, the diff is large, and the conflict surface is high; it addresses no +Grok 4.6 or stall requirement here (`fn_20260812_gz125m33`, `fn_20260812_zyc1iqd0`). + +## 6. Disposition of the 18 inherited stability commits + +The branch carries 18 stability commits inherited from the earlier hang investigation (base +`origin/main` `a37a6ba`; tip `1ffca14`; +2505/-176; commit-level audit in `fn_20260812_fppld7hu`). +They are **already present in branch history**, so there is nothing to cherry-pick or reimplement. + +Wave 1 Task 1.1 re-verified them at the inherited tip `1ffca14` +(`1ffca147b461ea044e041626eb1ccc7465169526`) on `chore/stability-baseline`: `npx tsc -p tsconfig.json +--noEmit` PASS, `bun test/smoke.ts` PASS with 48 required assertions and 2 documented quarantines, +`bun run build` PASS. That record is the **accepted floor** for this workflow, written by commit +`39c6a15` (`docs(baseline): record inherited stability floor`) into +[`docs/cursor-hang-root-cause.md`](./cursor-hang-root-cause.md) ("Baseline status at inherited tip") +and [`docs/overlay-baseline.note.md`](./overlay-baseline.note.md) (`fn_20260812_fppld7hu`; +`docs/cursor-hang-root-cause.md:42-65`). + +Coverage those commits bring: bounded token refresh, strict Connect stream termination, an +idempotent terminal owner, a semantic-progress watchdog, HTTP/2 PING and session lifecycle handling, +tool-resume isolation, unary/non-streaming bounds, redacted lifecycle diagnostics, a fake +Connect/H2 fixture, and roughly 30 smoke assertions (`fn_20260812_fppld7hu`). + +## 7. Overlay disposition + +`docs/overlay-baseline.patch` is preserved as verbatim evidence and **MUST NOT be reapplied**; the +adjacent note and the RCA record the three-way split +([`docs/overlay-baseline.note.md`](./overlay-baseline.note.md); +`docs/cursor-hang-root-cause.md`, "Overlay provenance"): + +- **Kept / already landed:** stderr draining, sanitized error reporting, defensive close and destroy + guards. +- **Rejected:** the fixed timeout increases (30 to 60s connect; 120 to 180s idle). +- **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus H2 liveness. + +## 8. Origin PR #34 verdict: ADAPT (client-abort lifecycle) + +Origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) +("fix: handle cancelled SSE streams", noamkush, +4/-1) guards `sendSSE` with a `closed` flag to stop +`ERR_INVALID_STATE: Controller is already closed` when the client cancels +(`fn_20260812_gz125m33`). It is stall-adjacent to this fork's cancellation and termination path. + +**Verdict: ADAPT.** PR #34 correctly identifies the caller-cancellation boundary, but its `closed` +guard alone is insufficient for this fork: it only suppresses a later `controller.enqueue`, leaving +the bridge child, Connect/H2 stream, heartbeat, semantic-progress watchdog, paused-bridge indexes, +and this fork's idempotent terminal owner outside its scope. + +This fork already owns those resources through `createBridgeStreamResponse`'s idempotent terminal +owner: normal/error termination clears the semantic watchdog and heartbeat, removes matching +`activeBridges`/tool-call index entries, writes the one terminal SSE sequence, closes the controller, +and terminates the child. The bridge close callback persists current blobs and checkpoint state into +the existing `conversationStates` record, then cannot re-enter terminal cleanup. A paused bridge is +removed when its retained transport exits; durable conversation state is intentionally retained for +the TTL/disk-cache recovery contract. + +Wave 1 Task 1.3 adapts PR #34's missing cancellation guard to this ownership model: controller +closed-state now belongs to the shared response owner and `ReadableStream.cancel()` marks it before +clearing the watchdog/heartbeat, removing matching paused indexes, and terminating the bridge. It +does **not** enqueue or close the already consumer-cancelled controller, so late bridge callbacks +cannot write or produce a second close. `test/smoke.ts` uses the deterministic `client-abort` fake +Connect scenario to read one SSE frame, cancel the reader, and prove fixture-observed H2/session +cleanup, with zero retained proxy-side active bridges verified by source inspection. Typecheck and +the full deterministic smoke suite pass (2026-08-12). + +Evidence: origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) (+4/-1, +`closed` set by `cancel()`); `fn_20260812_gz125m33`; `src/proxy.ts:createBridgeStreamResponse`; +`test/fixtures/fake-cursor-server.ts:client-abort`; `test/smoke.ts:testClientAbortTeardown`. + +## Verification performed for this document + +- `bun run build` — PASS (documentation-only change; no source, tests, or dependencies touched). +- `git diff --check` — PASS for the new document. diff --git a/docs/overlay-baseline.note.md b/docs/overlay-baseline.note.md new file mode 100644 index 0000000..160f8c4 --- /dev/null +++ b/docs/overlay-baseline.note.md @@ -0,0 +1,18 @@ +# Overlay Baseline Disposition + +Status: **preserved evidence; accepted as historical input only.** The verbatim +unified diff in [`overlay-baseline.patch`](./overlay-baseline.patch) records the +speculative overlay that preceded the controlled stability work. It MUST NOT be +reapplied. Its meaningful whitespace is part of the evidence artifact and must +remain unchanged. + +At inherited tip `1ffca14` (`1ffca147b461ea044e041626eb1ccc7465169526`), the +overlay is dispositioned as follows: + +- **Kept/already landed:** stderr draining, sanitized errors, and defensive + close/destroy guards. +- **Rejected:** fixed 30→60s connect and 120→180s idle timeout bumps. +- **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus + H2 liveness. + +The repository gates were rerun against this tip and are recorded in the RCA. diff --git a/docs/overlay-baseline.patch b/docs/overlay-baseline.patch new file mode 100644 index 0000000..6afba1d --- /dev/null +++ b/docs/overlay-baseline.patch @@ -0,0 +1,195 @@ +diff --git a/.gitignore b/.gitignore +index 62ccde4..4d58f8d 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -2,3 +2,4 @@ node_modules/ + dist/ + *.tsbuildinfo + .DS_Store ++.goopspec/ +diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs +index e86132e..b16ca15 100644 +--- a/src/h2-bridge.mjs ++++ b/src/h2-bridge.mjs +@@ -89,21 +89,28 @@ const client = http2.connect(url || "https://api2.cursor.sh"); + + // Guard against initial connection failure. Reset on any h2 activity + // so long-running agent conversations (with tool call round-trips) survive. +-let timeout = setTimeout(killBridge, 30_000); ++// Initial connection timeout is generous because Cursor's server can be slow ++// to respond during peak load. ++let timeout = setTimeout(killBridge, 60_000); + + function resetTimeout() { + clearTimeout(timeout); +- timeout = setTimeout(killBridge, 120_000); ++ // 180s idle timeout: long enough for Grok 4.5 thinking/tool-call pauses, ++ // short enough to not hang OpenCode indefinitely. ++ timeout = setTimeout(killBridge, 180_000); + } + + function killBridge() { + clearTimeout(timeout); +- client.destroy(); ++ try { client.destroy(); } catch {} + process.exit(1); + } + +-client.on("error", () => { ++client.on("error", (err) => { + clearTimeout(timeout); ++ // Write the error to stderr so the parent process can see it. ++ // Without this, errors are silently swallowed. ++ try { process.stderr.write(`[h2-bridge] client error: ${err.message}\n`); } catch {} + process.exit(1); + }); + +@@ -136,9 +143,10 @@ h2Stream.on("end", () => { + setTimeout(() => process.exit(0), 100); + }); + +-h2Stream.on("error", () => { ++h2Stream.on("error", (err) => { + clearTimeout(timeout); +- client.close(); ++ try { process.stderr.write(`[h2-bridge] stream error: ${err.message}\n`); } catch {} ++ try { client.close(); } catch {} + process.exit(1); + }); + +diff --git a/src/proxy.ts b/src/proxy.ts +index 93c44ec..b74b2be 100644 +--- a/src/proxy.ts ++++ b/src/proxy.ts +@@ -308,7 +308,7 @@ function spawnBridge(options: SpawnBridgeOptions): { + const proc = Bun.spawn(["node", BRIDGE_PATH], { + stdin: "pipe", + stdout: "pipe", +- stderr: "ignore", ++ stderr: "pipe", + }); + + const config = JSON.stringify({ +@@ -319,6 +319,20 @@ function spawnBridge(options: SpawnBridgeOptions): { + }); + proc.stdin.write(lpEncode(new TextEncoder().encode(config))); + ++ // Forward stderr to console for debugging. Without this, bridge errors ++ // are silently swallowed and the proxy appears to hang for no reason. ++ (async () => { ++ try { ++ const reader = proc.stderr.getReader(); ++ while (true) { ++ const { done, value } = await reader.read(); ++ if (done) break; ++ const text = new TextDecoder().decode(value); ++ if (text.trim()) console.error(`[h2-bridge] ${text.trimEnd()}`); ++ } ++ } catch {} ++ })(); ++ + const cbs = { + data: null as ((chunk: Buffer) => void) | null, + close: null as ((code: number) => void) | null, +@@ -469,7 +483,7 @@ export async function startProxy( + + proxyServer = Bun.serve({ + port: 0, +- idleTimeout: 255, // max — Cursor responses can take 30s+ ++ idleTimeout: 255, // max - Cursor responses can take 30s+, especially Grok 4.5 + async fetch(req) { + const url = new URL(req.url); + +@@ -1511,10 +1525,50 @@ function createBridgeStreamResponse( + }, + ); + +- bridge.onData(processChunk); ++ // --- Watchdog timer --- ++ // If no data arrives within TOOL_CALL_TIMEOUT_MS, force-close the stream ++ // so OpenCode doesn't hang waiting for a response that will never come. ++ // This catches: H2 socket close without Connect end-stream frame, ++ // silent server disconnects, and model stalls (especially Grok 4.5). ++ const TOOL_CALL_TIMEOUT_MS = 90_000; // 90s ++ let lastDataTime = Date.now(); ++ const watchdog = setInterval(() => { ++ if (closed) { ++ clearInterval(watchdog); ++ return; ++ } ++ if (Date.now() - lastDataTime > TOOL_CALL_TIMEOUT_MS) { ++ clearInterval(watchdog); ++ if (!mcpExecReceived) { ++ const flushed = tagFilter.flush(); ++ if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); ++ if (flushed.content) sendSSE(makeChunk({ content: flushed.content })); ++ } ++ sendSSE(makeChunk({ ++ content: "\n[Error: request timed out - no data from Cursor server]", ++ })); ++ sendSSE(makeChunk({}, "stop")); ++ sendSSE(makeUsageChunk()); ++ sendDone(); ++ closeController(); ++ activeBridges.delete(bridgeKey); ++ clearInterval(heartbeatTimer); ++ bridge.end(); ++ } ++ }, 5_000); ++ ++ // Wrap processChunk to track data arrival for watchdog ++ const originalProcessChunk = processChunk; ++ const trackedProcessChunk = (incoming: Buffer) => { ++ lastDataTime = Date.now(); ++ originalProcessChunk(incoming); ++ }; ++ ++ bridge.onData(trackedProcessChunk); + + bridge.onClose((code) => { + clearInterval(heartbeatTimer); ++ clearInterval(watchdog); + const stored = conversationStates.get(convKey); + if (stored) { + for (const [k, v] of blobStore) stored.blobStore.set(k, v); +@@ -1529,16 +1583,30 @@ function createBridgeStreamResponse( + sendSSE(makeUsageChunk()); + sendDone(); + closeController(); +- } else if (code !== 0) { +- // Bridge died while tool calls are pending (timeout, crash, etc.). +- // Close the SSE stream so the client doesn't hang forever. +- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" })); +- sendSSE(makeChunk({}, "stop")); +- sendSSE(makeUsageChunk()); +- sendDone(); +- closeController(); +- // Remove stale entry so the next request doesn't try to resume it. +- activeBridges.delete(bridgeKey); ++ } else { ++ // Bridge closed while tool calls are pending. ++ // Whether the exit code is 0 (server clean-close) or non-zero ++ // (timeout/crash), if mcpExecReceived is true, the bridge is ++ // kept alive for continuation. When it dies unexpectedly, the ++ // next request will detect a dead bridge and fall through to a ++ // fresh one. But we must NOT close the SSE stream here when ++ // code === 0 and mcpExecReceived is true, because that means ++ // the tool calls were emitted and OpenCode is executing them. ++ // The stream was already closed by the onMcpExec handler. ++ if (code !== 0) { ++ // Bridge died with error while tool calls pending. ++ // Close the SSE stream so the client doesn't hang forever. ++ sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" })); ++ sendSSE(makeChunk({}, "stop")); ++ sendSSE(makeUsageChunk()); ++ sendDone(); ++ closeController(); ++ // Remove stale entry so the next request doesn't try to resume it. ++ activeBridges.delete(bridgeKey); ++ } ++ // When code === 0 and mcpExecReceived: the SSE stream was already ++ // closed by onMcpExec. Nothing more to do. The bridge is kept ++ // alive in activeBridges for the tool-result resume path. + } + }); + }, diff --git a/src/auth.ts b/src/auth.ts index 330233f..104b276 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,5 @@ import { generatePKCE } from "./pkce"; +import { AsyncLocalStorage } from "node:async_hooks"; const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl"; const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll"; @@ -10,6 +11,76 @@ const POLL_MAX_ATTEMPTS = 150; const POLL_BASE_DELAY = 1000; const POLL_MAX_DELAY = 10_000; const POLL_BACKOFF_MULTIPLIER = 1.2; +const DEFAULT_REFRESH_TIMEOUT_MS = 15_000; +const MAX_REFRESH_TIMEOUT_MS = 60_000; + +export class CursorTokenRefreshTimeoutError extends Error { + constructor() { + super("Cursor token refresh timed out; retry the request or sign in again"); + this.name = "CursorTokenRefreshTimeoutError"; + } +} + +function getRefreshTimeoutMs(): number { + const configured = Number(process.env.CURSOR_REFRESH_TIMEOUT_MS); + if (!Number.isFinite(configured) || configured <= 0) return DEFAULT_REFRESH_TIMEOUT_MS; + return Math.min(Math.floor(configured), MAX_REFRESH_TIMEOUT_MS); +} + +const lifecycleDiagnosticContext = new AsyncLocalStorage<{ + requestId: string; + startedAt: number; + emittedStages: Set; +}>(); + +export type LifecycleDiagnosticStage = + | "auth_access_start" | "auth_access_complete" + | "auth_refresh_start" | "auth_refresh_complete" | "auth_refresh_failed" + | "proxy_entry" | "bridge_spawn" | "connect_frame" | "end_stream" + | "message_dispatch" + | "tool_pause" | "tool_resume" | "sse_emission" | "terminal"; + +type LifecycleDiagnosticDetails = Readonly<{ + byteLength?: number; messageCase?: string; interactionCase?: string; + toolCount?: number; exitCode?: number; errorName?: string; status?: string; +}>; + +/** Diagnostics are opt-in because even safe metadata is operational noise. */ +export function lifecycleDiagnosticsEnabled(): boolean { + return process.env.CURSOR_PROXY_DIAGNOSTICS === "1"; +} + +/** Keep one correlation ID across asynchronous proxy and auth work. */ +export function withLifecycleDiagnosticContext(requestId: string, operation: () => T): T { + return lifecycleDiagnosticContext.run({ requestId, startedAt: performance.now(), emittedStages: new Set() }, operation); +} + +export function lifecycleDiagnosticContextInfo(): { requestId: string; elapsedMs: number } | undefined { + const context = lifecycleDiagnosticContext.getStore(); + return context && { requestId: context.requestId, elapsedMs: Math.round(performance.now() - context.startedAt) }; +} + +/** Emits only explicitly allowlisted metadata. Do not add payload-bearing fields here. */ +export function emitLifecycleDiagnostic( + source: "auth" | "proxy", stage: LifecycleDiagnosticStage, + details: LifecycleDiagnosticDetails = {}, +): void { + if (!lifecycleDiagnosticsEnabled()) return; + const context = lifecycleDiagnosticContextInfo(); + if (!context) return; + const store = lifecycleDiagnosticContext.getStore(); + if (!store || store.emittedStages.has(stage)) return; + store.emittedStages.add(stage); + const event: Record = { source, stage, requestId: context.requestId, elapsedMs: context.elapsedMs }; + if (typeof details.byteLength === "number") event.byteLength = details.byteLength; + if (typeof details.messageCase === "string") event.messageCase = details.messageCase; + if (typeof details.interactionCase === "string") event.interactionCase = details.interactionCase; + if (typeof details.toolCount === "number") event.toolCount = details.toolCount; + if (typeof details.exitCode === "number") event.exitCode = details.exitCode; + if (typeof details.errorName === "string") event.errorName = details.errorName; + if (typeof details.status === "string") event.status = details.status; + console.error(JSON.stringify(event)); +} export interface CursorAuthParams { verifier: string; @@ -89,18 +160,34 @@ export async function pollCursorAuth( export async function refreshCursorToken( refreshToken: string, ): Promise { - const response = await fetch(CURSOR_REFRESH_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${refreshToken}`, - "Content-Type": "application/json", - }, - body: "{}", - }); + emitLifecycleDiagnostic("auth", "auth_refresh_start"); + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, getRefreshTimeoutMs()); + let response: Response; + try { + response = await fetch(CURSOR_REFRESH_URL, { + method: "POST", + headers: { Authorization: `Bearer ${refreshToken}`, "Content-Type": "application/json" }, + body: "{}", + signal: controller.signal, + }); + } catch (error) { + const refreshError = timedOut + ? new CursorTokenRefreshTimeoutError() + : new Error("Cursor token refresh request failed"); + emitLifecycleDiagnostic("auth", "auth_refresh_failed", { errorName: refreshError.name }); + throw refreshError; + } finally { + clearTimeout(timeout); + } if (!response.ok) { - const error = await response.text(); - throw new Error(`Cursor token refresh failed: ${error}`); + emitLifecycleDiagnostic("auth", "auth_refresh_failed", { exitCode: response.status }); + throw new Error(`Cursor token refresh failed with HTTP status ${response.status}`); } const data = (await response.json()) as { @@ -108,11 +195,13 @@ export async function refreshCursorToken( refreshToken: string; }; - return { + const credentials = { access: data.accessToken, refresh: data.refreshToken || refreshToken, expires: getTokenExpiry(data.accessToken), }; + emitLifecycleDiagnostic("auth", "auth_refresh_complete"); + return credentials; } diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index e86132e..5b48d3d 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -22,13 +22,29 @@ import http2 from "node:http2"; import crypto from "node:crypto"; const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f"; +const DEFAULT_PING_INTERVAL_MS = 15_000; +const DEFAULT_PING_TIMEOUT_MS = 10_000; +const DEFAULT_SESSION_TIMEOUT_MS = 120_000; +const MAX_PING_INTERVAL_MS = 300_000; +const MAX_PING_TIMEOUT_MS = 60_000; +const MAX_SESSION_TIMEOUT_MS = 600_000; -/** Write one length-prefixed message to stdout. */ -function writeMessage(data) { +function boundedDuration(name, fallback, maximum) { + const value = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(value) && value > 0 ? Math.min(value, maximum) : fallback; +} + +/** Write one typed length-prefixed message to stdout. */ +function writeMessage(kind, data) { + const payload = Buffer.concat([Buffer.from([kind]), Buffer.from(data)]); const lenBuf = Buffer.alloc(4); - lenBuf.writeUInt32BE(data.length, 0); + lenBuf.writeUInt32BE(payload.length, 0); process.stdout.write(lenBuf); - process.stdout.write(data); + process.stdout.write(payload); +} + +function writeTransportStatus(transport) { + writeMessage(1, Buffer.from(JSON.stringify({ transport }))); } // --- Buffered stdin reader --- @@ -83,29 +99,109 @@ const configBuf = await readMessage(); if (!configBuf) process.exit(1); const config = JSON.parse(configBuf.toString("utf8")); -const { accessToken, url, path: rpcPath, unary } = config; +const { accessToken, url, path: rpcPath, unary, diagnostic: diagnosticContext, diagnosticsEnabled } = config; +const diagnosticStartedAt = performance.now(); +const emittedStages = new Set(); + +// This is deliberately an allowlist. Transport errors, headers, payloads, +// and request configuration values are never serialized. +function emitDiagnostic(stage, details = {}) { + if (!diagnosticsEnabled || !diagnosticContext?.requestId || emittedStages.has(stage)) return; + emittedStages.add(stage); + const event = { + source: "bridge", + stage, + requestId: diagnosticContext.requestId, + elapsedMs: Math.round(performance.now() - diagnosticStartedAt), + parentElapsedMs: diagnosticContext.elapsedMs, + }; + for (const key of ["byteLength", "exitCode"]) + if (typeof details[key] === "number") event[key] = details[key]; + if (typeof details.status === "string") event.status = details.status; + if (typeof details.errorName === "string") event.errorName = details.errorName; + try { process.stderr.write(`${JSON.stringify(event)}\n`); } catch {} +} +emitDiagnostic("bridge_start"); const client = http2.connect(url || "https://api2.cursor.sh"); +const pingIntervalMs = boundedDuration( + "CURSOR_BRIDGE_PING_INTERVAL_MS", DEFAULT_PING_INTERVAL_MS, MAX_PING_INTERVAL_MS, +); +const pingTimeoutMs = boundedDuration( + "CURSOR_BRIDGE_PING_TIMEOUT_MS", DEFAULT_PING_TIMEOUT_MS, MAX_PING_TIMEOUT_MS, +); +const sessionTimeoutMs = boundedDuration( + "CURSOR_BRIDGE_SESSION_TIMEOUT_MS", DEFAULT_SESSION_TIMEOUT_MS, MAX_SESSION_TIMEOUT_MS, +); +let pingInterval; +let pingTimeout; +let cleanExit = false; + +function clearLivenessTimers() { + clearTimeout(timeout); + if (pingInterval) clearInterval(pingInterval); + if (pingTimeout) clearTimeout(pingTimeout); + pingInterval = undefined; + pingTimeout = undefined; +} + +function sendPing() { + if (failed || cleanExit || pingTimeout) return; + try { + pingTimeout = setTimeout(() => failBridge("PingTimeout"), pingTimeoutMs); + client.ping((error) => { + if (pingTimeout) clearTimeout(pingTimeout); + pingTimeout = undefined; + if (error) failBridge("PingFailure"); + }); + } catch { + failBridge("PingFailure"); + } +} + +client.on("connect", () => { + emitDiagnostic("h2_connect"); + writeTransportStatus("writable"); + // PING validates the HTTP/2/TCP path, not model output. A 15s cadence and + // 10s response window leave normal multi-minute model thinking untouched. + pingInterval = setInterval(sendPing, pingIntervalMs); +}); // Guard against initial connection failure. Reset on any h2 activity // so long-running agent conversations (with tool call round-trips) survive. -let timeout = setTimeout(killBridge, 30_000); +let timeoutReason = "connect"; +let timeout = setTimeout(() => killBridge(timeoutReason), 30_000); function resetTimeout() { clearTimeout(timeout); - timeout = setTimeout(killBridge, 120_000); + timeoutReason = "idle"; + timeout = setTimeout(() => killBridge(timeoutReason), 120_000); } -function killBridge() { - clearTimeout(timeout); - client.destroy(); - process.exit(1); +function killBridge(reason = "connect") { + failBridge(`Timeout:${reason}`); } -client.on("error", () => { - clearTimeout(timeout); +let failed = false; +function failBridge(errorName) { + if (failed || cleanExit) return; + failed = true; + writeTransportStatus("failed"); + clearLivenessTimers(); + emitDiagnostic("terminal", { errorName, exitCode: 1 }); + try { client.destroy(); } catch {} process.exit(1); +} + +client.on("error", () => failBridge("Http2SessionError")); + +client.on("goaway", () => failBridge("Http2Goaway")); +client.on("frameError", () => failBridge("Http2FrameError")); +client.on("timeout", () => failBridge("Http2SessionTimeout")); +client.on("close", () => { + if (!cleanExit) failBridge("Http2SessionClose"); }); +client.setTimeout(sessionTimeoutMs); const headers = { ":method": "POST", @@ -122,24 +218,51 @@ if (!unary) { headers["connect-protocol-version"] = "1"; } const h2Stream = client.request(headers); +emitDiagnostic("connect_stream"); + +const CONNECT_END_STREAM_FLAG = 0b00000010; +let connectFrameBuffer = Buffer.alloc(0); +let sawConnectEndStream = false; + +function observeConnectFrames(chunk) { + if (unary) return; + connectFrameBuffer = Buffer.concat([connectFrameBuffer, chunk]); + while (connectFrameBuffer.length >= 5) { + const flags = connectFrameBuffer[0]; + const messageLength = connectFrameBuffer.readUInt32BE(1); + if (connectFrameBuffer.length < 5 + messageLength) return; + connectFrameBuffer = connectFrameBuffer.subarray(5 + messageLength); + if (flags & CONNECT_END_STREAM_FLAG) sawConnectEndStream = true; + } +} // Forward H2 response data → stdout (length-prefixed) h2Stream.on("data", (chunk) => { resetTimeout(); - writeMessage(chunk); + observeConnectFrames(chunk); + emitDiagnostic("bridge_data", { byteLength: chunk.length }); + writeMessage(0, chunk); }); h2Stream.on("end", () => { - clearTimeout(timeout); + clearLivenessTimers(); + if (!unary && !sawConnectEndStream) { + failBridge("ConnectProtocolError"); + } + cleanExit = true; + writeTransportStatus("closed"); + emitDiagnostic("terminal", { exitCode: 0 }); client.close(); // Give stdout time to flush setTimeout(() => process.exit(0), 100); }); -h2Stream.on("error", () => { - clearTimeout(timeout); - client.close(); - process.exit(1); +h2Stream.on("error", (err) => { + failBridge("Http2StreamError"); +}); +h2Stream.on("aborted", () => failBridge("Http2StreamAborted")); +h2Stream.on("close", () => { + if (!cleanExit) failBridge("Http2StreamClose"); }); // Forward stdin → H2 stream (after config message) @@ -160,9 +283,16 @@ if (unary) { // EOF or zero-length = done writing break; } - if (!h2Stream.closed && !h2Stream.destroyed) { + if (h2Stream.closed || h2Stream.destroyed) { + failBridge("BridgeWriteError"); + return; + } + try { resetTimeout(); h2Stream.write(msg); + } catch (error) { + failBridge(error?.name || "BridgeWriteError"); + return; } } diff --git a/src/native-tools.ts b/src/native-tools.ts index 7ab2cca..fa4502c 100644 --- a/src/native-tools.ts +++ b/src/native-tools.ts @@ -221,13 +221,14 @@ interface PendingNativeExec { * Convert the redirected tool's text result into the typed native result the * paused exec expects. Returns false when no faithful conversion exists * (caller falls back to an mcpResult). - * `sendMessage` receives an unframed AgentClientMessage binary. + * `sendMessage` receives an unframed AgentClientMessage binary and must report + * whether the retained transport accepted it. */ export function sendNativeExecResult( exec: PendingNativeExec, binding: NativeExecBinding, text: string, - sendMessage: (bytes: Uint8Array) => void, + sendMessage: (bytes: Uint8Array) => boolean, ): boolean { const args = binding.args; @@ -243,7 +244,9 @@ export function sendNativeExecResult( const clientMessage = create(AgentClientMessageSchema, { message: { case: "execClientMessage", value: execClientMessage }, }); - sendMessage(toBinary(AgentClientMessageSchema, clientMessage)); + if (!sendMessage(toBinary(AgentClientMessageSchema, clientMessage))) { + throw new Error("Failed to write native tool result to Cursor bridge"); + } }; switch (binding.resultType) { @@ -353,7 +356,9 @@ export function sendNativeExecResult( const clientMessage = create(AgentClientMessageSchema, { message: { case: "execClientControlMessage", value: controlMessage }, }); - sendMessage(toBinary(AgentClientMessageSchema, clientMessage)); + if (!sendMessage(toBinary(AgentClientMessageSchema, clientMessage))) { + throw new Error("Failed to write native tool result to Cursor bridge"); + } return true; } diff --git a/src/proxy.ts b/src/proxy.ts index 93c44ec..abb03da 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -65,6 +65,7 @@ import { type AgentServerMessage, type ConversationStateStructure, type ExecServerMessage, + type InteractionUpdate, type KvServerMessage, type McpToolDefinition, } from "./proto/agent_pb"; @@ -78,6 +79,13 @@ import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promi import { homedir } from "node:os"; import { resolve as pathResolve } from "node:path"; import { z } from "zod"; +import { + CursorTokenRefreshTimeoutError, + emitLifecycleDiagnostic, + lifecycleDiagnosticContextInfo, + lifecycleDiagnosticsEnabled, + withLifecycleDiagnosticContext, +} from "./auth"; const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh"; const CONNECT_END_STREAM_FLAG = 0b00000010; @@ -149,6 +157,10 @@ interface PendingExec { /** A bridge kept alive across requests for tool result continuation. */ interface ActiveBridge { + /** Opaque proxy-generated handle; never derived from caller content. */ + resumeId: string; + /** Cursor tool-call identifiers that resolve this exact paused interaction. */ + toolCallIds: Set; bridge: ReturnType; heartbeatTimer: NodeJS.Timeout; blobStore: Map; @@ -157,10 +169,11 @@ interface ActiveBridge { pendingExecs: PendingExec[]; } -// Active bridges keyed by a session token (derived from conversation state). -// When tool_calls are returned, the bridge stays alive. The next request -// with tool results looks up the bridge and sends mcpResult messages. +// Active bridges are keyed by an opaque, per-pause UUID. A separate index maps +// Cursor's tool-call identifiers back to that UUID on the follow-up request. +// Message content is deliberately not part of resume identity. const activeBridges = new Map(); +const activeBridgeToolCalls = new Map(); interface StoredConversation { conversationId: string; @@ -294,31 +307,88 @@ interface SpawnBridgeOptions { url?: string; /** When true, use application/proto for unary RPCs instead of Connect streaming. */ unary?: boolean; + diagnostic?: { requestId: string; elapsedMs: number }; } function spawnBridge(options: SpawnBridgeOptions): { proc: ReturnType; - write: (data: Uint8Array) => void; + /** Returns false when the child has already exited or its stdin rejects the frame. */ + write: (data: Uint8Array) => boolean; end: () => void; + /** Stop the child when this request has reached a non-resumable terminal state. */ + terminate: () => void; onData: (cb: (chunk: Buffer) => void) => void; onClose: (cb: (code: number) => void) => void; /** True while the bridge subprocess is still running. */ get alive(): boolean; + /** True only after the child reports a live, writable HTTP/2 transport. */ + get writable(): boolean; } { const proc = Bun.spawn(["node", BRIDGE_PATH], { stdin: "pipe", stdout: "pipe", - stderr: "ignore", + stderr: "pipe", }); + emitLifecycleDiagnostic("proxy", "bridge_spawn"); const config = JSON.stringify({ accessToken: options.accessToken, url: options.url ?? CURSOR_API_URL, path: options.rpcPath, unary: options.unary ?? false, + diagnostic: options.diagnostic, + diagnosticsEnabled: lifecycleDiagnosticsEnabled(), }); proc.stdin.write(lpEncode(new TextEncoder().encode(config))); + // Always drain stderr: an unread pipe can block the child. Only allowlisted + // diagnostic records are emitted, and only when explicitly enabled. + (async () => { + try { + const reader = proc.stderr.getReader(); + let pending = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + pending += new TextDecoder().decode(value, { stream: true }); + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (!lifecycleDiagnosticsEnabled()) continue; + try { + const parsed = JSON.parse(line) as Record; + const diagnosticRequestId = options.diagnostic?.requestId; + if (!diagnosticRequestId || parsed.source !== "bridge" || parsed.requestId !== diagnosticRequestId) continue; + const bridgeStages = new Set([ + "bridge_start", "h2_connect", "connect_stream", "bridge_data", "terminal", + ]); + if (typeof parsed.stage !== "string" || !bridgeStages.has(parsed.stage)) continue; + const event: Record = { + source: "bridge", + stage: parsed.stage, + requestId: diagnosticRequestId, + elapsedMs: typeof parsed.elapsedMs === "number" ? parsed.elapsedMs : 0, + }; + for (const key of ["parentElapsedMs", "byteLength", "status", "exitCode"] as const) { + if (typeof parsed[key] === "number") event[key] = parsed[key]; + } + const allowedBridgeErrorNames = new Set([ + "Timeout:connect", "Timeout:idle", "ConnectProtocolError", "BridgeWriteError", + "PingTimeout", "PingFailure", "Http2SessionError", "Http2Goaway", + "Http2FrameError", "Http2SessionTimeout", "Http2SessionClose", + "Http2StreamError", "Http2StreamAborted", "Http2StreamClose", + ]); + if ( + typeof parsed.errorName === "string" && + allowedBridgeErrorNames.has(parsed.errorName) + ) event.errorName = parsed.errorName; + console.error(JSON.stringify(event)); + } catch {} + } + } + } catch {} + })(); + const cbs = { data: null as ((chunk: Buffer) => void) | null, close: null as ((code: number) => void) | null, @@ -327,6 +397,7 @@ function spawnBridge(options: SpawnBridgeOptions): { // Track exit state so late onClose registrations fire immediately. let exited = false; let exitCode = 1; + let writable = false; (async () => { const reader = proc.stdout.getReader(); @@ -343,7 +414,19 @@ function spawnBridge(options: SpawnBridgeOptions): { if (pending.length < 4 + len) break; const payload = pending.subarray(4, 4 + len); pending = pending.subarray(4 + len); - cbs.data?.(Buffer.from(payload)); + // The child reserves the first byte for pipe-level control. H2 data + // remains opaque to this layer; control records only establish whether + // a retained bridge can safely accept a tool-result resume. + if (payload[0] === 1) { + try { + const status = JSON.parse(payload.subarray(1).toString("utf8")); + writable = status.transport === "writable"; + } catch { + writable = false; + } + } else if (payload[0] === 0) { + cbs.data?.(Buffer.from(payload.subarray(1))); + } } } } catch { @@ -359,8 +442,15 @@ function spawnBridge(options: SpawnBridgeOptions): { return { proc, get alive() { return !exited; }, + get writable() { return !exited && writable; }, write(data) { - try { proc.stdin.write(lpEncode(data)); } catch {} + if (exited) return false; + try { + proc.stdin.write(lpEncode(data)); + return true; + } catch { + return false; + } }, end() { try { @@ -368,6 +458,10 @@ function spawnBridge(options: SpawnBridgeOptions): { proc.stdin.end(); } catch {} }, + terminate() { + try { proc.stdin.end(); } catch {} + try { proc.kill(); } catch {} + }, onData(cb) { cbs.data = cb; }, onClose(cb) { if (exited) { @@ -454,6 +548,11 @@ export function getProxyPort(): number | undefined { return proxyPort; } +/** Visible for lifecycle assertions; retained bridges are never resumable after termination. */ +export function getActiveBridgeCount(): number { + return activeBridges.size; +} + export async function startProxy( getAccessToken: () => Promise, models: ReadonlyArray<{ id: string; name: string }> = [], @@ -484,22 +583,41 @@ export async function startProxy( } if (req.method === "POST" && url.pathname === "/v1/chat/completions") { - try { - const body = (await req.json()) as ChatCompletionRequest; - if (!proxyAccessTokenProvider) { - throw new Error("Cursor proxy access token provider not configured"); + return withLifecycleDiagnosticContext(crypto.randomUUID(), async () => { + try { + emitLifecycleDiagnostic("proxy", "proxy_entry"); + const body = (await req.json()) as ChatCompletionRequest; + if (!proxyAccessTokenProvider) { + throw new Error("Cursor proxy access token provider not configured"); + } + emitLifecycleDiagnostic("proxy", "auth_access_start"); + const accessToken = await proxyAccessTokenProvider(); + emitLifecycleDiagnostic("proxy", "auth_access_complete"); + // Await so failures from either streaming or non-streaming handling + // remain inside this request's structured-error boundary. + return await handleChatCompletion(body, accessToken); + } catch (err) { + emitLifecycleDiagnostic("proxy", "terminal", { + errorName: err instanceof Error ? err.name : "UnknownError", + }); + const message = err instanceof Error ? err.message : String(err); + const refreshTimedOut = err instanceof CursorTokenRefreshTimeoutError; + const responseStalled = err instanceof CursorResponseStallTimeoutError; + return new Response( + JSON.stringify({ + error: { + message, + type: refreshTimedOut || responseStalled ? "gateway_timeout" : "server_error", + code: refreshTimedOut ? "token_refresh_timeout" : responseStalled ? "response_stall_timeout" : "internal_error", + }, + }), + { + status: refreshTimedOut || responseStalled ? 504 : 500, + headers: { "Content-Type": "application/json" }, + }, + ); } - const accessToken = await proxyAccessTokenProvider(); - return handleChatCompletion(body, accessToken); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return new Response( - JSON.stringify({ - error: { message, type: "server_error", code: "internal_error" }, - }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } + }); } return new Response("Not Found", { status: 404 }); @@ -548,18 +666,19 @@ async function handleChatCompletion( ); } - // bridgeKey: model-specific, for active tool-call bridges - // convKey: model-independent, for conversation state that survives model switches - const bridgeKey = deriveBridgeKey(modelId, body.messages); + // A tool-result request has no trustworthy conversation ID in OpenAI's wire + // shape. Its tool_call_id is the only identifier bound to the paused server + // interaction, so resolve exclusively through that index and fail closed on + // missing, ambiguous, or conflicting IDs. const convKey = deriveConversationKey(body.messages); - const activeBridge = activeBridges.get(bridgeKey); + const activeBridge = findPausedBridge(toolResults); if (activeBridge && toolResults.length > 0) { - activeBridges.delete(bridgeKey); + removeActiveBridge(activeBridge); - if (activeBridge.bridge.alive) { + if (activeBridge.bridge.writable) { // Resume the live bridge with tool results - return handleToolResultResume(activeBridge, toolResults, userText, modelId, bridgeKey, convKey); + return handleToolResultResume(activeBridge, toolResults, userText, modelId, convKey); } // Bridge died (timeout, server disconnect, etc.). @@ -569,10 +688,10 @@ async function handleChatCompletion( } // Clean up stale bridge if present - if (activeBridge && activeBridges.has(bridgeKey)) { + if (activeBridge && activeBridges.has(activeBridge.resumeId)) { clearInterval(activeBridge.heartbeatTimer); activeBridge.bridge.end(); - activeBridges.delete(bridgeKey); + removeActiveBridge(activeBridge); } let stored = conversationStates.get(convKey); @@ -602,7 +721,7 @@ async function handleChatCompletion( if (body.stream === false) { return handleNonStreamingResponse(payload, accessToken, modelId, convKey); } - return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey); + return handleStreamingResponse(payload, accessToken, modelId, convKey); } interface ToolResultInfo { @@ -890,6 +1009,8 @@ function buildCursorRequest( } function parseConnectEndStream(data: Uint8Array): Error | null { + if (data.length === 0) return null; + try { const payload = JSON.parse(new TextDecoder().decode(data)); const error = payload?.error; @@ -996,11 +1117,71 @@ function createThinkingTagFilter(): { }; } -interface StreamState { +export interface StreamState { toolCallIndex: number; pendingExecs: PendingExec[]; outputTokens: number; totalTokens: number; + /** Semantic progress only; liveness and checkpoint traffic must not update it. */ + semanticProgressCount: number; + lastSemanticProgressAt: number; +} + +// The initial wait is deliberately longer: reasoning models may queue and +// plan for minutes before emitting their first token. Afterwards, ten +// minutes still accommodates extended thinking while bounding heartbeat-only +// stalls. Hard caps keep invalid environment values from disabling this guard. +const DEFAULT_FIRST_PROGRESS_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_SEMANTIC_PROGRESS_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_FIRST_PROGRESS_TIMEOUT_MS = 60 * 60 * 1000; +const MAX_SEMANTIC_PROGRESS_TIMEOUT_MS = 30 * 60 * 1000; + +function boundedDuration(name: string, fallback: number, maximum: number): number { + const value = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(value) && value > 0 ? Math.min(value, maximum) : fallback; +} + +class CursorResponseStallTimeoutError extends Error { + constructor(stage: "first_progress" | "semantic_progress") { + super(`Cursor response stalled waiting for ${stage.replace("_", " ")}`); + this.name = "CursorResponseStallTimeoutError"; + } +} + +function createSemanticProgressWatchdog( + state: StreamState, + onStall: (error: CursorResponseStallTimeoutError) => void, +): { observe: () => void; clear: () => void } { + const firstProgressTimeoutMs = boundedDuration( + "CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS", + DEFAULT_FIRST_PROGRESS_TIMEOUT_MS, + MAX_FIRST_PROGRESS_TIMEOUT_MS, + ); + const semanticProgressTimeoutMs = boundedDuration( + "CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS", + DEFAULT_SEMANTIC_PROGRESS_TIMEOUT_MS, + MAX_SEMANTIC_PROGRESS_TIMEOUT_MS, + ); + let observedProgress = state.semanticProgressCount; + let timer: ReturnType | undefined; + + const arm = (timeoutMs: number, stage: "first_progress" | "semantic_progress") => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => onStall(new CursorResponseStallTimeoutError(stage)), timeoutMs); + }; + + arm(firstProgressTimeoutMs, "first_progress"); + return { + observe() { + if (state.semanticProgressCount === observedProgress) return; + observedProgress = state.semanticProgressCount; + arm(semanticProgressTimeoutMs, "semantic_progress"); + }, + clear() { + if (timer) clearTimeout(timer); + timer = undefined; + }, + }; } function computeUsage(state: StreamState) { @@ -1010,7 +1191,7 @@ function computeUsage(state: StreamState) { return { prompt_tokens, completion_tokens, total_tokens }; } -function processServerMessage( +export function processServerMessage( msg: AgentServerMessage, blobStore: Map, mcpTools: McpToolDefinition[], @@ -1020,51 +1201,107 @@ function processServerMessage( onText: (text: string, isThinking?: boolean) => void, onMcpExec: (exec: PendingExec) => void, onCheckpoint?: (checkpointBytes: Uint8Array) => void, + onProtocolError?: (error: Error) => void, ): void { - const msgCase = msg.message.case; - - if (msgCase === "interactionUpdate") { - handleInteractionUpdate(msg.message.value, state, onText); - } else if (msgCase === "kvServerMessage") { - handleKvMessage(msg.message.value as KvServerMessage, blobStore, sendFrame); - } else if (msgCase === "execServerMessage") { - handleExecMessage( - msg.message.value as ExecServerMessage, - mcpTools, - cloudRule, - sendFrame, - onMcpExec, - ); - } else if (msgCase === "conversationCheckpointUpdate") { - const stateStructure = msg.message.value as ConversationStateStructure; - if (stateStructure.tokenDetails) { - state.totalTokens = stateStructure.tokenDetails.usedTokens; + const markSemanticProgress = () => { + state.semanticProgressCount += 1; + state.lastSemanticProgressAt = Date.now(); + }; + + switch (msg.message.case) { + case "interactionUpdate": + handleInteractionUpdate(msg.message.value, state, onText, markSemanticProgress); + return; + case "kvServerMessage": + handleKvMessage(msg.message.value, blobStore, sendFrame); + return; + case "execServerMessage": + markSemanticProgress(); + handleExecMessage(msg.message.value, mcpTools, cloudRule, sendFrame, onMcpExec); + return; + case "conversationCheckpointUpdate": { + const stateStructure = msg.message.value; + if (stateStructure.tokenDetails) state.totalTokens = stateStructure.tokenDetails.usedTokens; + if (onCheckpoint) onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure)); + return; } - if (onCheckpoint) { - onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure)); + case "execServerControlMessage": + // Cursor's abort control has no OpenAI-compatible acknowledgement path. + // Failing is safer than leaving the server waiting for an unsupported response. + onProtocolError?.(new Error(`Unsupported Cursor exec control message: ${msg.message.value.message.case ?? "unknown"}`)); + return; + case "interactionQuery": + // Query answers may require user interaction or Cursor-specific capabilities that + // this proxy cannot provide. Never silently leave the server waiting for one. + onProtocolError?.(new Error(`Unsupported Cursor interaction query: ${msg.message.value.query.case ?? "unknown"}`)); + return; + case undefined: + // Protobuf decoders represent empty or forward-compatible messages as an + // unset oneof. They carry no semantic progress and must not turn a valid + // stream into a protocol error; structurally invalid payloads still throw + // during decoding and are terminated by the caller. + return; + default: { + const unhandled: never = msg.message; + throw new Error(`Unhandled Cursor server message: ${String(unhandled)}`); } } } function handleInteractionUpdate( - update: any, + update: InteractionUpdate, state: StreamState, onText: (text: string, isThinking?: boolean) => void, + markSemanticProgress: () => void, ): void { - const updateCase = update.message?.case; - - if (updateCase === "textDelta") { - const delta = update.message.value.text || ""; - if (delta) onText(delta, false); - } else if (updateCase === "thinkingDelta") { - const delta = update.message.value.text || ""; - if (delta) onText(delta, true); - } else if (updateCase === "tokenDelta") { - state.outputTokens += update.message.value.tokens ?? 0; + switch (update.message.case) { + case "textDelta": { + const delta = update.message.value.text || ""; + if (delta) { + markSemanticProgress(); + onText(delta, false); + } + return; + } + case "thinkingDelta": { + const delta = update.message.value.text || ""; + if (delta) { + markSemanticProgress(); + onText(delta, true); + } + return; + } + case "tokenDelta": { + const tokens = update.message.value.tokens ?? 0; + state.outputTokens += tokens; + if (tokens > 0) markSemanticProgress(); + return; + } + // Tool calls are handled through execServerMessage, not InteractionUpdate. + case "partialToolCall": + case "toolCallDelta": + case "toolCallStarted": + case "toolCallCompleted": + // These update Cursor's internal transcript or UI but do not establish model progress. + case "thinkingCompleted": + case "userMessageAppended": + case "summary": + case "summaryStarted": + case "summaryCompleted": + case "shellOutputDelta": + case "stepStarted": + case "stepCompleted": + // Cursor's turnEnded sequencing is unproven, so only a Connect end-stream may terminate. + case "heartbeat": + case "turnEnded": + return; + case undefined: + return; + default: { + const unhandled: never = update.message; + throw new Error(`Unhandled Cursor interaction update: ${String(unhandled)}`); + } } - // toolCallStarted, partialToolCall, toolCallDelta, toolCallCompleted - // are intentionally ignored. MCP tool calls flow through the exec - // message path (mcpArgs → mcpResult), not interaction updates. } /** Send a KV client response back to Cursor. */ @@ -1315,14 +1552,23 @@ function sendExecResult( sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage))); } -/** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */ -function deriveBridgeKey(modelId: string, messages: OpenAIMessage[]): string { - const firstUserMsg = messages.find((m) => m.role === "user"); - const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : ""; - return createHash("sha256") - .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`) - .digest("hex") - .slice(0, 16); +function removeActiveBridge(active: ActiveBridge): void { + if (activeBridges.get(active.resumeId) === active) { + activeBridges.delete(active.resumeId); + } + for (const toolCallId of active.toolCallIds) { + if (activeBridgeToolCalls.get(toolCallId) === active.resumeId) { + activeBridgeToolCalls.delete(toolCallId); + } + } +} + +function findPausedBridge(toolResults: ToolResultInfo[]): ActiveBridge | undefined { + if (toolResults.length === 0 || toolResults.some((result) => !result.toolCallId)) return undefined; + const resumeIds = new Set(toolResults.map((result) => activeBridgeToolCalls.get(result.toolCallId))); + if (resumeIds.size !== 1) return undefined; + const resumeId = resumeIds.values().next().value; + return typeof resumeId === "string" ? activeBridges.get(resumeId) : undefined; } /** Derive a key for conversation state. Model-independent so context survives model switches. */ @@ -1358,18 +1604,22 @@ function createBridgeStreamResponse( mcpTools: McpToolDefinition[], cloudRule: string | undefined, modelId: string, - bridgeKey: string, convKey: string, ): Response { const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`; const created = Math.floor(Date.now() / 1000); + let cancelStream = () => {}; + // `cancel()` is invoked outside the stream source's `start()` callback. Keep + // this state in the shared response owner so an aborted client prevents all + // later terminal or model-output writes to its already-cancelled controller. + let closed = false; const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); - let closed = false; const sendSSE = (data: object) => { if (closed) return; + emitLifecycleDiagnostic("proxy", "sse_emission"); controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); }; const sendDone = () => { @@ -1379,6 +1629,7 @@ function createBridgeStreamResponse( const closeController = () => { if (closed) return; closed = true; + emitLifecycleDiagnostic("proxy", "terminal"); controller.close(); }; @@ -1410,13 +1661,53 @@ function createBridgeStreamResponse( pendingExecs: [], outputTokens: 0, totalTokens: 0, + semanticProgressCount: 0, + lastSemanticProgressAt: Date.now(), }; const tagFilter = createThinkingTagFilter(); let mcpExecReceived = false; + let connectEndStreamReceived = false; + let terminal = false; + let semanticWatchdog: ReturnType; + + const finishTerminal = (error?: Error) => { + if (terminal) return; + terminal = true; + semanticWatchdog.clear(); + clearInterval(heartbeatTimer); + for (const active of activeBridges.values()) { + if (active.bridge === bridge) removeActiveBridge(active); + } + + const flushed = tagFilter.flush(); + if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); + if (flushed.content) sendSSE(makeChunk({ content: flushed.content })); + if (error) sendSSE(makeChunk({ content: `\n[Error: ${error.message}]` })); + sendSSE(makeChunk({}, "stop")); + sendSSE(makeUsageChunk()); + sendDone(); + closeController(); + bridge.terminate(); + }; + + cancelStream = () => { + if (terminal) return; + terminal = true; + // The consumer owns the controller after cancellation. Do not call + // controller.close() or enqueue a terminal SSE frame here. + closed = true; + semanticWatchdog.clear(); + clearInterval(heartbeatTimer); + for (const active of activeBridges.values()) { + if (active.bridge === bridge) removeActiveBridge(active); + } + bridge.terminate(); + }; const processChunk = createConnectFrameParser( (messageBytes) => { + emitLifecycleDiagnostic("proxy", "connect_frame", { byteLength: messageBytes.length }); try { const serverMessage = fromBinary( AgentServerMessageSchema, @@ -1427,7 +1718,9 @@ function createBridgeStreamResponse( blobStore, mcpTools, cloudRule, - (data) => bridge.write(data), + (data) => { + if (!bridge.write(data)) throw new Error("Failed to write frame to Cursor bridge"); + }, state, (text, isThinking) => { if (isThinking) { @@ -1440,6 +1733,10 @@ function createBridgeStreamResponse( }, // onMcpExec — the model wants to execute a tool. (exec) => { + // A tool pause transfers control to the caller; it is not a + // model-output stall and must leave its live bridge resumable. + semanticWatchdog.clear(); + emitLifecycleDiagnostic("proxy", "tool_pause"); state.pendingExecs.push(exec); mcpExecReceived = true; @@ -1460,15 +1757,27 @@ function createBridgeStreamResponse( }], })); - // Keep the bridge alive for tool result continuation. - activeBridges.set(bridgeKey, { + // The server-issued tool-call ID identifies this paused + // interaction. Refuse a duplicate instead of overwriting an + // unrelated bridge and risking cross-conversation routing. + const existingResumeId = activeBridgeToolCalls.get(exec.toolCallId); + if (existingResumeId) { + finishTerminal(new Error("Cursor tool-call identity is already paused")); + return; + } + const resumeId = crypto.randomUUID(); + const active: ActiveBridge = { + resumeId, + toolCallIds: new Set([exec.toolCallId]), bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, pendingExecs: state.pendingExecs, - }); + }; + activeBridges.set(resumeId, active); + activeBridgeToolCalls.set(exec.toolCallId, resumeId); sendSSE(makeChunk({}, "tool_calls")); sendDone(); @@ -1485,35 +1794,46 @@ function createBridgeStreamResponse( persistConversation(convKey, stored); } }, + finishTerminal, ); - } catch { - // Skip unparseable messages + semanticWatchdog.observe(); + emitLifecycleDiagnostic("proxy", "message_dispatch", { + messageCase: serverMessage.message.case, + interactionCase: serverMessage.message.case === "interactionUpdate" + ? serverMessage.message.value.message?.case + : undefined, + }); + } catch (error) { + // A complete Connect message frame that cannot decode as an + // AgentServerMessage cannot be safely skipped: it may be the only + // terminal signal. Unknown fields remain protobuf-compatible; this + // path is reserved for structurally invalid payloads and writes. + finishTerminal(error instanceof Error + ? new Error(`Failed to process Cursor server message: ${error.message}`) + : new Error("Failed to process Cursor server message")); } }, (endStreamBytes) => { + connectEndStreamReceived = true; const endError = parseConnectEndStream(endStreamBytes); + emitLifecycleDiagnostic("proxy", "end_stream", { + byteLength: endStreamBytes.length, + status: endError ? "error" : "clean", + }); if (process.env.CURSOR_PROXY_DEBUG) { console.error(`[proxy] endStream: ${endError ? endError.message : "clean"}`); } - if (endError) { - // Surface the error and shut down: the server is done with this - // stream, and heartbeats would otherwise keep the bridge (and the - // SSE response) open forever. - sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` })); - sendSSE(makeChunk({}, "stop")); - sendSSE(makeUsageChunk()); - sendDone(); - closeController(); - activeBridges.delete(bridgeKey); - clearInterval(heartbeatTimer); - bridge.end(); - } + finishTerminal(endError ?? undefined); }, ); + semanticWatchdog = createSemanticProgressWatchdog(state, finishTerminal); + bridge.onData(processChunk); bridge.onClose((code) => { + emitLifecycleDiagnostic("proxy", "terminal", { exitCode: code }); + semanticWatchdog.clear(); clearInterval(heartbeatTimer); const stored = conversationStates.get(convKey); if (stored) { @@ -1521,27 +1841,32 @@ function createBridgeStreamResponse( stored.lastAccessMs = Date.now(); persistConversation(convKey, stored); } - if (!mcpExecReceived) { - const flushed = tagFilter.flush(); - if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); - if (flushed.content) sendSSE(makeChunk({ content: flushed.content })); - sendSSE(makeChunk({}, "stop")); - sendSSE(makeUsageChunk()); - sendDone(); - closeController(); - } else if (code !== 0) { - // Bridge died while tool calls are pending (timeout, crash, etc.). - // Close the SSE stream so the client doesn't hang forever. - sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" })); - sendSSE(makeChunk({}, "stop")); - sendSSE(makeUsageChunk()); - sendDone(); - closeController(); - // Remove stale entry so the next request doesn't try to resume it. - activeBridges.delete(bridgeKey); + if (terminal) return; + if (mcpExecReceived) { + // Tool pause deliberately owns neither terminal cleanup nor bridge + // shutdown. Once the retained bridge itself exits, it is stale on + // either exit code and must no longer be resumable. + for (const active of activeBridges.values()) { + if (active.bridge === bridge) removeActiveBridge(active); + } + return; + } + + if (!connectEndStreamReceived) { + finishTerminal(new Error( + code === 0 + ? "Connect stream ended without end-of-stream frame" + : `Bridge exited with code ${code} before Connect end-stream`, + )); + return; } + + finishTerminal(new Error(`Bridge exited with code ${code} after Connect end-stream`)); }); }, + cancel() { + cancelStream(); + }, }); return new Response(stream, { headers: SSE_HEADERS }); @@ -1555,6 +1880,7 @@ function startBridge( const bridge = spawnBridge({ accessToken, rpcPath: "/agent.v1.AgentService/Run", + diagnostic: lifecycleDiagnosticContextInfo(), }); bridge.write(frameConnectMessage(requestBytes)); const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000); @@ -1565,14 +1891,13 @@ function handleStreamingResponse( payload: CursorRequestPayload, accessToken: string, modelId: string, - bridgeKey: string, convKey: string, ): Response { const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes); return createBridgeStreamResponse( bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, payload.cloudRule, - modelId, bridgeKey, convKey, + modelId, convKey, ); } @@ -1582,10 +1907,10 @@ function handleToolResultResume( toolResults: ToolResultInfo[], userText: string, modelId: string, - bridgeKey: string, convKey: string, ): Response { const { bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, pendingExecs } = active; + emitLifecycleDiagnostic("proxy", "tool_resume", { toolCount: toolResults.length }); // Answer each pending exec with a matching tool result: redirected native // execs get their typed native result frame, MCP execs get an mcpResult. @@ -1652,11 +1977,17 @@ function handleToolResultResume( ); } - return createBridgeStreamResponse( + try { + return createBridgeStreamResponse( bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, - modelId, bridgeKey, convKey, - ); + modelId, convKey, + ); + } catch (error) { + clearInterval(heartbeatTimer); + bridge.terminate(); + throw error; + } } async function handleNonStreamingResponse( @@ -1698,8 +2029,9 @@ async function collectFullResponse( accessToken: string, convKey: string, ): Promise { - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve, reject } = Promise.withResolvers(); let fullText = ""; + let settled = false; const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes); @@ -1708,9 +2040,37 @@ async function collectFullResponse( pendingExecs: [], outputTokens: 0, totalTokens: 0, + semanticProgressCount: 0, + lastSemanticProgressAt: Date.now(), }; const tagFilter = createThinkingTagFilter(); + const persistState = () => { + const stored = conversationStates.get(convKey); + if (stored) { + for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v); + stored.lastAccessMs = Date.now(); + persistConversation(convKey, stored); + } + }; + let semanticWatchdog: ReturnType; + const finishCollection = (error?: Error) => { + if (settled) return; + settled = true; + semanticWatchdog.clear(); + clearInterval(heartbeatTimer); + persistState(); + if (error) { + bridge.terminate(); + reject(error); + return; + } + const flushed = tagFilter.flush(); + fullText += flushed.content; + bridge.terminate(); + resolve({ text: fullText, usage: computeUsage(state) }); + }; + bridge.onData(createConnectFrameParser( (messageBytes) => { try { @@ -1723,7 +2083,9 @@ async function collectFullResponse( payload.blobStore, payload.mcpTools, payload.cloudRule, - (data) => bridge.write(data), + (data) => { + if (!bridge.write(data)) throw new Error("Failed to write frame to Cursor bridge"); + }, state, (text, isThinking) => { if (isThinking) return; @@ -1740,30 +2102,35 @@ async function collectFullResponse( persistConversation(convKey, stored); } }, + (error) => { + finishCollection(error); + }, ); - } catch { - // Skip + semanticWatchdog.observe(); + } catch (error) { + // A complete Connect frame with an invalid protobuf payload cannot be + // safely ignored in unary mode either: it may contain the only + // terminal signal. Preserve the same explicit protocol failure that + // the streaming path emits. + finishCollection(error instanceof Error + ? new Error(`Failed to process Cursor server message: ${error.message}`) + : new Error("Failed to process Cursor server message")); } }, - () => {}, + (endStreamBytes) => { + const endError = parseConnectEndStream(endStreamBytes); + finishCollection(endError ?? undefined); + }, )); - bridge.onClose(() => { - clearInterval(heartbeatTimer); - const stored = conversationStates.get(convKey); - if (stored) { - for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v); - stored.lastAccessMs = Date.now(); - persistConversation(convKey, stored); - } - const flushed = tagFilter.flush(); - fullText += flushed.content; + semanticWatchdog = createSemanticProgressWatchdog(state, finishCollection); - const usage = computeUsage(state); - resolve({ - text: fullText, - usage, - }); + bridge.onClose((code) => { + finishCollection(new Error( + code === 0 + ? "Connect stream ended without end-of-stream frame" + : `Bridge exited with code ${code} before Connect end-stream`, + )); }); return promise; diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts new file mode 100644 index 0000000..962aa68 --- /dev/null +++ b/test/fixtures/fake-cursor-server.ts @@ -0,0 +1,403 @@ +import http2 from "node:http2"; +import type { AddressInfo } from "node:net"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { + AgentServerMessageSchema, + ConversationStateStructureSchema, + ExecServerMessageSchema, + HeartbeatUpdateSchema, + InteractionUpdateSchema, + McpArgsSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, +} from "../../src/proto/agent_pb"; + +const CONNECT_END_STREAM_FLAG = 0b00000010; + +export type CursorScenario = + | "clean-end-stream" + | "clean-end-stream-kept-open" + | "error-end-stream" + | "empty-end-stream" + | "missing-end-stream" + | "malformed-terminal" + | "malformed-server-message" + | "partial-frame" + | "slow-semantic-progress" + | "semantic-progress-stall" + | "semantic-progress-trickle" + | "heartbeat-trickle" + | "heartbeat-trickle-kept-open" + | "turn-ended-kept-open" + | "checkpoint-trickle" + | "abrupt-session-close" + | "frame-error" + | "goaway" + | "stream-aborted" + | "client-abort" + | "silent-transport" + | "transport-death" + | "tool-pause-resume" + | "tool-pause-transport-death" + | "tool-pause-clean-exit"; + +export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ + "clean-end-stream", + "clean-end-stream-kept-open", + "error-end-stream", + "empty-end-stream", + "missing-end-stream", + "malformed-terminal", + "malformed-server-message", + "partial-frame", + "slow-semantic-progress", + "semantic-progress-stall", + "semantic-progress-trickle", + "heartbeat-trickle", + "heartbeat-trickle-kept-open", + "turn-ended-kept-open", + "checkpoint-trickle", + "abrupt-session-close", + "frame-error", + "goaway", + "stream-aborted", + "client-abort", + "silent-transport", + "transport-death", + "tool-pause-resume", + "tool-pause-transport-death", + "tool-pause-clean-exit", +] as const; + +export interface FakeCursorServer { + apiUrl: string; + setScenario: (scenario: CursorScenario) => void; + assertClean: () => void; + close: () => Promise; + /** Test-only resource accounting hooks for proving cleanup assertions fire. */ + trackChild: (pid: number) => void; + untrackChild: (pid: number) => void; + trackBridgeEntry: (key: string) => void; + untrackBridgeEntry: (key: string) => void; +} + +/** Connect envelope: flags, four-byte big-endian payload size, then payload. */ +export function frameConnectMessage(payload: Uint8Array, flags = 0): Buffer { + const frame = Buffer.alloc(5 + payload.length); + frame[0] = flags; + frame.writeUInt32BE(payload.length, 1); + frame.set(payload, 5); + return frame; +} + +export function frameConnectEndStream(payload: Uint8Array): Buffer { + return frameConnectMessage(payload, CONNECT_END_STREAM_FLAG); +} + +/** Deliberately violates the advertised Connect payload length. */ +export function frameMalformedConnectMessage(payload: Uint8Array, advertisedLength: number): Buffer { + const frame = frameConnectMessage(payload); + frame.writeUInt32BE(advertisedLength, 1); + return frame; +} + +function serverMessage(message: Parameters[1]): Buffer { + return toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, message as never)); +} + +function textDelta(text: string): Buffer { + return serverMessage({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "textDelta", + value: create(TextDeltaUpdateSchema, { text }), + }, + }), + }, + }); +} + +function heartbeat(): Buffer { + return serverMessage({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "heartbeat", value: create(HeartbeatUpdateSchema, {}) }, + }), + }, + }); +} + +function turnEnded(): Buffer { + return serverMessage({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }), + }, + }); +} + +function checkpoint(): Buffer { + return serverMessage({ + message: { + case: "conversationCheckpointUpdate", + value: create(ConversationStateStructureSchema, {}), + }, + }); +} + +function toolPause(toolCallId: string): Buffer { + return serverMessage({ + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 1, + execId: `fixture-exec-${toolCallId}`, + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + name: "fixture-tool", + toolName: "fixture-tool", + toolCallId, + providerIdentifier: "fixture", + args: { input: new TextEncoder().encode("{}") }, + }), + }, + }), + }, + }); +} + +function isScenario(value: string): value is CursorScenario { + return (CURSOR_SCENARIOS as readonly string[]).includes(value); +} + +/** + * A local Connect-over-H2 server with bounded failure sequences. The request header + * x-fake-cursor-scenario selects a scenario; setScenario supplies the default. + */ +export async function createFakeCursorServer(options: { deadlineMs?: number } = {}): Promise { + const deadlineMs = options.deadlineMs ?? 100; + const server = http2.createServer(); + const sessions = new Set(); + const streams = new Set(); + const childPids = new Set(); + const bridgeEntries = new Set(); + let defaultScenario: CursorScenario = "clean-end-stream"; + let toolPauseCount = 0; + + server.on("session", (session) => { + sessions.add(session); + session.on("error", () => {}); + session.once("close", () => sessions.delete(session)); + }); + + server.on("stream", (stream, headers) => { + streams.add(stream); + stream.on("error", () => {}); + stream.once("close", () => streams.delete(stream)); + stream.resume(); + if (headers[":path"] !== "/agent.v1.AgentService/Run") { + stream.respond({ ":status": 404 }); + stream.end(); + return; + } + + const requested = String(headers["x-fake-cursor-scenario"] ?? defaultScenario); + const scenario = isScenario(requested) ? requested : defaultScenario; + let completed = false; + const timeout = setTimeout(() => { + if (!completed && !stream.closed && !stream.destroyed) { + stream.close(http2.constants.NGHTTP2_CANCEL); + } + }, deadlineMs); + const finish = () => { + completed = true; + clearTimeout(timeout); + }; + stream.once("close", finish); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + + if (scenario === "tool-pause-resume" || scenario === "tool-pause-transport-death" || scenario === "tool-pause-clean-exit") { + const toolCallId = `fixture-call-${++toolPauseCount}`; + let receivedInitialRequest = false; + let paused = false; + stream.on("data", (chunk) => { + if (!receivedInitialRequest) { + receivedInitialRequest = true; + paused = true; + stream.write(frameConnectMessage(toolPause(toolCallId))); + if (scenario === "tool-pause-transport-death") { + const session = stream.session; + setTimeout(() => session.destroy(), 10); + } else if (scenario === "tool-pause-clean-exit") { + setTimeout(() => stream.end(frameConnectEndStream(new TextEncoder().encode("{}"))), 10); + } + return; + } + if (!paused || stream.closed || stream.destroyed) return; + paused = false; + const receivedExpectedResult = Buffer.from(chunk).includes( + Buffer.from(`result-for-${toolCallId}`), + ); + stream.end(Buffer.concat([ + frameConnectMessage(textDelta(receivedExpectedResult ? "resumed-correctly" : "resumed-with-wrong-result")), + frameConnectEndStream(new TextEncoder().encode("{}")), + ])); + }); + return; + } + + const endWith = (body: Uint8Array) => stream.end(frameConnectEndStream(body)); + const trickle = (payload: Buffer) => { + let sent = 0; + const interval = setInterval(() => { + if (stream.closed || stream.destroyed || ++sent > 3) { + clearInterval(interval); + if (!stream.closed && !stream.destroyed) stream.end(); + return; + } + stream.write(frameConnectMessage(payload)); + }, 10); + }; + const trickleUntilClosed = (payload: Buffer) => { + const interval = setInterval(() => { + if (stream.closed || stream.destroyed) return clearInterval(interval); + stream.write(frameConnectMessage(payload)); + }, 10); + }; + + switch (scenario) { + case "clean-end-stream": + stream.end(Buffer.concat([frameConnectMessage(textDelta("complete")), frameConnectEndStream(new TextEncoder().encode("{}"))])); + break; + case "clean-end-stream-kept-open": + stream.write(frameConnectEndStream(new TextEncoder().encode("{}"))); + break; + case "error-end-stream": + endWith(new TextEncoder().encode(JSON.stringify({ error: { code: "unavailable", message: "fixture failure" } }))); + break; + case "empty-end-stream": + endWith(new Uint8Array()); + break; + case "missing-end-stream": + stream.end(frameConnectMessage(textDelta("truncated"))); + break; + case "malformed-terminal": + stream.end(frameMalformedConnectMessage(new TextEncoder().encode("{"), 9)); + break; + case "malformed-server-message": + stream.end(Buffer.concat([ + frameConnectMessage(new Uint8Array([0xff])), + frameConnectEndStream(new TextEncoder().encode("{}")), + ])); + break; + case "partial-frame": + stream.end(frameConnectMessage(textDelta("partial")).subarray(0, 7)); + break; + case "slow-semantic-progress": { + let part = 0; + const interval = setInterval(() => { + if (stream.closed || stream.destroyed) return clearInterval(interval); + if (part < 3) stream.write(frameConnectMessage(textDelta(`part-${++part}`))); + else { + clearInterval(interval); + endWith(new TextEncoder().encode("{}")); + } + }, 15); + break; + } + case "semantic-progress-stall": + setTimeout(() => { + if (!stream.closed && !stream.destroyed) { + stream.write(frameConnectMessage(textDelta("before-semantic-stall"))); + } + }, 10); + break; + case "semantic-progress-trickle": { + let part = 0; + const interval = setInterval(() => { + if (stream.closed || stream.destroyed) return clearInterval(interval); + if (part < 4) stream.write(frameConnectMessage(textDelta(`trickle-${++part}`))); + else { + clearInterval(interval); + endWith(new TextEncoder().encode("{}")); + } + }, 20); + break; + } + case "heartbeat-trickle": + trickle(heartbeat()); + break; + case "heartbeat-trickle-kept-open": + trickleUntilClosed(heartbeat()); + break; + case "turn-ended-kept-open": + stream.write(frameConnectMessage(turnEnded())); + break; + case "checkpoint-trickle": + trickle(checkpoint()); + break; + case "abrupt-session-close": + stream.write(frameConnectMessage(textDelta("before-close"))); + setTimeout(() => stream.session.destroy(), 10); + break; + case "frame-error": + stream.write(frameConnectMessage(textDelta("before-reset"))); + setTimeout(() => stream.close(http2.constants.NGHTTP2_PROTOCOL_ERROR), 10); + break; + case "goaway": + stream.write(frameConnectMessage(textDelta("before-goaway"))); + setTimeout(() => stream.session.goaway(http2.constants.NGHTTP2_NO_ERROR), 10); + break; + case "stream-aborted": + stream.write(frameConnectMessage(textDelta("before-abort"))); + setTimeout(() => stream.close(http2.constants.NGHTTP2_CANCEL), 10); + break; + case "client-abort": + // Send one frame, then intentionally remain open. The smoke test owns + // the consumer-side cancel and must cause this H2 stream to disappear. + stream.write(frameConnectMessage(textDelta("abort-ready"))); + break; + case "silent-transport": + // Keep the H2 connection open without emitting frames. Tests lower the + // client session bound while disabling the first PING to exercise its + // explicit timeout path without waiting for production timings. + break; + case "transport-death": + setTimeout(() => stream.session.destroy(), 10); + break; + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + return { + apiUrl: `http://127.0.0.1:${port}`, + setScenario(scenario) { defaultScenario = scenario; }, + trackChild(pid) { childPids.add(pid); }, + untrackChild(pid) { childPids.delete(pid); }, + trackBridgeEntry(key) { bridgeEntries.add(key); }, + untrackBridgeEntry(key) { bridgeEntries.delete(key); }, + assertClean() { + const leaks = [ + sessions.size && `${sessions.size} H2 session(s)`, + streams.size && `${streams.size} H2 stream(s)`, + childPids.size && `${childPids.size} child process(es)`, + bridgeEntries.size && `${bridgeEntries.size} bridge map entry(ies)`, + ].filter(Boolean); + if (leaks.length) throw new Error(`Fake Cursor fixture leaked ${leaks.join(", ")}`); + }, + async close() { + for (const session of sessions) session.destroy(); + await new Promise((resolve, reject) => + server.close((error) => error ? reject(error) : resolve()), + ); + this.assertClean(); + }, + }; +} diff --git a/test/smoke.ts b/test/smoke.ts index 53d948a..48afb39 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -3,9 +3,24 @@ import http2 from "node:http2"; import type { AddressInfo } from "node:net"; import { create, toBinary } from "@bufbuild/protobuf"; import { + AgentServerMessageSchema, + ConversationStateStructureSchema, + ExecServerControlMessageSchema, + GetBlobArgsSchema, GetUsableModelsResponseSchema, + HeartbeatUpdateSchema, + InteractionQuerySchema, + InteractionUpdateSchema, + KvServerMessageSchema, ModelDetailsSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, } from "../src/proto/agent_pb"; +import { + CURSOR_SCENARIOS, + createFakeCursorServer, + type CursorScenario, +} from "./fixtures/fake-cursor-server"; type DiscoveryMode = "success" | "empty" | "auth-error"; @@ -13,11 +28,13 @@ interface TestModules { startProxy: typeof import("../src/proxy").startProxy; stopProxy: typeof import("../src/proxy").stopProxy; getProxyPort: typeof import("../src/proxy").getProxyPort; + getActiveBridgeCount: typeof import("../src/proxy").getActiveBridgeCount; generateCursorAuthParams: typeof import("../src/auth").generateCursorAuthParams; getTokenExpiry: typeof import("../src/auth").getTokenExpiry; CursorAuthPlugin: typeof import("../src/index").CursorAuthPlugin; getCursorModels: typeof import("../src/models").getCursorModels; clearModelCache: typeof import("../src/models").clearModelCache; + processServerMessage: typeof import("../src/proxy").processServerMessage; } interface TestCursorBackend { @@ -68,6 +85,23 @@ function frameConnectUnaryMessage(payload: Uint8Array): Buffer { return frame; } +function frameConnectEndStream(payload: Uint8Array): Buffer { + const frame = frameConnectUnaryMessage(payload); + frame[0] = 0b00000010; + return frame; +} + +async function captureDiagnosticOutput(operation: () => Promise): Promise<{ result: T; lines: string[] }> { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => lines.push(args.map(String).join(" ")); + try { + return { result: await operation(), lines }; + } finally { + console.error = original; + } +} + async function createTestCursorBackend(): Promise { let discoveryMode: DiscoveryMode = "success"; let discoveredModels: Array<{ id: string; name: string; reasoning?: boolean }> = [ @@ -113,7 +147,10 @@ async function createTestCursorBackend(): Promise { ":status": 200, "content-type": "application/connect+proto", }); - stream.end(); + stream.end(Buffer.concat([ + frameConnectUnaryMessage(new Uint8Array()), + frameConnectEndStream(new TextEncoder().encode("{}")), + ])); return; } @@ -216,11 +253,13 @@ async function loadModules(): Promise { startProxy: proxy.startProxy, stopProxy: proxy.stopProxy, getProxyPort: proxy.getProxyPort, + getActiveBridgeCount: proxy.getActiveBridgeCount, generateCursorAuthParams: auth.generateCursorAuthParams, getTokenExpiry: auth.getTokenExpiry, CursorAuthPlugin: index.CursorAuthPlugin, getCursorModels: models.getCursorModels, clearModelCache: models.clearModelCache, + processServerMessage: proxy.processServerMessage, }; } @@ -395,11 +434,655 @@ async function testArrayContentParsing(modules: TestModules) { ); } } + assertEqual(res.status, 200, "Empty protobuf frames must be ignored without failing a non-streaming response"); modules.stopProxy(); console.log("[test] Array content parsing OK"); } +async function testNonStreamingProtocolErrorsAreStructured() { + console.log("[test] Testing non-streaming protocol error handling..."); + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + fixture.setScenario("malformed-server-message"); + try { + const result = await runScenarioSubprocess(fixture.apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: false, messages: [{ role: "user", content: "malformed non-streaming response" }] }), + }); + const body = await response.json(); + stopProxy(); + console.log(JSON.stringify({ status: response.status, body })); + process.exit(0); + `); + assertEqual(result.status, 500, "Non-streaming protocol errors must return a structured server error"); + const body = result.body as { error?: { message?: string } }; + assert( + body.error?.message?.includes("Failed to process Cursor server message"), + "Non-streaming protocol errors must preserve the terminal error message", + ); + } finally { + await fixture.close(); + } + console.log("[test] Non-streaming protocol errors return structured responses"); +} + +async function runFakeScenario(apiUrl: string, scenario: CursorScenario): Promise { + const session = http2.connect(apiUrl); + const stream = session.request({ + ":method": "POST", + ":path": "/agent.v1.AgentService/Run", + "x-fake-cursor-scenario": scenario, + }); + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + stream.close(http2.constants.NGHTTP2_CANCEL); + session.destroy(); + reject(new Error(`Fixture scenario exceeded its deadline: ${scenario}`)); + }, 500); + const finish = (error?: Error) => { + clearTimeout(timer); + session.destroy(); + if (error) reject(error); + else resolve(Buffer.concat(chunks)); + }; + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.once("end", () => finish()); + stream.once("close", () => finish()); + stream.once("error", () => finish()); + stream.end(); + }); +} + +async function testFakeCursorScenarioHarness() { + console.log("[test] Testing bounded fake Cursor scenario harness..."); + const fixture = await createFakeCursorServer({ deadlineMs: 100 }); + try { + for (const scenario of CURSOR_SCENARIOS) { + fixture.setScenario(scenario); + await runFakeScenario(fixture.apiUrl, scenario); + } + + fixture.trackChild(4242); + let caughtLeak = false; + try { + fixture.assertClean(); + } catch (error) { + caughtLeak = error instanceof Error && error.message.includes("child process"); + } + assert(caughtLeak, "Fixture cleanup assertion did not detect a deliberately leaked child"); + fixture.untrackChild(4242); + fixture.trackBridgeEntry("deliberate-leak"); + let caughtBridgeLeak = false; + try { + fixture.assertClean(); + } catch (error) { + caughtBridgeLeak = error instanceof Error && error.message.includes("bridge map entry"); + } + assert(caughtBridgeLeak, "Fixture cleanup assertion did not detect a deliberately leaked bridge entry"); + fixture.untrackBridgeEntry("deliberate-leak"); + await Bun.sleep(25); + fixture.assertClean(); + } finally { + await fixture.close(); + } + console.log(`[test] Fake Cursor scenarios selectable (${CURSOR_SCENARIOS.length}) and cleanup assertions OK`); +} + +interface SseObservation { + body: string; + timedOut: boolean; +} + +function doneCount(body: string): number { + return body.split("data: [DONE]").length - 1; +} + +async function observeProxyScenario( + scenario: CursorScenario, + deadlineMs = 125, + environment: Record = {}, +): Promise { + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + fixture.setScenario(scenario); + const childScript = ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "reproduce termination behavior" }] }), + }); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let body = ""; + const drain = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) return { body, timedOut: false }; + body += decoder.decode(value, { stream: true }); + } + })(); + const result = await Promise.race([ + drain, + new Promise((resolve) => setTimeout(() => resolve({ body, timedOut: true }), ${deadlineMs})), + ]); + void reader.cancel().catch(() => {}); + stopProxy(); + console.log(JSON.stringify(result)); + `; + const child = Bun.spawn({ + cmd: [process.execPath, "-e", childScript], + cwd: process.cwd(), + env: { ...process.env, CURSOR_API_URL: fixture.apiUrl, ...environment }, + stdout: "pipe", + stderr: "pipe", + }); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assertEqual(exitCode, 0, `Scenario subprocess failed (${scenario}): ${stderr}`); + return JSON.parse(stdout.trim()) as SseObservation; + } finally { + await fixture.close(); + } +} + +async function testStreamingTermination() { + console.log("[test] Testing streaming termination behavior..."); + + const cleanEnd = await observeProxyScenario("clean-end-stream-kept-open"); + assert(!cleanEnd.timedOut && cleanEnd.body.includes("data: [DONE]"), "Clean end-stream must finish in the local H2 fixture"); + assertEqual(doneCount(cleanEnd.body), 1, "Clean end-stream must emit exactly one [DONE]"); + console.log("[test] Valid end-stream does not reproduce as an open SSE: bridge exit cascades to [DONE]"); + + const errorEnd = await observeProxyScenario("error-end-stream"); + assert(errorEnd.body.includes("Connect error unavailable: fixture failure"), "Connect end-stream errors must preserve their code and message"); + assertEqual(doneCount(errorEnd.body), 1, "Connect end-stream errors must emit exactly one [DONE]"); + console.log("[test] Connect end-stream errors propagate exactly once"); + + const emptyEnd = await observeProxyScenario("empty-end-stream"); + assert(!emptyEnd.timedOut, "Empty end-stream must terminate"); + assert(!emptyEnd.body.includes("[Error:"), "Empty end-stream must not emit an error"); + assertEqual(doneCount(emptyEnd.body), 1, "Empty end-stream must emit exactly one [DONE]"); + console.log("[test] Empty end-stream completes cleanly exactly once"); + + const missingEnd = await observeProxyScenario("missing-end-stream"); + assert(missingEnd.body.includes("[Error:"), "Missing end-stream must be surfaced as an error"); + assertEqual(doneCount(missingEnd.body), 1, "Missing end-stream must emit exactly one [DONE]"); + console.log("[test] Missing end-stream is an explicit error exactly once"); + + const bridgeExit = await observeProxyScenario("frame-error"); + assert(bridgeExit.body.includes("[Error:"), "Bridge exit must be surfaced as an error"); + assertEqual(doneCount(bridgeExit.body), 1, "Bridge exit must emit exactly one [DONE]"); + console.log("[test] Bridge exit is an explicit error exactly once"); + + const malformedTerminal = await observeProxyScenario("malformed-terminal"); + assert(malformedTerminal.body.includes("[Error:"), "Malformed terminal frame must be surfaced as an error"); + assertEqual(doneCount(malformedTerminal.body), 1, "Malformed terminal frame must emit exactly one [DONE]"); + console.log("[test] Malformed terminal frame is an explicit error exactly once"); + + const malformedMessage = await observeProxyScenario("malformed-server-message"); + assert(malformedMessage.body.includes("Failed to process Cursor server message"), "Malformed protobuf payloads must reach the terminal error owner"); + assertEqual(doneCount(malformedMessage.body), 1, "Malformed protobuf payloads must emit exactly one [DONE]"); + console.log("[test] Malformed protobuf payloads terminate explicitly exactly once"); + + const abruptClose = await observeProxyScenario("abrupt-session-close"); + assert(abruptClose.body.includes("[Error:"), "Abrupt HTTP/2 closure must be surfaced as an error"); + assertEqual(doneCount(abruptClose.body), 1, "Abrupt HTTP/2 closure must emit exactly one [DONE]"); + console.log("[test] Abrupt HTTP/2 closure is an explicit error exactly once"); + + const frameError = await observeProxyScenario("frame-error"); + assert(frameError.body.includes("[Error:"), "HTTP/2 frame errors must be surfaced as an error"); + assertEqual(doneCount(frameError.body), 1, "HTTP/2 frame errors must emit exactly one [DONE]"); + console.log("[test] HTTP/2 frame errors are explicit exactly once"); + + const goaway = await observeProxyScenario("goaway"); + assert(goaway.body.includes("[Error:"), "HTTP/2 GOAWAY must be surfaced as an error"); + assertEqual(doneCount(goaway.body), 1, "HTTP/2 GOAWAY must emit exactly one [DONE]"); + console.log("[test] HTTP/2 GOAWAY is explicit exactly once"); + + const aborted = await observeProxyScenario("stream-aborted"); + assert(aborted.body.includes("[Error:"), "Aborted HTTP/2 streams must be surfaced as an error"); + assertEqual(doneCount(aborted.body), 1, "Aborted HTTP/2 streams must emit exactly one [DONE]"); + console.log("[test] Aborted HTTP/2 streams are explicit exactly once"); + + const sessionTimeout = await observeProxyScenario("silent-transport", 250, { + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "25", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(sessionTimeout.body.includes("[Error:"), "HTTP/2 session timeout must be surfaced as an error"); + assertEqual(doneCount(sessionTimeout.body), 1, "HTTP/2 session timeout must emit exactly one [DONE]"); + console.log("[test] HTTP/2 session timeout is explicit exactly once"); + + const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "25", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + }); + assert(!heartbeatTrickle.timedOut, "Heartbeat-only traffic must be bounded by semantic progress, not fixture closure"); + assert(heartbeatTrickle.body.includes("[Error: Cursor response stalled waiting for first progress]"), "Heartbeat-only traffic must reach an explicit semantic-stall error"); + assertEqual(doneCount(heartbeatTrickle.body), 1, "Heartbeat-only semantic stalls must emit exactly one [DONE]"); + console.log("[test] Heartbeat-only traffic is bounded by semantic progress exactly once"); + + const firstProgressStall = await observeProxyScenario("silent-transport", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "25", + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "1000", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(!firstProgressStall.timedOut, "First-progress stalls must be bounded before transport liveness expires"); + assert(firstProgressStall.body.includes("[Error: Cursor response stalled waiting for first progress]"), "First-progress stalls must surface an explicit error"); + assertEqual(doneCount(firstProgressStall.body), 1, "First-progress stalls must emit exactly one [DONE]"); + console.log("[test] First-progress stalls are bounded independently of transport liveness"); + + const turnEnded = await observeProxyScenario("turn-ended-kept-open"); + assert(turnEnded.body.includes("[Error:"), "turnEnded transport closure must not become a clean success"); + assertEqual(doneCount(turnEnded.body), 1, "turnEnded transport closure must emit exactly one [DONE]"); + console.log("[test] turnEnded transport closure is an explicit error exactly once"); + + const slowProgress = await observeProxyScenario("slow-semantic-progress", 500); + assert(!slowProgress.timedOut, "Slow semantic progress exceeded its bounded completion window"); + assert(!slowProgress.body.includes("[Error:"), "Slow semantic progress must not emit an error"); + for (const part of ["part-1", "part-2", "part-3"]) { + assert(slowProgress.body.includes(part), `Slow semantic progress lost ${part}`); + } + assert(slowProgress.body.includes("data: [DONE]"), "Slow semantic progress must finish normally"); + console.log("[test] Slow semantic progress survives bounded streaming completion"); + + const semanticStall = await observeProxyScenario("semantic-progress-stall", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "1000", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "1000", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(!semanticStall.timedOut, "Post-progress stalls must be bounded before the observation deadline"); + assert(semanticStall.body.includes("[Error: Cursor response stalled waiting for semantic progress]"), "Post-progress stalls must use the between-progress bound, not the first-progress bound"); + assertEqual(doneCount(semanticStall.body), 1, "Post-progress semantic stalls must emit exactly one [DONE]"); + console.log("[test] Post-progress silence uses the semantic-progress stall bound"); + + const semanticTrickle = await observeProxyScenario("semantic-progress-trickle", 500, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "1000", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + }); + assert(!semanticTrickle.timedOut, "Slow semantic progress must survive each re-armed between-progress bound"); + assert(!semanticTrickle.body.includes("[Error:"), "Re-armed semantic progress must not emit a stall error"); + assert(semanticTrickle.body.includes("trickle-4"), "Re-armed semantic progress must preserve the final delta"); + assertEqual(doneCount(semanticTrickle.body), 1, "Re-armed semantic progress must emit exactly one [DONE]"); + console.log("[test] Repeated semantic progress re-arms the between-progress bound"); +} + +async function testClientAbortTeardown() { + console.log("[test] Testing client-abort stream teardown..."); + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + fixture.setScenario("client-abort"); + try { + const result = await runScenarioSubprocess(fixture.apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "cancel this response" }] }), + }); + const reader = response.body.getReader(); + let text = ""; + while (!text.includes("abort-ready")) { + const next = await reader.read(); + if (next.done) break; + text += new TextDecoder().decode(next.value, { stream: true }); + } + await reader.cancel(); + await Bun.sleep(25); + stopProxy(); + console.log(JSON.stringify({ received: text.includes("abort-ready") })); + `); + assertEqual(result.received, true, "Client-abort fixture must deliver output before cancellation"); + fixture.assertClean(); + } finally { + await fixture.close(); + } + console.log("[test] Client abort closes the controller path and tears down bridge resources"); +} + +async function testServerMessageClassification(modules: TestModules) { + console.log("[test] Testing server-message progress and unsupported-query classification..."); + const state = { + toolCallIndex: 0, + pendingExecs: [], + outputTokens: 0, + totalTokens: 0, + semanticProgressCount: 0, + lastSemanticProgressAt: 0, + }; + const dispatch = ( + message: Parameters[1], + onProtocolError?: (error: Error) => void, + sendFrame: (data: Uint8Array) => void = () => {}, + ) => + modules.processServerMessage( + create(AgentServerMessageSchema, message as never), + new Map(), [], undefined, sendFrame, state, () => {}, () => {}, undefined, onProtocolError, + ); + + dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { + message: { case: "heartbeat", value: create(HeartbeatUpdateSchema, {}) }, + }) } }); + dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }) } }); + dispatch({ message: { case: "conversationCheckpointUpdate", value: create(ConversationStateStructureSchema, {}) } }); + assertEqual(state.semanticProgressCount, 0, "Heartbeat, turnEnded, and checkpoints must not count as semantic progress"); + + dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { + message: { case: "textDelta", value: create(TextDeltaUpdateSchema, { text: "progress" }) }, + }) } }); + assertEqual(state.semanticProgressCount, 1, "Text output must count as semantic progress"); + + let unsupportedError: Error | undefined; + dispatch({ message: { case: "interactionQuery", value: create(InteractionQuerySchema, { + id: 1, + query: { case: "webSearchRequestQuery", value: {} }, + }) } }, (error) => { unsupportedError = error; }); + assert(unsupportedError?.message.includes("Unsupported Cursor interaction query"), "Unsupported blocking queries must trigger an explicit terminal error"); + unsupportedError = undefined; + dispatch({ message: { case: "execServerControlMessage", value: create(ExecServerControlMessageSchema, { + message: { case: "abort", value: { id: 1 } }, + }) } }, (error) => { unsupportedError = error; }); + assert(unsupportedError?.message.includes("Unsupported Cursor exec control message"), "Unsupported control messages must trigger an explicit terminal error"); + + let writeFailure: Error | undefined; + try { + dispatch({ message: { case: "kvServerMessage", value: create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId: new Uint8Array([1]) }) }, + }) } }, undefined, () => { throw new Error("bridge write rejected"); }); + } catch (error) { + writeFailure = error instanceof Error ? error : new Error(String(error)); + } + assertEqual(writeFailure?.message, "bridge write rejected", "Bridge write failures must propagate to the terminal owner"); + const { sendNativeExecResult } = await import("../src/native-tools"); + let nativeWriteFailure: Error | undefined; + try { + sendNativeExecResult( + { execId: "dead-bridge", execMsgId: 1 }, + { resultType: "readResult", args: { path: "/tmp/never-written" } }, + "unreachable", + () => false, + ); + } catch (error) { + nativeWriteFailure = error instanceof Error ? error : new Error(String(error)); + } + assertEqual( + nativeWriteFailure?.message, + "Failed to write native tool result to Cursor bridge", + "A dead bridge must explicitly reject native tool-result writes", + ); + console.log("[test] Liveness traffic is non-progress and unsupported queries terminate explicitly"); +} + +async function runScenarioSubprocess( + apiUrl: string, + script: string, +): Promise> { + const child = Bun.spawn({ + cmd: [process.execPath, "-e", script], + cwd: process.cwd(), + env: { ...process.env, CURSOR_API_URL: apiUrl }, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assertEqual(exitCode, 0, `Scenario subprocess failed: ${stderr}`); + return JSON.parse(stdout.trim()) as Record; +} + +async function observeToolResume( + apiUrl: string, + scenario: "tool-pause-resume" | "tool-pause-transport-death", + collision = false, +): Promise> { + return runScenarioSubprocess(apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const base = "http://localhost:" + port + "/v1/chat/completions"; + const opening = "resume fixture collision-safe opening"; + const request = (messages) => fetch(base, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: true, messages, tools: [{ type: "function", function: { name: "fixture-tool", parameters: { type: "object" } } }] }), + }); + const boundedText = async (response, deadlineMs = 150) => { + const reader = response.body.getReader(); let text = ""; + const drain = (async () => { while (true) { const { done, value } = await reader.read(); if (done) return { text, timedOut: false }; text += new TextDecoder().decode(value, { stream: true }); } })(); + const result = await Promise.race([drain, new Promise(resolve => setTimeout(() => resolve({ text, timedOut: true }), deadlineMs))]); + void reader.cancel().catch(() => {}); return result; + }; + const first = await boundedText(await request([{ role: "user", content: opening }])); + const firstCall = JSON.parse(first.text.match(/data: (.+)\\n\\n/)?.[1] ?? "{}").choices?.[0]?.delta?.tool_calls?.[0]?.id; + if ("${scenario}" === "tool-pause-transport-death") await Bun.sleep(30); + let second; let secondCall; + if (${collision}) { + second = await boundedText(await request([{ role: "user", content: opening }])); + secondCall = JSON.parse(second.text.match(/data: (.+)\\n\\n/)?.[1] ?? "{}").choices?.[0]?.delta?.tool_calls?.[0]?.id; + } + const resumed = await boundedText(await request([ + { role: "user", content: opening }, + { role: "assistant", content: null, tool_calls: [{ id: firstCall, type: "function", function: { name: "fixture-tool", arguments: "{}" } }] }, + { role: "tool", tool_call_id: firstCall, content: "result-for-" + firstCall }, + ])); + stopProxy(); + console.log(JSON.stringify({ first, firstCall, second, secondCall, resumed })); + process.exit(0); + `); +} + +async function observeStalledRefresh(): Promise> { + return runScenarioSubprocess("http://127.0.0.1:1", ` + import http from "node:http"; + const server = http.createServer((_, res) => { void res; }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + process.env.CURSOR_REFRESH_URL = "http://127.0.0.1:" + port + "/auth/exchange_user_api_key"; + process.env.CURSOR_REFRESH_TIMEOUT_MS = "25"; + const { refreshCursorToken } = await import("./src/auth.ts"); + const { startProxy, stopProxy } = await import("./src/proxy.ts"); + const proxyPort = await startProxy(async () => (await refreshCursorToken("stalled-refresh")).access); + const pending = fetch("http://localhost:" + proxyPort + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "refresh stall" }] }) }); + const result = await Promise.race([pending.then(async response => ({ headersReturned: true, status: response.status, body: await response.json() })), new Promise(resolve => setTimeout(() => resolve({ headersReturned: false }), 125))]); + stopProxy(); server.close(); console.log(JSON.stringify(result)); process.exit(0); + `); +} + +async function observeSuccessfulRefresh(): Promise> { + return runScenarioSubprocess("http://127.0.0.1:1", ` + import http from "node:http"; + const server = http.createServer((req, res) => { + if (req.headers.authorization !== "Bearer valid-refresh") throw new Error("unexpected refresh authorization"); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ accessToken: "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDAwMDAwMDB9.fakesig", refreshToken: "rotated-refresh" })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + process.env.CURSOR_REFRESH_URL = "http://127.0.0.1:" + server.address().port + "/auth/exchange_user_api_key"; + process.env.CURSOR_REFRESH_TIMEOUT_MS = "25"; + const { refreshCursorToken } = await import("./src/auth.ts"); + const refreshed = await refreshCursorToken("valid-refresh"); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + console.log(JSON.stringify({ + accessMatches: refreshed.access === "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDAwMDAwMDB9.fakesig", + refreshMatches: refreshed.refresh === "rotated-refresh", + })); + `); +} + +async function testResumeRefreshAndNonStreaming(modules: TestModules) { + console.log("[test] Testing resume, refresh, and collection behavior..."); + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + try { + fixture.setScenario("tool-pause-resume"); + const liveResume = await observeToolResume(fixture.apiUrl, "tool-pause-resume"); + assert( + String((liveResume.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), + "Healthy tool resume must preserve the matching tool result", + ); + console.log("[test] Healthy tool resume preserves the paused bridge and result"); + + fixture.setScenario("tool-pause-transport-death"); + const deadResume = await observeToolResume(fixture.apiUrl, "tool-pause-transport-death"); + assert( + String((deadResume.resumed as { text?: string }).text ?? "").includes('"finish_reason":"tool_calls"'), + "A dead bridge must fall back to a new request instead of resuming the old stream", + ); + console.log("[test] Transport death exits the child; resume follows the fresh-request fallback"); + + fixture.setScenario("tool-pause-resume"); + const collision = await observeToolResume(fixture.apiUrl, "tool-pause-resume", true); + assert( + collision.secondCall !== undefined && collision.secondCall !== collision.firstCall, + "Identical-opening conversations must receive distinct paused tool-call identities", + ); + assert( + String((collision.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), + "An identical-opening conversation must resume its own paused bridge", + ); + assert( + !String((collision.resumed as { text?: string }).text ?? "").includes("resumed-with-wrong-result"), + "A tool result must never resume a different identical-opening conversation", + ); + console.log("[test] Identical-opening conversations retain isolated paused bridges"); + + fixture.setScenario("tool-pause-transport-death"); + const droppedWrite = await observeToolResume(fixture.apiUrl, "tool-pause-transport-death"); + assert( + String((droppedWrite.resumed as { text?: string }).text ?? "").includes('"finish_reason":"tool_calls"'), + "A closed transport must not accept a dropped resume write as a live bridge", + ); + assert( + !String((droppedWrite.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), + "A dead bridge must reject its pending result rather than accepting a stale write", + ); + console.log("[test] Closed-stream bridge rejects stale writes and resumes through a fresh request"); + + fixture.setScenario("tool-pause-clean-exit"); + const port = await modules.startProxy(async () => "test-token"); + const paused = await fetch(`http://localhost:${port}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "test", stream: true, + messages: [{ role: "user", content: "paused bridge cleanup" }], + tools: [{ type: "function", function: { name: "fixture-tool", parameters: { type: "object" } } }], + }), + }); + await paused.text(); + await Bun.sleep(40); + assertEqual(modules.getActiveBridgeCount(), 0, "A clean terminal path must remove its paused bridge entry"); + modules.stopProxy(); + console.log("[test] Clean paused-bridge exit removes its active bridge entry"); + } finally { + await fixture.close(); + } + + const refresh = await observeStalledRefresh(); + assert(refresh.headersReturned === true, "Refresh stall must not leave the request pre-header"); + assertEqual(refresh.status, 504, "Refresh timeout must return Gateway Timeout"); + const refreshError = refresh.body as { error?: { message?: string; type?: string; code?: string } }; + assertEqual(refreshError.error?.type, "gateway_timeout", "Refresh timeout must be a structured timeout error"); + assertEqual(refreshError.error?.code, "token_refresh_timeout", "Refresh timeout must identify the refresh boundary"); + assert(!JSON.stringify(refreshError).includes("stalled-refresh"), "Refresh timeout errors must not expose refresh credentials"); + console.log("[test] Stalled token refresh returns a bounded, redacted structured error"); + + const successfulRefresh = await observeSuccessfulRefresh(); + assertEqual(successfulRefresh.accessMatches, true, "Successful refresh must preserve the access token"); + assertEqual(successfulRefresh.refreshMatches, true, "Successful refresh must preserve a rotated refresh token"); + console.log("[test] Successful token refresh completes within the configured bound"); + + const collectionFixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + try { + collectionFixture.setScenario("heartbeat-trickle-kept-open"); + const nonStreaming = await runScenarioSubprocess(collectionFixture.apiUrl, ` + process.env.CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS = "25"; + process.env.CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS = "25"; + const { startProxy, stopProxy } = await import("./src/proxy.ts"); + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: false, messages: [{ role: "user", content: "non-streaming stall" }] }) }); + const body = await response.json(); stopProxy(); console.log(JSON.stringify({ headersReturned: true, status: response.status, body })); process.exit(0); + `); + assert(nonStreaming.headersReturned === true, "Non-streaming collection must not wait forever for bridge close"); + assertEqual(nonStreaming.status, 504, "Non-streaming semantic stalls must return Gateway Timeout"); + const nonStreamingError = nonStreaming.body as { error?: { type?: string; code?: string } }; + assertEqual(nonStreamingError.error?.type, "gateway_timeout", "Non-streaming stalls must be structured timeout errors"); + assertEqual(nonStreamingError.error?.code, "response_stall_timeout", "Non-streaming stalls must identify the response boundary"); + console.log("[test] Non-streaming collection returns a bounded structured stall error"); + } finally { + await collectionFixture.close(); + } +} + +async function testLifecycleDiagnostics(modules: TestModules) { + console.log("[test] Testing redacted lifecycle diagnostics..."); + const originalSetting = process.env.CURSOR_PROXY_DIAGNOSTICS; + const fakeToken = "fake-access-token-DO-NOT-LOG"; + const secretPrompt = "prompt-content-DO-NOT-LOG"; + const secretToolPayload = "tool-payload-DO-NOT-LOG"; + const request = { + model: "test", + stream: true, + messages: [{ role: "user", content: secretPrompt }], + tools: [{ + type: "function", + function: { name: "example", parameters: { type: "object", example: secretToolPayload } }, + }], + }; + const runRequest = async () => { + const port = await modules.startProxy(async () => fakeToken); + const response = await fetch(`http://localhost:${port}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${fakeToken}` }, + body: JSON.stringify(request), + }); + await response.text(); + await Bun.sleep(150); + modules.stopProxy(); + }; + + try { + delete process.env.CURSOR_PROXY_DIAGNOSTICS; + const disabled = await captureDiagnosticOutput(runRequest); + assertEqual(disabled.lines.length, 0, "Diagnostics must be silent when disabled"); + + process.env.CURSOR_PROXY_DIAGNOSTICS = "1"; + const enabled = await captureDiagnosticOutput(runRequest); + const events = enabled.lines.map((line) => JSON.parse(line) as Record); + const stages = new Set(events.map((event) => event.stage)); + for (const stage of [ + "proxy_entry", "auth_access_start", "auth_access_complete", "bridge_spawn", + "bridge_start", "h2_connect", "connect_stream", "bridge_data", "connect_frame", + "message_dispatch", "sse_emission", "terminal", + ]) { + assert(stages.has(stage), `Missing diagnostic lifecycle stage: ${stage}`); + } + const requestIds = new Set(events.map((event) => event.requestId)); + assertEqual(requestIds.size, 1, "Expected one shared diagnostic correlation ID"); + assert(events.every((event) => typeof event.elapsedMs === "number"), "Expected monotonic diagnostic timing"); + const output = enabled.lines.join("\n"); + for (const secret of [fakeToken, secretPrompt, secretToolPayload]) { + assert(!output.includes(secret), `Diagnostic output leaked secret: ${secret}`); + } + } finally { + if (originalSetting === undefined) delete process.env.CURSOR_PROXY_DIAGNOSTICS; + else process.env.CURSOR_PROXY_DIAGNOSTICS = originalSetting; + modules.stopProxy(); + } + console.log("[test] Redacted lifecycle diagnostics OK"); +} + async function testExpiredTokenRefreshBeforeDiscovery( modules: TestModules, backend: TestCursorBackend, @@ -531,20 +1214,36 @@ async function main() { const modules = await loadModules(); + let unhandledRejection: unknown; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejection = reason; + }; + process.on("unhandledRejection", onUnhandledRejection); + try { await testProxyStartStop(modules); await testAuthParams(modules); await testTokenExpiry(modules); await testPluginShape(modules); await testArrayContentParsing(modules); + await testNonStreamingProtocolErrorsAreStructured(); + await testFakeCursorScenarioHarness(); + await testStreamingTermination(); + await testClientAbortTeardown(); + await testServerMessageClassification(modules); + await testResumeRefreshAndNonStreaming(modules); + await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); + await Bun.sleep(0); + assert(!unhandledRejection, `Unhandled rejection escaped the smoke harness: ${String(unhandledRejection)}`); console.log("\n✓ All smoke tests passed"); process.exitCode = 0; } catch (err) { console.error("\n✗ Smoke test failed:", err); process.exitCode = 1; } finally { + process.off("unhandledRejection", onUnhandledRejection); modules.stopProxy(); await backend.close(); }