From 327635678ea76927335de577a11ad4b0c0c6d8b6 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 11:18:14 +0900 Subject: [PATCH 1/3] fix(cursor): bound output guard reasoning quarantine --- src/adapters/cursor.ts | 19 +++++++++++++-- src/adapters/cursor/envelope-echo.ts | 10 +++++--- tests/cursor-envelope-echo-retry.test.ts | 31 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 6761a3d194f..a02f596a0a5 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -31,6 +31,7 @@ import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions"; import { + CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES, CURSOR_ECHO_RETRY_CONTINUATION_TEXT, CURSOR_ROUTING_COMMENTARY_RETRY_TEXT, CursorEnvelopeEchoSniffer, @@ -242,12 +243,26 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ? new CursorRoutingCommentarySniffer() : undefined; let guardHeld: AdapterEvent[] = []; + let guardHeldBytes = 0; + const guardEncoder = new TextEncoder(); const releaseGuardHeld = () => { for (const held of guardHeld) { if (held.type !== "heartbeat") emittedOutput = true; emit(held); } guardHeld = []; + guardHeldBytes = 0; + }; + const holdGuardEvent = (event: AdapterEvent) => { + guardHeld.push(event); + // Count the complete retained representation, including per-event overhead, so an + // upstream cannot evade the cap with empty or non-text reasoning frames. + guardHeldBytes += guardEncoder.encode(JSON.stringify(event)).byteLength; + if (guardHeldBytes <= CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) return true; + echoSniffer?.finish(); + routingCommentarySniffer?.finish(); + releaseGuardHeld(); + return false; }; const guardsSettled = () => (!echoSniffer || echoSniffer.settled) @@ -286,7 +301,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda for (const event of events) { if (!guardsSettled()) { if (event.type === "text_delta") { - guardHeld.push(event); + if (!holdGuardEvent(event)) continue; if (echoSniffer && !echoSniffer.settled) { const decision = echoSniffer.feed(event.text); if (decision.kind === "echo") { @@ -306,7 +321,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } else if (event.type === "thinking_delta" || event.type === "heartbeat") { // Reasoning before first text stays ordered; liveness still passes through. if (event.type === "thinking_delta") { - guardHeld.push(event); + holdGuardEvent(event); continue; } } else { diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index ac7ec429c34..dd78d1a9e08 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -16,7 +16,7 @@ const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const const MAX_SNIFF_BYTES = 40; const MAX_ROUTING_COMMENTARY_BYTES = 512; /** Aggregate quarantine cap: past this, flush and disarm. */ -const MAX_HOLD_BYTES = 8 * 1024; +export const CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES = 8 * 1024; const encoder = new TextEncoder(); export class CursorToolResultEchoError extends Error { @@ -70,7 +70,11 @@ export class CursorEnvelopeEchoSniffer { const stillPrefix = ECHO_MARKERS.some(marker => probe.length < marker.length && marker.startsWith(probe), ); - if (stillPrefix && this.byteCount <= MAX_SNIFF_BYTES && this.buffered.length < MAX_HOLD_BYTES) { + if ( + stillPrefix + && this.byteCount <= MAX_SNIFF_BYTES + && this.buffered.length < CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES + ) { return { kind: "hold" }; } this.done = true; @@ -129,7 +133,7 @@ export class CursorRoutingCommentarySniffer { && lineBreakCount < 2; if ( this.byteCount < MAX_ROUTING_COMMENTARY_BYTES - && this.buffered.length < MAX_HOLD_BYTES + && this.buffered.length < CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES && (lineBreakCount === 0 || pendingFailureClaim) && (hasRoutingHint || this.byteCount < 64) ) { diff --git a/tests/cursor-envelope-echo-retry.test.ts b/tests/cursor-envelope-echo-retry.test.ts index 0cba08740b8..12580b83312 100644 --- a/tests/cursor-envelope-echo-retry.test.ts +++ b/tests/cursor-envelope-echo-retry.test.ts @@ -154,6 +154,37 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(text).toBe("[note] leading bracket but not an envelope"); }); + test("reasoning-only quarantine is capped and disarms before unbounded retention", async () => { + let attempt = 0; + const factory = () => ({ + async *run() { + attempt += 1; + for (let i = 0; i < 100; i += 1) { + yield { type: "thinking", thinking: "x".repeat(128) } satisfies CursorServerMessage; + } + // Once the aggregate hold cap flushes, later marker-like text is ordinary output rather + // than evidence for a retry whose preceding reasoning has already reached the client. + yield { type: "text", text: ECHO_TEXT } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter( + { ...provider, apiKey: "cursor-token" }, + { createTransport: factory as never }, + ); + const events: AdapterEvent[] = []; + await adapter.runTurn?.( + toolResultBody("cursor/kimi-k3"), + { headers: new Headers() }, + event => events.push(event), + ); + + expect(attempt).toBe(1); + expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(100); + expect(events.filter(event => event.type === "text_delta")).not.toHaveLength(0); + }); + test("plain user turns (no trailing toolResult) never arm the sniffer", async () => { let attempt = 0; const factory = () => ({ From c0bc4950eb9a4408fae0566a85226cbc7693c56e Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 21 Sep 2026 02:31:00 +0000 Subject: [PATCH 2/3] ci: retrigger checks (empty commit; dev merge conflicts) From 1aae049cb275171c3493b02d0dac5e3b2567ec9f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:42:30 +0000 Subject: [PATCH 3/3] fix(cursor): bound output-quarantine memory and classify before the cap Two Devin Review findings on the turn-start output quarantine: - A single oversized event (Cursor accepts frames up to 16 MiB) was fully retained and re-encoded before the 8 KiB cap released it. The retained size is now projected from payload length before any serialized copy exists; a frame that cannot fit settles the sniffers, releases the held events, and passes through directly. - The aggregate cap was checked before the armed sniffers saw the text, so a >8 KiB first delta could bypass echo/routing classification entirely. Text deltas are now classified first (on the bounded leading window each sniffer needs) and only then subject to the cap. Adds regression tests for oversized single deltas carrying each guarded pattern and one oversized reasoning frame, and documents the aggregate limit/disarm contract in structure/providers/cursor.md. Co-Authored-By: Epinephrine --- src/adapters/cursor.ts | 39 +++++++- structure/providers/cursor.md | 9 ++ .../cursor/cursor-envelope-echo-retry.test.ts | 96 +++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index e13870cb64c..5cfef1b8ada 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -332,7 +332,29 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda guardHeld = []; guardHeldBytes = 0; }; + // A single frame can carry a multi-megabyte payload (the transport accepts up to the + // 16 MiB Cursor message bound), so the serialized size is projected — object overhead + // plus raw payload length — BEFORE any encoded copy exists. Escapes only inflate the + // exact figure, making the raw length a safe lower bound for the overflow decision. + const GUARD_EVENT_OVERHEAD_BYTES = 64; + const projectedGuardEventBytes = (event: AdapterEvent): number => + GUARD_EVENT_OVERHEAD_BYTES + + (event.type === "text_delta" + ? Buffer.byteLength(event.text, "utf8") + : event.type === "thinking_delta" + ? Buffer.byteLength(event.thinking, "utf8") + : 0); const holdGuardEvent = (event: AdapterEvent) => { + if (guardHeldBytes + projectedGuardEventBytes(event) > CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) { + // Too large to retain even unescaped: settle the sniffers, release what was held, + // and pass this event through without ever encoding it. + echoSniffer?.finish(); + routingCommentarySniffer?.finish(); + releaseGuardHeld(); + if (event.type !== "heartbeat") emittedOutput = true; + emitTextObserved(event); + return false; + } guardHeld.push(event); // Count the complete retained representation, including per-event overhead, so an // upstream cannot evade the cap with empty or non-text reasoning frames. @@ -343,6 +365,14 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda releaseGuardHeld(); return false; }; + // Each sniffer settles from a bounded leading window (40 B / 512 B respectively), so + // feeding an oversized delta whole would retain megabytes it never inspects. The + // bounded prefix still covers every decision path — including marker prefixes and + // routing claims — while the tail falls through to the aggregate cap. + const ECHO_SNIFF_FEED_MAX_CHARS = 512; + const ROUTING_SNIFF_FEED_MAX_CHARS = 2048; + const boundedSniffText = (text: string, maxChars: number): string => + text.length > maxChars ? text.slice(0, maxChars) : text; const guardsSettled = () => (!echoSniffer || echoSniffer.settled) && (!routingCommentarySniffer || routingCommentarySniffer.settled); @@ -383,21 +413,24 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } if (!guardsSettled()) { if (event.type === "text_delta") { - if (!holdGuardEvent(event)) continue; + // Classify the delta before the aggregate-cap check: an oversized first + // delta must still pass the armed sniffers (echo/hallucination detection is + // prefix-based), so the cap cannot disarm them before they see the text. if (echoSniffer && !echoSniffer.settled) { - const decision = echoSniffer.feed(event.text); + const decision = echoSniffer.feed(boundedSniffText(event.text, ECHO_SNIFF_FEED_MAX_CHARS)); if (decision.kind === "echo") { guardHeld = []; throw new CursorToolResultEchoError(decision.marker); } } if (routingCommentarySniffer && !routingCommentarySniffer.settled) { - const decision = routingCommentarySniffer.feed(event.text); + const decision = routingCommentarySniffer.feed(boundedSniffText(event.text, ROUTING_SNIFF_FEED_MAX_CHARS)); if (decision.kind === "hallucination") { guardHeld = []; throw new CursorRoutingCommentaryError(); } } + if (!holdGuardEvent(event)) continue; if (guardsSettled()) releaseGuardHeld(); continue; } else if (event.type === "thinking_delta" || event.type === "heartbeat") { diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 7252130843a..ce21712e910 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -192,6 +192,15 @@ Translated Chat request construction uses the [inline-image budget](../transport ## Mid-stream envelope echo +Held quarantine output is bounded by the aggregate `CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES` (8 KiB) +budget in `src/adapters/cursor.ts`. Text deltas are fed to the armed echo and +routing-commentary sniffers BEFORE the cap check, so a single oversized first delta cannot +disarm the guards without being classified; each sniffer reads only the bounded leading window +its decision needs. Retained bytes are projected from payload length before any serialized +copy exists, so a multi-megabyte frame cannot force a same-size encoded allocation. An event +that cannot fit the remaining budget settles both sniffers, releases the held events, and is +emitted directly. + The prefix sniffer only watches the opening bytes of a turn. An external model that writes real prose first and then pastes a replayed `[Tool Result]` envelope defeats it, so that text reaches the client and is stored as assistant output. `CursorMidstreamEchoObserver` records those diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index dcd444ba299..88eaa515f03 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -344,6 +344,102 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(events.filter(event => event.type === "text_delta")).not.toHaveLength(0); }); + test("an oversized first text delta is still classified by the echo sniffer", async () => { + // One text delta larger than the aggregate hold cap whose leading bytes are the + // echoed envelope marker. + let attempt = 0; + const runRequests: CursorRunRequest[] = []; + const oversizedFactory = () => ({ + async *run(request: CursorRunRequest) { + runRequests.push(request); + attempt += 1; + if (attempt === 1) { + yield { type: "text", text: ECHO_TEXT + "x".repeat(32 * 1024) } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + return; + } + yield { type: "text", text: "STATE A17" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: oversizedFactory as never }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(toolResultBody("cursor/kimi-k3"), { headers: new Headers() }, event => events.push(event)); + expect(attempt).toBe(2); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("STATE A17"); + }); + + test("an oversized first text delta is still classified by the routing sniffer", async () => { + let attempt = 0; + const factory = () => ({ + async *run() { + attempt += 1; + if (attempt === 1) { + // Routing claim padded past the 8 KiB aggregate cap in a single delta. + yield { + type: "text", + text: "네이티브 셸은 차단됐으니 exec_command 경로로 읽겠습니다. " + "x".repeat(32 * 1024), + } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + return; + } + yield { type: "text", text: "READ_OK" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const body = { + modelId: "cursor/kimi-k3-1m", + context: { + messages: [{ role: "user", content: "Read the file and report its first line.", timestamp: 1 }], + tools: [{ + name: "exec", + description: "Run JavaScript code to orchestrate nested tool calls.", + parameters: {}, + freeform: true, + }], + }, + stream: false, + options: {}, + _cursorConversationId: "cursor_routing_oversized", + _cursorIdentityScope: "acct-routing-commentary", + } as OcxParsedRequest; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(attempt).toBe(2); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("READ_OK"); + }); + + test("a single reasoning frame larger than the cap flushes without unbounded retention", async () => { + let attempt = 0; + const bigThinking = "y".repeat(64 * 1024); + const factory = () => ({ + async *run() { + attempt += 1; + yield { type: "thinking", thinking: bigThinking } satisfies CursorServerMessage; + yield { type: "text", text: "post-thought answer" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.( + toolResultBody("cursor/kimi-k3"), + { headers: new Headers() }, + event => events.push(event), + ); + expect(attempt).toBe(1); + const thinking = events.filter(e => e.type === "thinking_delta").map(e => (e as { thinking: string }).thinking).join(""); + expect(thinking).toBe(bigThinking); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("post-thought answer"); + }); + test("plain user turns (no trailing toolResult) never arm the sniffer", async () => { let attempt = 0; const factory = () => ({