Files
orca/src/shared/pty-startup-reply-echo-shapes.test.ts
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

141 lines
7.0 KiB
TypeScript

/**
* The echo shapes here are TRANSCRIBED FROM A LIVE PTY, not derived from the spec: each
* `echo` is what `/bin/bash` actually emitted after the reply was written to the master,
* captured under node-pty at a readline prompt (tty raw, readline echoes in software) and
* under a `read` builtin (tty cooked, kernel ECHOCTL echoes).
*
* Two shapes matched nothing before this file existed: an OSC reply on a cooked tty (its
* trailing BEL prints as `^G`, and only ESC was caret-encoded) and any private DSR at a
* readline prompt (the readline projection was gated on an OSC introducer).
*/
import { describe, expect, it } from 'vitest'
import { locateEcho, replyEchoProjections } from './pty-startup-reply-echo-shapes'
const OSC11_BEL = '\x1b]11;rgb:2e2e/3434/3434\x07'
const OSC11_ST = '\x1b]11;rgb:2e2e/3434/3434\x1b\\'
const OSC10_BEL = '\x1b]10;rgb:c6c6/c6c6/c6c6\x07'
const DSR_997 = '\x1b[?997;1n'
const DSR_996 = '\x1b[?996n'
/** `readline` = tty raw at a bash prompt; `cooked` = kernel ECHOCTL under `read`. */
const LIVE_ECHOES: readonly { name: string; reply: string; echo: string }[] = [
{ name: 'OSC 11 BEL / readline', reply: OSC11_BEL, echo: '\x0711;rgb:2e2e/3434/3434\x07' },
{ name: 'OSC 11 ST / readline', reply: OSC11_ST, echo: '\x0711;rgb:2e2e/3434/3434' },
{ name: 'OSC 10 BEL / readline', reply: OSC10_BEL, echo: '\x0710;rgb:c6c6/c6c6/c6c6\x07' },
{ name: 'DSR 997 / readline', reply: DSR_997, echo: '\x07997;1n' },
{ name: 'DSR 996 / readline', reply: DSR_996, echo: '\x07996n' },
{ name: 'OSC 11 BEL / cooked', reply: OSC11_BEL, echo: '^[]11;rgb:2e2e/3434/3434^G' },
{ name: 'OSC 11 ST / cooked', reply: OSC11_ST, echo: '^[]11;rgb:2e2e/3434/3434^[\\' },
{ name: 'OSC 10 BEL / cooked', reply: OSC10_BEL, echo: '^[]10;rgb:c6c6/c6c6/c6c6^G' },
{ name: 'DSR 997 / cooked', reply: DSR_997, echo: '^[[?997;1n' },
{ name: 'DSR 996 / cooked', reply: DSR_996, echo: '^[[?996n' }
]
describe('replyEchoProjections on a POSIX pty', () => {
it.each(LIVE_ECHOES)('matches the live $name echo', ({ reply, echo }) => {
const match = locateEcho(replyEchoProjections(reply, 'posix-pty'), echo)
expect(match).toEqual({ kind: 'complete', offset: 0, length: echo.length })
})
// The tty coalesces its echo with surrounding shell output, so anchoring at offset 0
// would recognize almost no real echo.
it.each(LIVE_ECHOES)('finds the $name echo embedded in output', ({ reply, echo }) => {
const match = locateEcho(replyEchoProjections(reply, 'posix-pty'), `user@host:~$ ${echo} `)
expect(match).toEqual({ kind: 'complete', offset: 13, length: echo.length })
})
// `stty -echoctl` echoes the reply verbatim.
it.each(LIVE_ECHOES.map((entry) => entry.reply))('matches the verbatim echo of %j', (reply) => {
expect(locateEcho(replyEchoProjections(reply, 'posix-pty'), reply).kind).toBe('complete')
})
it('caret-encodes every control, not just ESC', () => {
const [kernel] = replyEchoProjections(OSC11_BEL, 'posix-pty')
expect(kernel?.needle).toBe('^[]11;rgb:2e2e/3434/3434^G')
expect(kernel?.needle).not.toContain('\x07')
})
// ECHOCTL passes TAB/LF/CR through literally and renders DEL as `^?`. No reply grammar
// carries one today; this pins the encoder so a future grammar cannot silently
// over-predict. Table matches the caret notation `vis(3)` defines.
it.each([
{ name: 'TAB stays literal', input: '\t', encoded: '\t' },
{ name: 'LF stays literal', input: '\n', encoded: '\n' },
{ name: 'CR stays literal', input: '\r', encoded: '\r' },
{ name: 'NUL carets to ^@', input: '\x00', encoded: '^@' },
{ name: 'BEL carets to ^G', input: '\x07', encoded: '^G' },
{ name: 'ESC carets to ^[', input: '\x1b', encoded: '^[' },
{ name: 'DEL carets to ^?', input: '\x7f', encoded: '^?' }
])('$name under ECHOCTL', ({ input, encoded }) => {
const [kernel] = replyEchoProjections(`\x1b[?9${input}n`, 'posix-pty')
expect(kernel?.needle).toBe(`^[[?9${encoded}n`)
})
// DA1 shares the `ESC [ ?` prefix but is not a cooked-echo-risk reply. Keeping this off
// the readline path here, rather than relying on the caller's predicate, means widening
// that predicate cannot silently arm a holdable `BEL 1;2c` needle.
it.each(['\x1b[?1;2c', '\x1b[?0u', '\x1b[?2026;2$y', '\x1b[?12;5R'])(
'projects no readline needle for %j, which is not a private DSR',
(reply) => {
const needles = replyEchoProjections(reply, 'posix-pty').map(
(projection) => projection.needle
)
expect(needles.some((needle) => needle.startsWith('\x07'))).toBe(false)
}
)
// The containment grammar accepts an empty parameter list 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.
it.each(['\x1b[?n', '\x1b[?;n', '\x1b[?5n'])(
'projects no readline needle for %j, which would be too short to be safe',
(reply) => {
const needles = replyEchoProjections(reply, 'posix-pty').map(
(projection) => projection.needle
)
expect(needles.some((needle) => needle.startsWith('\x07'))).toBe(false)
}
)
it('projects readline for a private DSR, which carries no OSC introducer', () => {
const needles = replyEchoProjections(DSR_997, 'posix-pty').map(
(projection) => projection.needle
)
expect(needles).toContain('\x07997;1n')
})
// A needle starting with ESC must never be held as a partial: a read ending on a bare
// ESC is a strict prefix of it, and an expired hold would release a stolen query raw.
it('holds a partial only for needles that do not start with ESC', () => {
for (const { reply } of LIVE_ECHOES) {
for (const projection of replyEchoProjections(reply, 'posix-pty')) {
expect(projection.holdPartial).toBe(!projection.needle.startsWith('\x1b'))
}
}
})
})
describe('replyEchoProjections on other backends', () => {
it('keeps ConPTY on its documented ESC-stripped form', () => {
expect(replyEchoProjections(DSR_997, 'windows-conpty')).toEqual([
{ needle: '[?997;1n', holdPartial: true }
])
})
// Documents current behaviour, and is NOT a claim that it is right: conhost's echo of a
// BEL-terminated reply has never been captured, so the needle keeps a raw BEL exactly as
// the POSIX caret form used to. Unreachable in-tree (every OSC reply Orca emits is
// ST-terminated) and deliberately not corrected blind — see the branch comment.
it('leaves a BEL literal in the ConPTY needle, which is unverified', () => {
const [conpty] = replyEchoProjections(OSC11_BEL, 'windows-conpty')
expect(conpty?.needle).toBe(']11;rgb:2e2e/3434/3434\x07')
// The ST reply is the shape #9651 was actually reported against, and it has no BEL.
const [st] = replyEchoProjections(OSC11_ST, 'windows-conpty')
expect(st?.needle).toBe(']11;rgb:2e2e/3434/3434\\')
})
it('suppresses nothing when the echo shape is unverified', () => {
expect(replyEchoProjections(DSR_997, 'windows-wsl')).toEqual([])
})
})