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
56 changes: 52 additions & 4 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
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,
Expand Down Expand Up @@ -315,6 +316,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
? new CursorRoutingCommentarySniffer()
: undefined;
let guardHeld: AdapterEvent[] = [];
let guardHeldBytes = 0;
const guardEncoder = new TextEncoder();
// Exactly-once observation: every client-bound text delta passes through here
// exactly once — held deltas only on release, ordinary deltas at emit time.
const emitTextObserved = (event: AdapterEvent): void => {
Expand All @@ -327,7 +330,49 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
emitTextObserved(held);
}
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.
guardHeldBytes += guardEncoder.encode(JSON.stringify(event)).byteLength;
if (guardHeldBytes <= CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) return true;
echoSniffer?.finish();
routingCommentarySniffer?.finish();
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);
Expand Down Expand Up @@ -368,27 +413,30 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
}
if (!guardsSettled()) {
if (event.type === "text_delta") {
guardHeld.push(event);
// 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") {
// Reasoning before first text stays ordered; liveness still passes through.
if (event.type === "thinking_delta") {
guardHeld.push(event);
holdGuardEvent(event);
continue;
}
} else {
Expand Down
74 changes: 49 additions & 25 deletions src/adapters/cursor/envelope-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const MAX_MIDSTREAM_SCAN_LENGTH = 512 * 1024;
const MAX_MIDSTREAM_FINDINGS = 8;
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 {
Expand Down Expand Up @@ -130,16 +130,22 @@ export class CursorMidstreamEchoObserver {
private totalLength = 0;
private disarmed = false;
private lineDisarmed = false;
private corruptionWatch: { finding: MidstreamEchoFinding; remaining: number; window: string } | undefined;
private readonly corruptionWatches: Array<{
finding: MidstreamEchoFinding;
remaining: number;
window: string;
}> = [];
private readonly recorded: MidstreamEchoFinding[] = [];

feed(textDelta: string): void {
if (this.disarmed && !this.corruptionWatch) return;
if (this.disarmed && this.corruptionWatches.length === 0) return;
let index = 0;
while (index < textDelta.length) {
const newline = textDelta.indexOf("\n", index);
const segment = newline === -1 ? textDelta.slice(index) : textDelta.slice(index, newline);
if (this.corruptionWatch) this.watchCorruption(segment + (newline === -1 ? "" : "\n"));
if (this.corruptionWatches.length > 0) {
this.watchCorruption(segment + (newline === -1 ? "" : "\n"));
}
if (!this.disarmed && !this.lineDisarmed && segment.length > 0) {
this.lineBuffer += segment;
if (this.lineBuffer.length > MAX_MIDSTREAM_LINE_INDENT + 32) {
Expand All @@ -160,9 +166,7 @@ export class CursorMidstreamEchoObserver {
}

findings(): readonly MidstreamEchoFinding[] {
if (this.corruptionWatch) {
this.settleCorruption();
}
while (this.corruptionWatches.length > 0) this.settleCorruption(0);
return this.recorded;
}

Expand All @@ -187,12 +191,24 @@ export class CursorMidstreamEchoObserver {
this.lineDisarmed = true;
return;
}
const finding: MidstreamEchoFinding = {
marker,
offset: this.lineStartOffset,
callIdCorrupt: false,
};
this.corruptionWatch = { finding, remaining: MIDSTREAM_CORRUPTION_WINDOW, window: "" };
// A new marker ends the previous marker's corruption window: the text
// between two markers belongs to the earlier finding only. Without this,
// every open watch consumed the same following text, so one corrupt
// call-id after a second marker also marked the first, clean finding
// corrupt (clean-then-corrupt cross-contamination).
while (this.corruptionWatches.length > 0) this.settleCorruption(0);
if (this.recorded.length + this.corruptionWatches.length < MAX_MIDSTREAM_FINDINGS) {
const finding: MidstreamEchoFinding = {
marker,
offset: this.lineStartOffset,
callIdCorrupt: false,
};
this.corruptionWatches.push({
finding,
remaining: MIDSTREAM_CORRUPTION_WINDOW,
window: "",
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.lineDisarmed = true;
return;
}
Expand All @@ -203,24 +219,28 @@ export class CursorMidstreamEchoObserver {
}

private watchCorruption(text: string): void {
const watch = this.corruptionWatch;
if (!watch) return;
const take = Math.min(watch.remaining, text.length);
watch.window += text.slice(0, take);
watch.remaining -= take;
if (watch.remaining <= 0) this.settleCorruption();
for (const watch of this.corruptionWatches) {
const take = Math.min(watch.remaining, text.length);
watch.window += text.slice(0, take);
watch.remaining -= take;
}
let index = 0;
while (index < this.corruptionWatches.length) {
if (this.corruptionWatches[index]!.remaining <= 0) this.settleCorruption(index);
else index += 1;
}
}

private settleCorruption(): void {
const watch = this.corruptionWatch;
private settleCorruption(index: number): void {
const watch = this.corruptionWatches[index];
if (!watch) return;
const window = watch.window;
watch.finding.callIdCorrupt =
/fc_[0-9a-f]+[ \t]+mar-/.test(window)
|| /call_id: \S+[ \t]+\S+_0\b/.test(window);
if (this.recorded.length < MAX_MIDSTREAM_FINDINGS) this.recorded.push(watch.finding);
this.recorded.push(watch.finding);
// Window text is discarded here; only booleans/offsets survive.
this.corruptionWatch = undefined;
this.corruptionWatches.splice(index, 1);
}
}

Expand Down Expand Up @@ -251,7 +271,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;
Expand Down Expand Up @@ -316,7 +340,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)
) {
Expand Down
9 changes: 9 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading