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

123 lines
4.4 KiB
TypeScript

/**
* Keeps the transcript in pty-startup-reply-echo-shapes.test.ts honest.
*
* That file asserts against echo bytes recorded by hand, which is exactly how the two
* shapes this PR corrected went wrong in the first place: the projection and the test both
* encoded the same guess. This one writes a reply to a real PTY master, reads what bash
* actually echoes, and feeds it to the projection — so a shell or libc change that moves
* the shape fails here instead of silently disarming suppression.
*
* Two line disciplines, because they echo differently and both are reachable:
* readline — tty raw at a prompt, readline echoes in software
* cooked — under `read`, the kernel echoes via ECHOCTL
*/
import { existsSync } from 'node:fs'
import { afterEach, describe, expect, it } from 'vitest'
import { locateEcho, replyEchoProjections } from './pty-startup-reply-echo-shapes'
import { mode2031SequenceFor } from './terminal-color-scheme-protocol'
const BASH = '/bin/bash'
const itWithBash = process.platform !== 'win32' && existsSync(BASH) ? it : it.skip
const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark')
const OSC_COLOR_REPLY_ST = '\x1b]11;rgb:2e2e/3434/3434\x1b\\'
type Pty = { write: (data: string) => void; kill: () => void }
let live: Pty | null = null
afterEach(() => {
live?.kill()
live = null
})
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline && !predicate()) {
await sleep(25)
}
}
/**
* Writes `reply` to the master once bash is settled, and returns what came back.
* `discipline: 'cooked'` parks bash in `read` first, which restores ICANON+ECHO.
*/
async function echoOf(reply: string, discipline: 'readline' | 'cooked'): Promise<string> {
const { spawn } = await import('node-pty')
let output = ''
const pty = spawn(BASH, ['--norc', '--noprofile', '-i'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
env: { ...process.env, PS1: 'ORCA16542> ', TERM: 'xterm-256color' }
})
live = { write: (data) => pty.write(data), kill: () => pty.kill() }
pty.onData((data) => {
output += data
})
await waitFor(() => output.includes('ORCA16542> '), 10_000)
if (discipline === 'cooked') {
pty.write('read -r ORCA_LINE\r')
await sleep(400)
}
output = ''
pty.write(reply)
// No marker to wait on: the echo is all this produces, so settle instead.
await waitFor(() => output.length > 0, 5_000)
await sleep(250)
return output
}
describe('replyEchoProjections against a real bash pty', () => {
// The reachable fix: a latched mode-2031 push echoed at a readline prompt had no
// projection at all before this, so it always painted `997;1n` on the prompt (#9993).
itWithBash(
'matches what readline echoes for a mode-2031 reply',
async () => {
const echo = await echoOf(COLOR_SCHEME_REPLY, 'readline')
expect(echo).not.toBe('')
const match = locateEcho(replyEchoProjections(COLOR_SCHEME_REPLY, 'posix-pty'), echo)
expect(match).toMatchObject({ kind: 'complete' })
},
30_000
)
itWithBash(
'matches what the kernel echoes for a mode-2031 reply',
async () => {
const echo = await echoOf(COLOR_SCHEME_REPLY, 'cooked')
expect(echo).not.toBe('')
const match = locateEcho(replyEchoProjections(COLOR_SCHEME_REPLY, 'posix-pty'), echo)
expect(match).toMatchObject({ kind: 'complete' })
},
30_000
)
// The ST form is what every in-tree OSC reply uses, so this is the shape that must never
// regress — the BEL form is only reachable from a foreign or older emulator.
itWithBash(
'matches what the kernel echoes for an ST-terminated OSC reply',
async () => {
const echo = await echoOf(OSC_COLOR_REPLY_ST, 'cooked')
expect(echo).not.toBe('')
const match = locateEcho(replyEchoProjections(OSC_COLOR_REPLY_ST, 'posix-pty'), echo)
expect(match).toMatchObject({ kind: 'complete' })
},
30_000
)
itWithBash(
'matches what readline echoes for an ST-terminated OSC reply',
async () => {
const echo = await echoOf(OSC_COLOR_REPLY_ST, 'readline')
expect(echo).not.toBe('')
const match = locateEcho(replyEchoProjections(OSC_COLOR_REPLY_ST, 'posix-pty'), echo)
expect(match).toMatchObject({ kind: 'complete' })
},
30_000
)
})