Files
orca/src/shared/pty-startup-reply-echo-shapes.ts
T
Neil d1a11b3299 fix(pty): match the echo shapes a real tty actually produces (#16542)
Reply echo suppression modelled two echo shapes from the spec rather than from
a tty. Captured under node-pty against real bash, at a readline prompt and
under `read`:

  - Readline mangles CSI replies, not just OSC: `ESC [ ?` becomes BEL and the
    residue echoes. The projection was gated on an OSC introducer, so a private
    DSR echo was never matched at a readline prompt. This is the reachable one:
    a mode-2031 theme push (`CSI ?997;1n`) left latched by an exited TUI paints
    `997;1n` on a bash prompt (#9993's scenario).
  - ECHOCTL carets EVERY control, not just ESC. A BEL-terminated OSC reply
    echoes as `^G`, but the needle kept a literal BEL — a string no tty
    produces. Hardening only: every in-tree OSC reply is ST-terminated
    (terminal-osc-color-reply.ts:112, xterm's own reply), so the changed byte
    is unreachable except from a foreign or older emulator.

Why this is not the CSI projection #13160 review dropped: that one was the
identity (`replaceAll('\x1b]', …)` is a no-op on a CSI reply), so it was
ESC-led and 500ms-held bare-ESC tails away from the query parser. This one is
BEL-led. The rule is now asserted for every shape rather than implied by the
gate: holdPartial iff the needle does not start with ESC.

The readline branch is keyed on the private-DSR grammar with a non-empty
parameter list, plus a floor on needle length. The containment grammar admits
`CSI ? n`, and `answerLiveQueryReply` takes client-supplied bytes on the relay
path, so a peer could otherwise arm a two-byte `BEL n` needle and delete the
first bell-then-`n` in ordinary output. #61c65151129 proved this system can eat
real output when a needle outlives its budget; a length floor is cheap.

Live coverage: pty-reply-echo-shapes.node-pty.test.ts writes a reply to a real
bash master and feeds back what it echoes, so a shell or libc change fails the
suite instead of silently disarming suppression. Registered in the
shell-contracts lane. The transcript tests and the caretEcho helpers that
encoded the same ESC-only assumption are corrected alongside.

Suppression is display-only. This does not change what reaches the child's
stdin — the reply is written to the master either way, in call order.
2026-08-26 14:36:45 -07:00

172 lines
6.8 KiB
TypeScript

import type { PtyOwnerBackend } from './pty-owner-backend'
// The shapes a reply written to the PTY master can come back as, and how one is located
// inside a coalesced read. Split from the write path: what an echo LOOKS like is
// independent of who writes it (#12112, #13137).
/**
* One echo shape to watch for.
*
* `holdPartial` is the safety bit. A shape that does NOT start with ESC can be held as a
* partial candidate across reads, because no terminal query can share a prefix with it —
* holding it steals nothing. A shape that DOES start with ESC must never be held: a read
* ending on a bare ESC is a strict prefix of it, the hold would take that ESC away from
* the query parser, and an expired hold releases it raw — so a query torn at its own ESC
* would never be answered. Such a shape is matched only when it arrives complete.
*/
export type EchoProjection = { needle: string; holdPartial: boolean }
export type PtyStartupReplyEchoMatch =
| { kind: 'complete'; offset: number; length: number }
| { kind: 'partial'; offset: number }
| { kind: 'none' }
/* oxlint-disable-next-line no-control-regex -- terminal reply grammars are control sequences */
const PRIVATE_DSR_RE = new RegExp('^\\u001b\\[\\?[0-9][0-9;]*n$')
/**
* Floor on a BEL-led needle. The containment grammar accepts an empty parameter list
* (`CSI ? n`), and `answerLiveQueryReply` takes client-supplied bytes on the relay path —
* so without this a peer could arm a two-byte `BEL n` needle that deletes the first
* bell-then-`n` in ordinary output. Below this length the match is not worth the span.
*/
const MIN_READLINE_NEEDLE_LENGTH = 4
/**
* ECHOCTL carets EVERY control, not just ESC. Every OSC reply Orca emits is ST-terminated
* (terminal-osc-color-reply.ts) and ST is ESC-led, so this is byte-identical to the old
* ESC-only encoding for all of them. It matters for a BEL-terminated reply, which the
* grammar admits from a foreign or older emulator: the tty prints that BEL as `^G`, where
* encoding ESC alone left a literal BEL in the needle — a string no tty produces.
*
* Shapes verified against a live pty; bash, zsh and sh echo identically here, because this
* is the kernel and not the shell.
*
* TAB/LF/CR are exempt: ECHOCTL passes them through literally, so caret-encoding them
* would over-predict. No reply grammar carries one today, which is why this is a guard
* rather than a fix.
*/
function caretEncodeControls(reply: string): string {
let out = ''
for (const ch of reply) {
const code = ch.charCodeAt(0)
if (code === 0x09 || code === 0x0a || code === 0x0d) {
out += ch
} else if (code < 0x20) {
out += `^${String.fromCharCode(code + 0x40)}`
} else {
out += code === 0x7f ? '^?' : ch
}
}
return out
}
/**
* Readline swallows the escape introducer, rings the bell, and echoes the residue — BEL
* replaces `ESC ]` for an OSC reply and `ESC [ ?` for a private DSR. The DSR shape had no
* projection at all, so `CSI ? … n` was never matched at a readline prompt.
*/
function readlineEchoProjection(reply: string): string | null {
if (reply.includes('\x1b]')) {
return reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '')
}
// Keyed on the private-DSR grammar, not a bare `ESC [ ?` prefix: DA1 (`ESC [ ? 1 ; 2 c`)
// shares that prefix and is kept off this path today only by a predicate one module away.
// Matching the final `n` here means widening that predicate cannot silently arm a needle.
if (!PRIVATE_DSR_RE.test(reply)) {
return null
}
const needle = `\x07${reply.slice(3)}`
return needle.length >= MIN_READLINE_NEEDLE_LENGTH ? needle : null
}
export function replyEchoProjections(
reply: string,
ownerBackend: PtyOwnerBackend
): readonly EchoProjection[] {
if (ownerBackend === 'windows-conpty') {
// ESC-stripped, but observed only for the ST-terminated OSC 10/11 reply that #9651
// was reported against — which is every OSC reply Orca emits. Whether conhost leaves a
// BEL literal, carets it, or eats it is unknown, so this shares the latent defect the
// POSIX caret form had. Not corrected blind: #9500 Decision 3 forbids generalising the
// ESC-strip without evidence, and the harness that would produce it does not exist.
return [{ needle: reply.replaceAll('\x1b', ''), holdPartial: true }]
}
if (ownerBackend !== 'posix-pty') {
// wsl.exe is ConPTY-hosted but its echo shape is unverified; suppress nothing.
return []
}
const readline = readlineEchoProjection(reply)
return [
// The kernel's ECHOCTL caret form — the POSIX default.
{ needle: caretEncodeControls(reply), holdPartial: true },
// Readline rewrites the reply, and echoes it even while the kernel reports ECHO clear.
...(readline === null ? [] : [{ needle: readline, holdPartial: true }]),
// A `stty -echoctl` tty echoes the reply verbatim. Complete-match-only, because it
// starts with ESC — see `holdPartial`. We just wrote these exact bytes, and we are
// the terminal, so a child emitting the identical span in the same window is not a
// case worth trading a stolen query for.
{ needle: reply, holdPartial: false }
]
}
/** Earliest offset whose suffix of `data` is a strict prefix of `needle`, else -1. */
function suffixPrefixOffset(needle: string, data: string): number {
for (
let offset = Math.max(0, data.length - needle.length + 1);
offset < data.length;
offset += 1
) {
if (needle.startsWith(data.slice(offset))) {
return offset
}
}
return -1
}
// Why search the whole span: the tty coalesces its echo with whatever the shell and the
// program wrote around it, so anchoring at offset 0 recognizes almost no real echo.
export function locateEcho(
projections: readonly EchoProjection[],
data: string
): PtyStartupReplyEchoMatch {
let complete: { offset: number; length: number } | null = null
let partialOffset = -1
for (const projection of projections) {
const at = data.indexOf(projection.needle)
if (at !== -1) {
if (!complete || at < complete.offset) {
complete = { offset: at, length: projection.needle.length }
}
continue
}
if (!projection.holdPartial) {
continue
}
const suffix = suffixPrefixOffset(projection.needle, data)
if (suffix !== -1 && (partialOffset === -1 || suffix < partialOffset)) {
partialOffset = suffix
}
}
if (complete) {
return { kind: 'complete', ...complete }
}
return partialOffset === -1 ? { kind: 'none' } : { kind: 'partial', offset: partialOffset }
}
export function isBetterEchoMatch(
candidate: PtyStartupReplyEchoMatch,
best: PtyStartupReplyEchoMatch
): boolean {
if (candidate.kind === 'none') {
return false
}
if (best.kind === 'none') {
return true
}
if (candidate.kind !== best.kind) {
return candidate.kind === 'complete'
}
return candidate.offset < best.offset
}