Skip to content
Open
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
70 changes: 70 additions & 0 deletions packages/core/src/lib/stdin-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,76 @@ describe("StdinParser", () => {
}
})

test("paste at exactly maxPasteBytes is emitted unchanged", () => {
const p = createParser({ maxPasteBytes: 4 })
try {
p.push(Buffer.from("\x1b[200~abcd\x1b[201~"))
expect(snap(p)).toEqual([paste("abcd")])
} finally {
p.destroy()
}
})

test("paste exceeding maxPasteBytes is dropped entirely", () => {
const p = createParser({ maxPasteBytes: 4 })
try {
p.push(Buffer.from("\x1b[200~abcde\x1b[201~"))
expect(snap(p)).toEqual([])
} finally {
p.destroy()
}
})

test("oversized paste body does not leak key/mouse/response events", () => {
const p = createParser({ maxPasteBytes: 4 })
try {
p.push(Buffer.from("\x1b[200~"))
p.push(Buffer.from("x".repeat(64 * 1024)))
p.push(Buffer.from("\x1b[201~"))
expect(snap(p)).toEqual([])
} finally {
p.destroy()
}
})

test("oversized paste recovers normal input after the end marker", () => {
const p = createParser({ maxPasteBytes: 4 })
try {
p.push(Buffer.from("\x1b[200~"))
p.push(Buffer.from("x".repeat(1000)))
p.push(Buffer.from("\x1b[201~"))
expect(snap(p)).toEqual([])
p.push(Buffer.from("z"))
expect(snap(p)).toEqual([k("z")])
} finally {
p.destroy()
}
})

test("oversized paste recognizes an end marker split across the size boundary", () => {
const p = createParser({ maxPasteBytes: 4 })
try {
p.push(Buffer.from("\x1b[200~abcdef"))
p.push(Buffer.from("g\x1b[20"))
p.push(Buffer.from("1~z"))
expect(snap(p)).toEqual([k("z")])
} finally {
p.destroy()
}
})

test("oversized paste split across many small chunks is still dropped", () => {
const p = createParser({ maxPasteBytes: 4 })
try {
p.push(Buffer.from("\x1b[200~"))
for (let i = 0; i < 1000; i++) p.push(Buffer.from("x"))
p.push(Buffer.from("\x1b[201~"))
expect(snap(p)).toEqual([])
} finally {
p.destroy()
}
})

test("trailing bytes after paste end are parsed normally", () => {
const p = createParser()
try {
Expand Down
60 changes: 54 additions & 6 deletions packages/core/src/lib/stdin-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export interface StdinParserProtocolContext {
export interface StdinParserOptions {
timeoutMs?: number
maxPendingBytes?: number
// Maximum bytes retained for one bracketed paste. A paste whose body would
// exceed this is dropped in full when its end marker arrives (no truncated
// event, no leak of body bytes into key/mouse/response events), while the
// parser keeps scanning for the end marker to recover normal input. This is
// a memory bound only; a missing end marker has no automatic recovery.
maxPasteBytes?: number
armTimeouts?: boolean
onTimeoutFlush?: () => void
useKittyKeyboard?: boolean
Expand Down Expand Up @@ -101,17 +107,31 @@ type ParserState =
// Collects paste body incrementally, bypassing the main ByteQueue so large
// pastes don't grow the parser buffer. Keeps only a small tail for end-marker
// detection across chunk boundaries.
//
// Retention is bounded by maxPasteBytes. Once the accumulated body would
// exceed the limit, overLimit is set and the body stops being retained: the
// parser keeps scanning for the end marker (using only the small tail) but
// drops the whole paste when it completes, so oversized bytes never become
// key/mouse/response events and retained memory stays bounded.
interface PasteCollector {
tail: Uint8Array
parts: Uint8Array[]
totalLength: number
overLimit: boolean
}

// 20ms is to distinguish a lone ESC keypress from the start of an
// escape sequence. Gemini/Claude uses 50ms, Codex uses 20ms, trying
// this as a balanced default for now.
const DEFAULT_TIMEOUT_MS = 20
const DEFAULT_MAX_PENDING_BYTES = 64 * 1024
// Upper bound on bytes retained for a single bracketed paste. A paste whose
// body would exceed this is dropped entirely when its end marker arrives; it
// never emits a truncated event or leaks body bytes as key/mouse/response
// events. There is no automatic recovery from a missing end marker (bytes are
// indistinguishable from delayed paste content), so an unterminated paste is
// bounded here and recovered via reset()/destroy().
const DEFAULT_MAX_PASTE_BYTES = 16 * 1024 * 1024
const INITIAL_PENDING_CAPACITY = 256
const ESC = 0x1b
const BEL = 0x07
Expand Down Expand Up @@ -533,6 +553,7 @@ function createPasteCollector(): PasteCollector {
tail: EMPTY_BYTES,
parts: [],
totalLength: 0,
overLimit: false,
}
}

Expand Down Expand Up @@ -568,6 +589,7 @@ export class StdinParser {
private readonly events: StdinEvent[] = []
private readonly timeoutMs: number
private readonly maxPendingBytes: number
private readonly maxPasteBytes: number
private readonly armTimeouts: boolean
private readonly onTimeoutFlush: (() => void) | null
private readonly useKittyKeyboard: boolean
Expand Down Expand Up @@ -598,6 +620,7 @@ export class StdinParser {
constructor(options: StdinParserOptions = {}) {
this.timeoutMs = normalizePositiveOption(options.timeoutMs, DEFAULT_TIMEOUT_MS)
this.maxPendingBytes = normalizePositiveOption(options.maxPendingBytes, DEFAULT_MAX_PENDING_BYTES)
this.maxPasteBytes = normalizePositiveOption(options.maxPasteBytes, DEFAULT_MAX_PASTE_BYTES)
this.armTimeouts = options.armTimeouts ?? true
this.onTimeoutFlush = options.onTimeoutFlush ?? null
this.useKittyKeyboard = options.useKittyKeyboard ?? true
Expand Down Expand Up @@ -1887,6 +1910,10 @@ export class StdinParser {
// across chunk boundaries. Bytes that can't be part of the end marker are
// appended to the paste collector without decoding.
//
// A paste whose retained body exceeds maxPasteBytes stops being collected
// (see pushPasteBytes): the end marker is still scanned for, but the whole
// paste is dropped instead of emitting a truncated event.
//
// Returns any bytes that follow the end marker — those go back through
// normal parsing in the push() loop.
private consumePasteBytes(chunk: Uint8Array): Uint8Array {
Expand All @@ -1897,10 +1924,12 @@ export class StdinParser {
if (endIndex !== -1) {
this.pushPasteBytes(combined.subarray(0, endIndex))

this.events.push({
type: "paste",
bytes: joinPasteBytes(paste.parts, paste.totalLength),
})
if (!paste.overLimit) {
this.events.push({
type: "paste",
bytes: joinPasteBytes(paste.parts, paste.totalLength),
})
}

this.paste = null
return combined.subarray(endIndex + BRACKETED_PASTE_END.length)
Expand All @@ -1918,16 +1947,35 @@ export class StdinParser {
return EMPTY_BYTES
}

// Appends paste body bytes to the collector until maxPasteBytes is reached.
// Beyond that point the body is discarded: overLimit is set, retained parts
// are freed, and consumePasteBytes() drops the paste entirely when its end
// marker arrives. The sliding tail (which lives on the collector, not in
// parts) is untouched so end-marker detection stays chunk-boundary
// invariant even while the body is being discarded.
private pushPasteBytes(bytes: Uint8Array): void {
if (bytes.length === 0) {
return
}

const paste = this.paste!
if (paste.overLimit) {
return
}

const nextLength = paste.totalLength + bytes.length
if (nextLength > this.maxPasteBytes) {
paste.overLimit = true
paste.parts = []
paste.totalLength = 0
return
}

// Copy here because subarray() inputs may alias the caller's chunk or the
// parser's pending buffer across pushes. The emitted paste event must keep
// the original bytes even if those backing buffers are later reused.
this.paste!.parts.push(Uint8Array.from(bytes))
this.paste!.totalLength += bytes.length
paste.parts.push(Uint8Array.from(bytes))
paste.totalLength = nextLength
}

private reconcileDeferredStateWithProtocolContext(): void {
Expand Down