Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/adapters/cursor/framing.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`);
Expand All @@ -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;
}
24 changes: 14 additions & 10 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/cursor-framing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
28 changes: 28 additions & 0 deletions tests/cursor-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading