From 818f550e9dcb03241ebb67be283d4f096d473cde Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sat, 1 Aug 2026 06:57:14 +0000 Subject: [PATCH] bound Cursor Connect frame buffering --- src/adapters/cursor/framing.ts | 61 +++++++++++++++++++++++++ src/adapters/cursor/live-transport.ts | 24 ++++++---- structure/04_transports-and-sidecars.md | 13 ++++++ tests/cursor-framing.test.ts | 28 ++++++++++++ tests/cursor-hardening.test.ts | 28 ++++++++++++ 5 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/adapters/cursor/framing.ts b/src/adapters/cursor/framing.ts index 3b1f494bf..82fece818 100644 --- a/src/adapters/cursor/framing.ts +++ b/src/adapters/cursor/framing.ts @@ -1,7 +1,11 @@ export const CONNECT_FRAME_HEADER_BYTES = 5; export const CONNECT_FLAG_COMPRESSED = 0x01; export const CONNECT_FLAG_END_STREAM = 0x02; +/** Outbound wire encoding remains uint32-compatible; inbound buffering is intentionally tighter. */ export const MAX_CONNECT_FRAME_PAYLOAD_BYTES = 0xffffffff; +export const MAX_INBOUND_CONNECT_FRAME_PAYLOAD_BYTES = 32 * 1024 * 1024; +export const MAX_INBOUND_CONNECT_FRAME_BYTES = + CONNECT_FRAME_HEADER_BYTES + MAX_INBOUND_CONNECT_FRAME_PAYLOAD_BYTES; export type ConnectFrameErrorCode = | "invalid_offset" @@ -72,6 +76,9 @@ export function tryDecodeConnectFrame(input: Uint8Array, offset = 0): DecodedCon const view = new DataView(input.buffer, input.byteOffset + offset, input.byteLength - offset); const flags = view.getUint8(0); const length = view.getUint32(1, false); + if (length > MAX_INBOUND_CONNECT_FRAME_PAYLOAD_BYTES) { + throw new ConnectFrameError("payload_too_large", `Inbound Connect frame payload too large: ${length}`); + } const readBytes = CONNECT_FRAME_HEADER_BYTES + length; if (input.length - offset < readBytes) return null; @@ -123,6 +130,53 @@ export function decodeAvailableConnectFrames(input: Uint8Array): DecodedConnectF }; } +/** + * Incrementally decode a transport chunk without concatenating bytes beyond one bounded frame. + * `pending` must be the incomplete remainder returned by an earlier call. + */ +export function decodeConnectStreamChunk( + pending: Uint8Array, + chunk: Uint8Array, +): DecodedConnectFrames { + if (pending.length === 0) return decodeAvailableConnectFrames(chunk); + if (pending.length > MAX_INBOUND_CONNECT_FRAME_BYTES) { + throw new ConnectFrameError("payload_too_large", "Inbound Connect pending frame exceeded its byte limit"); + } + + let carry = pending; + let rest = chunk; + if (carry.length < CONNECT_FRAME_HEADER_BYTES) { + const headerBytes = Math.min(CONNECT_FRAME_HEADER_BYTES - carry.length, rest.length); + carry = concatBytes(carry, rest.subarray(0, headerBytes)); + rest = rest.subarray(headerBytes); + if (carry.length < CONNECT_FRAME_HEADER_BYTES) return { frames: [], remainder: carry }; + } + + // The header-only decode validates the declared length before any payload accumulation. + const view = new DataView(carry.buffer, carry.byteOffset, carry.byteLength); + const payloadBytes = view.getUint32(1, false); + if (payloadBytes > MAX_INBOUND_CONNECT_FRAME_PAYLOAD_BYTES) { + throw new ConnectFrameError("payload_too_large", `Inbound Connect frame payload too large: ${payloadBytes}`); + } + const frameBytes = CONNECT_FRAME_HEADER_BYTES + payloadBytes; + const needed = frameBytes - carry.length; + if (needed < 0) { + throw new ConnectFrameError("frame_incomplete", "Invalid Connect pending-frame boundary"); + } + if (rest.length < needed) { + const remainder = concatBytes(carry, rest); + if (remainder.length > MAX_INBOUND_CONNECT_FRAME_BYTES) { + throw new ConnectFrameError("payload_too_large", "Inbound Connect pending frame exceeded its byte limit"); + } + return { frames: [], remainder }; + } + + const completed = concatBytes(carry, rest.subarray(0, needed)); + const first = decodeConnectFrame(completed).frame; + const following = decodeAvailableConnectFrames(rest.subarray(needed)); + return { frames: [first, ...following.frames], remainder: following.remainder }; +} + function assertOffset(input: Uint8Array, offset: number): void { if (!Number.isInteger(offset) || offset < 0 || offset > input.length) { throw new ConnectFrameError("invalid_offset", `Invalid Connect frame offset: ${offset}`); @@ -134,3 +188,10 @@ function assertByte(value: number, code: ConnectFrameErrorCode, message: string) throw new ConnectFrameError(code, message); } } + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a); + out.set(b, a.length); + return out; +} diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 78ac830ce..fd8bb2453 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1,7 +1,12 @@ import http2 from "node:http2"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { namespacedToolName, type OcxProviderConfig, type OcxUsage } from "../../types"; -import { CONNECT_FLAG_END_STREAM, decodeAvailableConnectFrames, encodeConnectFrame } from "./framing"; +import { + CONNECT_FLAG_END_STREAM, + ConnectFrameError, + decodeConnectStreamChunk, + encodeConnectFrame, +} from "./framing"; import { activePromptText, prepareCursorRunRequest } from "./protobuf-request"; import { createCursorContextUsageTracker, @@ -733,9 +738,8 @@ class LiveCursorTransport implements CursorTransport { debugProviderDiagnostic("cursor", "first-frame", { latencyMs: this.firstFrameAt - this.turnStartedAt }); } const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); - pending = concatBytes(pending, bytes); try { - const decoded = decodeAvailableConnectFrames(pending); + const decoded = decodeConnectStreamChunk(pending, bytes); pending = decoded.remainder; const frames = decoded.frames; for (const frame of frames) { @@ -792,6 +796,13 @@ class LiveCursorTransport implements CursorTransport { expectedClose: this.expectedClose, elapsedMs: Date.now() - this.turnStartedAt, }); + if (pending.length > 0 && !this.expectedClose) { + settler.settleFail(new ConnectFrameError( + "frame_incomplete", + `Cursor stream ended with ${pending.length} byte(s) of an incomplete Connect frame`, + )); + return; + } // A zero-frame end without an expected close is an unexpected EOF (peer dropped the // connection before any response frame) — surfacing it as success would silently // swallow the turn (WP4 review blocker 1). With frames, the protobuf event state @@ -978,13 +989,6 @@ export function isClientToolFrame(message: AgentServerMessage): boolean { } } -function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length); - out.set(a); - out.set(b, a.length); - return out; -} - /** Host-only label for Cursor transport diagnostics — never leaks path/query/credentials. */ function cursorHostLabel(baseUrl: string): string { try { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 5a39c59e2..26b306a6f 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -302,6 +302,19 @@ pre-compaction checkpoint is not persisted for later carry-forward. ## OpenRouter provider routing +Cursor's Connect decoder keeps the uint32 outbound wire format, but inbound frames are limited to a +32 MiB payload. The decoder rejects an oversized declaration as soon as the five-byte header is +available, carries at most one incomplete bounded frame between HTTP/2 chunks, and treats a nonempty +remainder at EOF as a transport failure rather than a successful turn. + +[Decision Log] +- 목적과 의도: Prevent a malformed or hostile Cursor stream from driving unbounded pending-buffer growth and repeated whole-buffer copies. +- 기존 구현 및 제약 조건: The Connect length field permits nearly 4 GiB, and the live transport concatenated every chunk until a full declared frame arrived; incomplete EOF bytes were silently discarded. +- 검토한 주요 대안: Keep the uint32 maximum in both directions; cap only the accumulated buffer after concatenation; reject oversized headers before payload accumulation and decode chunks incrementally. +- 선택한 방식: Preserve outbound compatibility while limiting inbound payloads to 32 MiB and carrying only one incomplete frame across chunks. +- 다른 대안 대신 이 방식을 선택한 이유: Header-time rejection avoids allocating the advertised payload, while incremental completion avoids copying a large remainder together with unrelated following frames. +- 장점, 단점 및 영향: Normal split and multi-frame streams retain byte order; a legitimate Cursor server response over 32 MiB now fails explicitly instead of consuming unbounded memory. + The canonical OpenRouter `openai-chat` transport may carry optional provider-routing preferences from `OcxProviderConfig.openRouterRouting`, with exact model-id replacements in `modelOpenRouterRouting`. The adapter maps camel-case config to OpenRouter's request wire diff --git a/tests/cursor-framing.test.ts b/tests/cursor-framing.test.ts index 5ca6860e7..162a4d7d9 100644 --- a/tests/cursor-framing.test.ts +++ b/tests/cursor-framing.test.ts @@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test"; import { CONNECT_FLAG_COMPRESSED, CONNECT_FLAG_END_STREAM, + MAX_INBOUND_CONNECT_FRAME_PAYLOAD_BYTES, ConnectFrameError, decodeAvailableConnectFrames, decodeConnectFrame, decodeConnectFrames, + decodeConnectStreamChunk, encodeConnectFrame, isConnectFrameCompressed, isConnectFrameEndStream, @@ -100,6 +102,32 @@ describe("Cursor Connect envelope framing", () => { expect(Array.from(decoded.remainder)).toEqual(Array.from(partial)); }); + test("rejects an oversized declared inbound payload from the five-byte header alone", () => { + const declared = MAX_INBOUND_CONNECT_FRAME_PAYLOAD_BYTES + 1; + const header = new Uint8Array(5); + new DataView(header.buffer).setUint32(1, declared, false); + + expectFrameError(() => decodeAvailableConnectFrames(header), "payload_too_large"); + }); + + test("stream decoder preserves split headers and payloads without changing frame order", () => { + const first = encodeConnectFrame(bytes(0x01, 0x02, 0x03)); + const second = encodeConnectFrame(bytes(0x04), { endStream: true }); + const combined = new Uint8Array(first.length + second.length); + combined.set(first); + combined.set(second, first.length); + + const headerPart = decodeConnectStreamChunk(new Uint8Array(), combined.subarray(0, 3)); + expect(headerPart.frames).toEqual([]); + const payloadPart = decodeConnectStreamChunk(headerPart.remainder, combined.subarray(3, 7)); + expect(payloadPart.frames).toEqual([]); + const completed = decodeConnectStreamChunk(payloadPart.remainder, combined.subarray(7)); + + expect(completed.remainder.length).toBe(0); + expect(completed.frames.map(frame => Array.from(frame.payload))).toEqual([[0x01, 0x02, 0x03], [0x04]]); + expect(completed.frames.map(frame => frame.endStream)).toEqual([false, true]); + }); + test("throws payload_too_large before allocating oversized frames", () => { const huge = { length: 2 ** 32 } as Uint8Array; diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 51d1dc6af..173d564bc 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -345,5 +345,33 @@ describe("Cursor live transport unexpected EOF", () => { expect(failure?.message).toContain("unexpected EOF"); }); }); + + test("a trailing partial Connect frame fails instead of being silently discarded", async () => { + await withDiscoveryServer(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(Uint8Array.of(0x00, 0x00, 0x00)); + }, async baseUrl => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + firstFrameTimeoutMs: 2_000, + }); + let failure: Error | undefined; + try { + for await (const _message of transport.run({ + modelId: "composer-2", + conversationId: "cursor_partial_eof_test", + system: [], + messages: [{ role: "user", content: "hello" }], + })) { /* no complete message expected */ } + } catch (err) { + failure = err instanceof Error ? err : new Error(String(err)); + } finally { + await transport.close?.(); + } + expect(failure).toBeDefined(); + expect(failure?.message).toContain("incomplete Connect frame"); + }); + }); }); import { ManagementRequest as Request } from "./helpers/management-auth";