mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
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.
141 lines
7.0 KiB
TypeScript
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([])
|
|
})
|
|
})
|