From 416bc51a4f6151236d959ef6a62d2d1653772287 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:32:51 +0530 Subject: [PATCH] fix: hold ambiguous program input for explicit recovery --- docs/design/input-awareness.md | 10 +- .../2026-10-28-resource-followup-plan.md | 23 +++ .../plans/2026-10-28-input-ownership.md | 27 +++ .../2026-10-28-input-ownership-design.md | 30 ++++ src/app/TerminalApp.ts | 79 +++++++-- src/status/inputStatus.ts | 12 +- tests/commandQueueLive.test.ts | 6 +- tests/fixtures/ownershipRaceEntry.mjs | 5 + tests/fixtures/ownershipRaceProbe.mjs | 28 +++ tests/inputOwnershipLive.test.ts | 35 ++-- tests/inputOwnershipLook.test.ts | 166 +++++++++++++++++- tests/inputOwnershipRaceLive.test.ts | 52 ++++++ tests/inputOwnershipSecrets.test.ts | 111 ++++-------- tests/inputOwnershipSecretsLive.test.ts | 20 ++- tests/inputQuarantine.test.ts | 16 +- tests/ptyResizeLive.test.ts | 4 +- tests/queueConditionOwnershipLive.test.ts | 2 +- tests/typingAfterRead.test.ts | 3 +- 18 files changed, 489 insertions(+), 140 deletions(-) create mode 100644 docs/performance/2026-10-28-resource-followup-plan.md create mode 100644 docs/superpowers/plans/2026-10-28-input-ownership.md create mode 100644 docs/superpowers/specs/2026-10-28-input-ownership-design.md create mode 100644 tests/fixtures/ownershipRaceEntry.mjs create mode 100644 tests/fixtures/ownershipRaceProbe.mjs create mode 100644 tests/inputOwnershipRaceLive.test.ts diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index eb8f0ba5..7ffe9e76 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -75,19 +75,17 @@ first key of a draft and an Enter that would queue, and asks the session to look The hold ends when the look answers or evidence arrives. Until then nothing typed is drawn, sent or queued: the keys may be a hidden reply, and an Enter may answer the command or queue the next one (#426). The look says what it saw: `hidden` (echo off for line input), `visible` (the terminal echoes), `unknown` (no sample said how it echoes: a probe -failed or timed out), `idle` (nothing runs) or `unavailable` (no way to look on this platform). `visible` routes the -keys as before (a terminal would show them too); `hidden` comes with a hidden request, so they go straight to the +failed or timed out), `idle` (nothing runs) or `unavailable` (no way to look on this platform). `visible` permits draft editing (a terminal would show it too), but says nothing about a reader. If the held keys include Enter and no reader was established, the entire draft moves into a confirmation hold, never the command-approval queue; `hidden` comes with a hidden request, so they go straight to the program. Echo off with line input is a hidden request whatever else is known (confirmed when the group is idle, likely when it computes or the kernel says it is blocked elsewhere), so NMSh never draws what the terminal would not echo. An `unknown` answer gets one more look; if that is blind too, or no answer comes within 5 s, the keys stay held and hidden: the activity rows say `Can't tell whether is reading your typing` and Ctrl+S sends it to the -program, Tab shows it in the composer (for review; a final Enter is not replayed), Esc discards it. Later evidence -decides it the same way. A look still running after 800 ms says `Checking whether … · your typing is kept`. +program, Tab shows it in the composer (for review; a final Enter is not replayed), Esc discards it. Later reliable reader evidence can resolve an ordinary lookup hold. A held Enter with only later likely evidence requires confirmation; once Enter is in confirmation quarantine, no later result sends it automatically. Ctrl+S explicitly sends all captured bytes in order to the same still-running command. Tab or Ctrl+Q restores the editable text for queue review, without replaying the final Enter; a fresh Enter explicitly queues it. Esc and Ctrl+C discard the entire capture. Completion/done, a different command, terminal handover and shutdown invalidate confirmation holds; stale decisions cannot recover them. After completion/done, confirmation typing stays hidden in the existing prompt hold for Tab/Esc review, with Ctrl+S disabled; it is never sent to a later command. Existing approval-queue entries are never sent automatically. A look still running after 800 ms says `Checking whether … · your typing is kept`. -A command that ends while its typing was held past 800 ms never runs it: NMSh never learned whether it was a reply +An ordinary lookup hold (not confirmation quarantine) that ends while its typing was held past 800 ms never runs it: NMSh never learned whether it was a reply (a timed `read -s` that gave up), so it is discarded with a note. A quick end (type-ahead after a short command) runs it as before. Ctrl+C discards held typing, as a terminal flushes its input queue on an interrupt, and is itself -never held. A program that took the terminal meanwhile (Fish's reader) gets the keys as raw keys. An older service +never held. An ordinary lookup hold can follow authenticated native-reader ownership as raw keys. Confirmation quarantine is cancelled at terminal handover, before newer keys reach the new owner. An older service does not say what its look saw; its answers are routed as before. Typing right after Ctrl+C or Ctrl+Z stays type-ahead for the prompt. Both ends negotiate the feature; an older service is never asked and behaves as before. diff --git a/docs/performance/2026-10-28-resource-followup-plan.md b/docs/performance/2026-10-28-resource-followup-plan.md new file mode 100644 index 00000000..3cda65dd --- /dev/null +++ b/docs/performance/2026-10-28-resource-followup-plan.md @@ -0,0 +1,23 @@ +# Deferred resource investigation + +Execute separately after the input-ownership correction has completed review and validation. This is an investigation plan, not authorization for a broad memory optimization or a claim that the input gate has passed. + +## Evidence to preserve + +The actual original be3f6abe versus merged c690de05 benchmark raw data remains at `/Volumes/T7Tmp/ai-dev/nmsh-stabilization-followup-20261027/`, with provenance, samples, harnesses and measurements.json. The ownership checkpoint records their hashes; nothing is pooled or overwritten. + +Warm median output/history results are substantial: 10k Unicode output 42503.28 to 269.84 ms, archive restore 684.59 to 251.93 ms, PageUp 872.55 to 40.11 ms, history typing 448.71 to 31.47 ms. Warm first-command output improves 1510.81 to 753.13 ms and durable completion 1643.01 to 858.59 ms. These are warm observations, not true-cold startup gains. + +Costs remain visible: idle CPU 3.35% to 3.75% of one core; idle frontend RSS 129.28 to 152.78 MiB; idle service RSS 49.86 to 60.53 MiB; output-finish service RSS 20.38 to 70.62 MiB. Point-in-time RSS is not retained heap, peak memory or leak evidence. The output-finish snapshots differ in elapsed process lifetime: baseline 38–49 s, merged 0.27–0.30 s. Frontend after short typing is a different observation (155.17 to 153.95 MiB), not a reason to erase the idle increase. + +Runtime/idle n=3 warm sessions; history n=4; long editing n=7 with correlated key samples. Small-n nearest-rank p95 is the observed maximum for runtime/history. Five-second CPU windows cover frontend/service/shell, omit other descendants, and are quantized by ps. No OS-cache purge, physical pixel latency, energy or sustained retention claim. Prior +6.7% history-typing p95 and +2.3% deletion p95 remain separate experiments without causal attribution. + +## Investigation sequence + +1. Reconstruct the matched benchmark environment with locked dependencies, built CLI, private HOME/service, fixed geometry/provider/shell and independently recorded exact heads. Keep original/raw results immutable. Alternate run order in a predefined bounded matrix, record all samples and external host activity, and separate source-loader costs from built runtime. +2. Idle CPU/memory: record all owned roles and descendants at common elapsed times and command lifecycle points over longer predefined windows. Collect CPU deltas, RSS, heap/GC/allocation evidence and timer/probe/render counts separately. Account for service backlog limits and sampler overhead. Do not attribute RSS differences to a leak without retained-object evidence. +3. Startup loading: measure authenticated shell readiness, first composer frame, first output and durable completion separately; record import/initialization paths and time spent starting shell/service/providers. Keep first-observed and warm samples separate. Define a reproducible cold-cache protocol before claiming any cold result; do not purge user caches or alter existing sessions. +4. Long sessions: use a bounded repeated output/history/edit workload with exact record/order checks, fixed idle checkpoints, detach/reattach and session closure. Measure retained heap and RSS across cycles, journals/backlogs, listener/process counts and reclamation after owned sessions close. Separate bounded caches from monotonic retention. +5. Form one evidence-backed hypothesis and a focused follow-up issue per cost. Agree acceptance budgets from the current milestone before code changes; validate any improvement against input safety and output/history performance. No broad cleanup or dependency churn. + +All scratch and artifacts use verified T7 storage. Physical Ghostty/Zed QA remains a separate manual gate. diff --git a/docs/superpowers/plans/2026-10-28-input-ownership.md b/docs/superpowers/plans/2026-10-28-input-ownership.md new file mode 100644 index 00000000..a3f686a8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-input-ownership.md @@ -0,0 +1,27 @@ +# Input ownership implementation plan + +> Implement inline using executing-plans; use independent review at the end. + +**Goal:** safely recover ambiguous answers through explicit confirmation. +**Architecture:** localized TerminalApp ownership hold; unchanged InputWatch/protocol/service approval semantics. +**Tech stack:** existing TypeScript/Node/PTYS. +**Spec:** ../specs/2026-10-28-input-ownership-design.md + +## Constraints and review focus +No automatic merge/release, no changes to PR513, no deadline/CI relaxation. Check combined and separate holds, late likely/hidden evidence, cancellation leaving editor drafts, owner/lifecycle invalidation, and explicit queue intent. + +## Task 1: ownership quarantine +- [x] Add deterministic tests in inputOwnershipLook.test.ts for explicit confirmation, late inference, completion, cancellation, queue and ordering. Run on unchanged source and retain expected failures. +- [x] Add confirmation hold state and atomic draft capture in TerminalApp.ts. Guard late state routing and lifecycle transitions; preserve older-service behavior and explicit actions. +- [x] Run affected unit and real-PTY tests; document changed Enter semantics in input-awareness.md. + +## Task 2: validation checkpoint +- [x] Controlled real-PTY old-red/new-green, immediate/delayed reads/password/non-reader/unknown cases; bounded stress. +- [x] Independent whole-change review and address safety findings; full npm run verify. +- [ ] Commit/push focused branch and open dev PR. Obtain exact-head hosted CI once, no retries for green. Keep unmerged. +- [ ] Report exact evidence/risks, unchanged PR513 and performance observations; leave separate deferred resource investigation plan. + +## Ledger +Fresh dev c690de05; primary checkout unchanged. Design reviewed before implementation. Native owner change cancels quarantine before handover to preserve privacy/order. + +Final local ledger: canonical `npm run verify` passed with TERM=xterm-256color, COLORTERM=truecolor, NO_COLOR unset and NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev. The longer T7 path's two unchanged test failures were reproduced on exact dev and resolved by the shortest compliant root, without test changes. Fixed two-round/two-worker PTY stress passed; original source fails the controlled approval-enqueue assertion and corrected source recovers exactly once with Ctrl+S. Independent findings are addressed. Completion retains review-only typing rather than losing it; combined submissions are guarded after the real editor/action parser; merely likely hidden ownership never releases held bytes. Hosted results belong to the PR checkpoint. No automatic merge. diff --git a/docs/superpowers/specs/2026-10-28-input-ownership-design.md b/docs/superpowers/specs/2026-10-28-input-ownership-design.md new file mode 100644 index 00000000..a2f00d78 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-input-ownership-design.md @@ -0,0 +1,30 @@ +# Ambiguous Enter ownership + +Approved by the maintainer in this session, including explicit confirmation for ambiguous Enter. + +Echo-visible evidence permits draft editing, not reader delivery. If a look resolves visible with no reader and the held keys include Enter, capture the entire draft and keys in the existing ownership hold, clear the composer, and require a user choice. Later visible/likely evidence never forwards it. Ctrl+S sends once only while the same command is live; Ctrl+Q explicitly queues after reviewable echo evidence; Tab reveals editing keys without final Enter; Esc/Ctrl+C discard. No approval-queue entry is recovered automatically. Unknown/hidden input remains private. + +Completion/done retains the capture in the existing prompt hold for Tab/Esc review, with delivery invalidated. A different command, terminal ownership changes and shutdown invalidate pending delivery; stale asynchronous decisions do nothing. Native passthrough cancels the ambiguous capture before handing over rather than delaying the terminal UI or delivering old bytes. This is a conservative builder decision under the approved owner-change requirement. Existing non-ambiguous reader routing remains unchanged. No wire protocol/detector changes or new dependencies. + +Alternatives: automatic late recovery from likely evidence is rejected because echo and likely reads are not proof of ownership. Sending service-owned approval entries would need a separate guarded removal/send transaction, so is deferred. + +Validation: deterministic old-source red/corrected-source green with real key routing, controlled real-PTY immediate/delayed line and password reads, non-reader/false-positive/unknown/completion/order/queue/cancellation coverage, independent review, canonical verification and exact-head hosted CI. Physical Ghostty/Zed remains NOT RUN. + +## State transitions + +| Current state | Evidence or action | Result | +|---|---|---| +| First-key lookup hold | Echo visible, no Enter | Real composer edits may replay; echo is presentation evidence only | +| First-key hold including Enter | Real editor parses a known NMSh slash action | Existing NMSh action path; no program answer | +| Replayed program Enter / separate Enter lookup | No reader or merely likely reader | Capture entire draft into confirmation quarantine; no approval entry | +| Ordinary lookup hold | Merely likely hidden reader | Confirmation quarantine, including unfinished typing; nothing drawn or delivered | +| Ordinary lookup hold | Confirmed reader / authenticated native owner | Existing same-command routing; keys remain ordered | +| Confirmation quarantine | Any later lookup/request | Retain capture; no automatic delivery | +| Confirmation quarantine | Ctrl+S, same command live | Send captured bytes once, in typed order; clear capture | +| Confirmation quarantine | Tab / Ctrl+Q | Queue-composer review; final Enter is removed; new Enter is explicit queue intent | +| Confirmation quarantine | Done / prompt return | Existing prompt hold, hidden and review-only; sending permanently invalidated | +| Pending confirmation | New exec / native terminal handover / shutdown / detach | Discard pending capture before new owner receives typing | +| Prompt hold | Tab / Esc / Ctrl+C | Review without submission / discard; no later program receives it | +| Existing approval entry | New reader evidence | Existing quarantine semantics; never recovered automatically | + +The submission guard runs after the real editor and NMSh action parser; no duplicate editor or slash parser was introduced. Queue-review drafts are excluded from later reader auto-transfer. The detector and wire protocol remain unchanged. Source review identified and tests reproduced the submitted-command passthrough callback bypass, stale queue-review draft transfer, and combined slash-action interception; the final boundaries cover each. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cbfa17a..e9a98209 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -475,7 +475,7 @@ const OWNERSHIP_LOOK_SLOW_MS = 800; */ const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ -type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; +type HoldSettle = 'route' | 'discard' | 'send' | 'reveal' | 'queue'; /** * The keys Tab replays when it shows held typing: text and the keys that edit it, never one that acts. A Ctrl+Q (queue), * Ctrl+S, history, palette or failure key typed while the typing was held must not queue, send or run it (#426 gate). @@ -486,6 +486,8 @@ const REVEALABLE_KEYS = new Set(['text', 'paste', 'enter', 'newline /** Keys held while NMSh looks at whether the running command reads input (#426). */ interface OwnershipHold { keys: Key[]; + /** Ownership was inconclusive: later inference cannot authorize delivery without a user choice. */ + confirmation?: boolean; startId: number; /** A question was open when the hold began: the typing is a reply. */ answering: boolean; @@ -1320,6 +1322,8 @@ export class TerminalApp { * byte goes to the program. Reclaimed in onShellPrompt. */ private handOverTerminal(restore = ''): void { + // Every passthrough entry (including submit's output callback) invalidates an ambiguous capture. + if (this.ownershipLook?.confirmation) this.ownershipLook.settle('discard'); // Images uploaded to the terminal are deleted before a program takes the screen. this.graphics?.releaseAll(); this.browserPanel?.session.forgetPictures(); @@ -1369,6 +1373,7 @@ export class TerminalApp { this.pendingSummarySave = undefined; this.effects.cancel(); if (this.running) { + if (!this.running.awaitingExec && this.ownershipLook?.confirmation) this.ownershipLook.settle('discard'); this.running.awaitingExec = false; this.running.historyAllowed = historyAllowed; this.chooseActivityPair(this.running); @@ -2536,6 +2541,7 @@ export class TerminalApp { if (key.kind === 'interrupt') hold.settle('discard'); // Held at the prompt there is no program to send it to: only showing or discarding it settles it. else if (hold.atPrompt && key.kind === 'sendToProgram') return; + else if (hold.confirmation && key.kind === 'queueCommand' && this.queueAvailable()) { hold.settle('queue'); return; } else if (hold.uncertain && key.kind === 'sendToProgram') { hold.settle('send'); return; } else if (hold.uncertain && key.kind === 'complete') { hold.settle('reveal'); return; } else if (hold.uncertain && key.kind === 'escape') { hold.settle('discard'); return; } @@ -13025,7 +13031,7 @@ export class TerminalApp { const request = this.inputState?.request; // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. Typing held // at the prompt for a command that ended is not this command's: the composer says it. - if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); + if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns, Boolean(this.ownershipLook.confirmation) && this.queueAvailable()); // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); @@ -13067,7 +13073,7 @@ export class TerminalApp { /** While a command runs, the empty composer says what Enter will do with what you type. */ private composerHint(): {text: string; waiting: boolean} | undefined { // Typing held when a command ended right after its hidden input: whatever runs now, only the person settles it. - if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program), waiting: true}; + if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program, Boolean(this.ownershipLook.confirmation)), waiting: true}; if (this.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { return {text: `${this.stopAhead.done ? 'Runs when the prompt is back' : `Runs at the prompt once ${this.stopAhead.program} stops`} · Esc takes it back`, waiting: false}; } @@ -13076,13 +13082,14 @@ export class TerminalApp { if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; const request = this.inputState?.request; const program = request ? inputProgram(request, this.running.command) : commandWords(this.running.command)[0] ?? 'the program'; - if (this.ownershipLook?.uncertain) return {text: heldInputActions(this.running.command.trim().split(/\s+/u)[0] || 'the command'), waiting: true}; + if (this.ownershipLook?.uncertain) return {text: heldInputActions(this.running.command.trim().split(/\s+/u)[0] || 'the command', Boolean(this.ownershipLook.confirmation) && this.queueAvailable()), waiting: true}; if (this.ownershipLook?.slow) return {text: `Checking whether ${program} is waiting for input · your typing is kept`, waiting: false}; // A waiting program gets the one instruction, with or without a queue; a question NMSh has not yet probed reads // as a line. if (this.answersByDefault()) return {text: inputComposerHint(request ?? {since: 0, confidence: 'likely', mode: 'line'}, program, this.queueAvailable()), waiting: true}; if (!this.queueAvailable()) return undefined; - return {text: `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to ${program}`, waiting: false}; + const enter = this.session.lookInput && this.session.features.has('input-look') ? 'Enter checks who reads it' : 'Enter holds it for approval'; + return {text: `${enter} · Ctrl+Q runs it after · Ctrl+S sends to ${program}`, waiting: false}; } /** @@ -13388,6 +13395,13 @@ export class TerminalApp { // it, and it is no reply (#436). One begun before that may be a reply, and is held for approval below. const afterDone = Boolean(this.running.done && this.runningDraft?.startId === this.running.startId && this.runningDraft.afterDone); if (intent === 'enter' && text.trim() && (this.justStopped() || afterDone) && !this.answersByDefault() && !this.passthrough) { this.holdForStop(text, afterDone && !this.justStopped()); return; } + // A first-key hold can contain edits and Enter together. Parse NMSh actions above first; a replayed + // program submission still needs reliable ownership, even when a later look only inferred a line reader. + if (intent === 'enter' && this.replayingHeld && text.trim() && !this.justStopped() && !this.running.done + && this.inputState?.request?.confidence !== 'confirmed' && this.session.lookInput && this.session.features.has('input-look')) { + this.holdForOwnershipLook({kind: 'enter'}, true); + return; + } const toProgram = intent === 'send' || (intent === 'enter' && (!this.queueAvailable() || !text.trim() || this.answersByDefault() || this.passthrough || this.justStopped())); if (toProgram) { this.editor.clear(); @@ -13624,11 +13638,16 @@ export class TerminalApp { this.inputState = state; if (this.arcade) this.arcadeInputState(state); // The shell said the command is done: typing begun from now on is for the prompt, never a reply (#426, #436). - if (state.done) this.running.done = true; + if (state.done) { + this.running.done = true; + if (this.ownershipLook?.confirmation) this.ownershipLook.settle('route'); + } if (state.request && stoppedAt !== undefined && state.request.since >= stoppedAt) { this.running.askedAfterStop = true; if (this.stopAhead?.startId === this.running.startId) this.settleStopAhead('asked'); } + // Apply the confirmation boundary before a newly inferred hidden/key request can transfer an old draft. + if (state.request?.confidence === 'likely') this.ownershipLook?.settle('route'); const draft = this.runningDraft; if (state.terminalOwner) { // A native reader took the terminal: what was typed for this command after it started is its input, as a @@ -13642,12 +13661,12 @@ export class TerminalApp { const request = state.request; // Keys typed after the program wrote its question, in the moment before NMSh could tell how it reads, were // meant for it: a terminal would already have delivered them. Hand them over now instead of hiding them. - if (request && directInput(request) && draft && !draft.typeAhead && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { + if (!this.queueCompose && !this.queueEdit && request && directInput(request) && draft && !draft.typeAhead && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { const typed = this.editor.text; this.editor.clear(); this.runningDraft = undefined; this.session.write(typed); - } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; + } else if (!this.queueCompose && !this.queueEdit && request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; if (request?.mode === 'hidden') this.stashDraftTypedBefore(request); if (request) this.holdQueuedReplies(request); // The evidence a held key waited for has arrived. @@ -13759,7 +13778,7 @@ export class TerminalApp { * A command that ends while its typing was held past the slow mark never runs it. One that ends while a look saw * hidden input right after a hidden answer leaves the typing held at the prompt for the person (Tab or Esc). */ - private holdForOwnershipLook(key: Key): void { + private holdForOwnershipLook(key: Key, requireConfirmation = false): void { const running = this.running!; const keys = [key]; // The command line's own first word (sudo, not the command sudo runs): what the person sees running. @@ -13773,9 +13792,24 @@ export class TerminalApp { if (hold.atPrompt && how !== 'reveal' && how !== 'discard') return; const ended = hold.ended || this.running?.startId !== running.startId; const doubted = hold.slow || hold.uncertain || hold.sawHidden; + if (hold.confirmation) { + // A look or a later likely/confirmed request cannot approve a previously ambiguous Enter. + if (how === 'route') { + if (ended || this.running?.done) { + // Delivery is invalid now; keep the person's typing hidden for review, never for another program. + hold.atPrompt = true; + clearTimeout(bound); clearTimeout(slow); + this.render(); + } + return; + } + if ((ended || this.running?.done) && how !== 'reveal') how = 'discard'; + } // The command ended while a look still saw hidden input right after NMSh answered a hidden question. The typing // is the next command or a reply to a second question with no prompt that gave up first; nothing tells them // apart, and a time window is no proof. It is neither run nor dropped: it waits, hidden, for the person. + if (how === 'route' && !ended && !hold.confirmation && this.inputState?.request?.confidence === 'likely' + && ((key.kind === 'enter' && !hold.answering) || directInput(this.inputState.request))) { confirm(); return; } if (how === 'route' && ended && !hold.answering && !doubted && hold.sawAnsweredHidden && keys.some(typing)) { clearTimeout(bound); clearTimeout(slow); @@ -13803,7 +13837,9 @@ export class TerminalApp { this.render(); return; } - if (how === 'reveal') { + if (how === 'reveal' || how === 'queue') { + // Review is composer-only, even if a password/key reader appeared since the Enter. + if (hold.confirmation) this.queueCompose = true; this.revealHeld(keys); return; } @@ -13830,6 +13866,19 @@ export class TerminalApp { try { for (const item of keys) this.handleKey(item); } finally { this.replayingHeld = false; this.replayTypedWhile = undefined; } this.render(); }; + // Move the whole draft into the hold before exposing any choice: cancellation must leave no future command. + const confirm = () => { + if (!hold.confirmation) { + if (this.editor.text) keys.unshift({kind: 'text', value: this.editor.text}); + this.editor.clear(); + this.runningDraft = undefined; + hold.confirmation = true; + } + hold.uncertain = true; + clearTimeout(bound); + clearTimeout(slow); + this.render(); + }; // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). const doubt = () => { if (settled || hold.atPrompt) return; @@ -13855,7 +13904,7 @@ export class TerminalApp { const deadline = Date.now() + OWNERSHIP_LOOK_MS; const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { - if (settled || hold.atPrompt) return; + if (settled || hold.atPrompt || hold.confirmation) return; // Hidden input seen just after NMSh answered this command's hidden question is usually that question, open until // its read returns, and the typing type-ahead; but it can be a second question with no prompt. Either way the // typing is not a known reply (not discarded) and not known type-ahead (never run by itself if the command ends). @@ -13867,11 +13916,16 @@ export class TerminalApp { if (result === 'idle') hold.ended = true; // An older service never says what it saw: that is no evidence either, so its typing stays held (fail closed). const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || result === undefined; + // Echo permits presentation, not submission to a reader. Combined first-key holds are checked + // by routeComposer after their editor keys and NMSh actions have been parsed. + if (result === 'visible' && !this.inputState?.request && key.kind === 'enter' + && this.running?.startId === running.startId && !this.running.done) { confirm(); return; } if (!blind) settle('route'); else if (!relooked && this.running?.startId === running.startId) { relooked = true; look(); } else doubt(); }; - look(); + if (requireConfirmation) confirm(); + else look(); } /** Look once the shell has started the command (keys typed right after Enter arrive before its exec). */ @@ -14171,6 +14225,7 @@ export class TerminalApp { private stop(exitCode: number): void { this.pendingQueuePause = undefined; + if (this.ownershipLook) this.ownershipLook.settle('discard'); this.queueExpiry?.(); this.queueExpiry = undefined; if (this.stopped) return; // A window maximized for setup is put back before the frontend ends (bounded, synchronous, best effort). diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index 55f4f9f4..0f0ee3e6 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -86,28 +86,28 @@ export function completionWaitFact(timing: InputTiming | undefined): string { } /** How to settle typing NMSh is holding because it cannot tell who reads it (#426): the composer's hint and the rows'. */ -export function heldInputActions(program: string): string { - return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it`; +export function heldInputActions(program: string, queueReview = false): string { + return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it${queueReview ? ' · Ctrl+Q reviews for queue' : ''}`; } /** * Typing held at the prompt (#426): the command ended right after hidden input, and NMSh could not tell whether the * typing was the next command or a reply to a question with no prompt. Nothing typed is shown until the person chooses. */ -export function heldAtPromptActions(program: string): string { - return `Typed during ${program}'s hidden input · held, not run · Tab shows it · Esc discards it`; +export function heldAtPromptActions(program: string, confirmation = false): string { + return `${confirmation ? `Typed while ${program} ran` : `Typed during ${program}'s hidden input`} · held, not run · Tab shows it · Esc discards it`; } /** * The activity rows while typing is held without evidence of who reads it: what happened, then how to choose. Nothing * typed is shown; the person decides, or later evidence does. */ -export function heldInputRows(program: string, columns: number): [string, string] { +export function heldInputRows(program: string, columns: number, queueReview = false): [string, string] { const attention = foreground(UI_COLORS.attention); const secondary = foreground(UI_COLORS.secondary); const head = `${inputGlyph({confidence: 'likely'})} Can't tell whether ${program} is reading your typing`; const tail = ' · held, not shown'; const first = `${attention}${BOLD}${truncateText(head, columns)}${RESET}${displayWidth(head + tail) <= columns ? `${secondary}${tail}${RESET}` : ''}`; const indent = ' '; - return [first, `${indent}${secondary}${truncateText(heldInputActions(program), Math.max(1, columns - indent.length))}${RESET}`]; + return [first, `${indent}${secondary}${truncateText(heldInputActions(program, queueReview), Math.max(1, columns - indent.length))}${RESET}`]; } diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index 0020a69f..1a26bfa6 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -73,7 +73,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { // (A redraw writes only the rows that changed, so the header and the list rows are checked apart.) await frontend.waitFor(/› cd sub +Next/u, mark); await frontend.waitFor(/Queue · 6 commands/u, mark); - assert.match(drawn(frontend, mark), /Enter holds it for approval · Ctrl\+Q runs it after · Ctrl\+S sends to sh/u, 'the empty composer says what Enter and Ctrl+Q do'); + assert.match(drawn(frontend, mark), /Enter checks who reads it · Ctrl\+Q runs it after · Ctrl\+S sends to sh/u, 'the empty composer says what Enter and Ctrl+Q do'); writeFileSync(release, ''); await frontend.waitFor(/STATE sub kept fn-x (?:opt|on)/u, mark, 30_000); const commands = [holding, ...SET_STATE[shell], CHECK_STATE[shell]]; @@ -309,7 +309,7 @@ test('live: after Ctrl+Z or Ctrl+C, what you type next is for the shell prompt, } finally { await sandbox.dispose(); } }); -test('live: a program that survives Ctrl+C gets ordinary routing again: a later line is held for approval, never sent into the program or run', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { +test('live: a program that survives Ctrl+C gets ordinary routing again: a later line stays in ownership quarantine, never sent into the program or run', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { const {sandbox, frontend} = await start('zsh'); try { const mark = frontend.mark; @@ -322,7 +322,7 @@ test('live: a program that survives Ctrl+C gets ordinary routing again: a later frontend.pty.write('\u0003'); await pause(4200); frontend.pty.write('echo LATER\r'); - await frontend.waitFor(/typed while \S+ ran/u, mark); + await frontend.waitFor(/Can.t tell whether \S+ is reading your typing/u, mark); writeFileSync(release, ''); await frontend.waitFor(/SURVIVED/u, mark, 20_000); await pause(1500); diff --git a/tests/fixtures/ownershipRaceEntry.mjs b/tests/fixtures/ownershipRaceEntry.mjs new file mode 100644 index 00000000..3a211be5 --- /dev/null +++ b/tests/fixtures/ownershipRaceEntry.mjs @@ -0,0 +1,5 @@ +const probe = new URL('./ownershipRaceProbe.mjs', import.meta.url); +probe.searchParams.set('gate', process.env.NMSH_TEST_OWNERSHIP_GATE); +await import(probe.href); +process.execArgv.push('--import=' + probe.href); +await import('../../src/index.ts'); diff --git a/tests/fixtures/ownershipRaceProbe.mjs b/tests/fixtures/ownershipRaceProbe.mjs new file mode 100644 index 00000000..26a73172 --- /dev/null +++ b/tests/fixtures/ownershipRaceProbe.mjs @@ -0,0 +1,28 @@ +/** Controlled observation seam only. Real shell reads, terminal modes and all routing remain production code. */ +import {appendFileSync, existsSync} from 'node:fs'; +import {InputWatch} from '../../src/session/InputWatch.ts'; +import {QueryOrder} from '../../src/shell/QueryOrder.ts'; +// The service deliberately strips frontend environment; the fixture passes only its private gate in this module URL. +const root = new URL(import.meta.url).searchParams.get('gate'); +if (!root) throw new Error('ownership race fixture needs its private gate'); +const trace = event => appendFileSync(root + '.jsonl', JSON.stringify({pid: process.pid, ...event}) + '\n'); +process.on('uncaughtExceptionMonitor', error => trace({event: 'fixture-error', message: error.message})); +trace({event: 'loaded'}); +const apply = InputWatch.prototype.apply; +InputWatch.prototype.apply = function(sample, now) { + const controlled = this.running?.command.includes('QA_RACE') && !existsSync(root + '.release'); + if (controlled) { + trace({event: 'sample', modes: sample.modes, states: sample.foreground.map(p => p.state), readingTerminal: sample.readingTerminal}); + sample = {...sample, readingTerminal: undefined, foreground: sample.foreground.map(p => ({...p, state: 'R' + p.state.replaceAll('R', '')}))}; + } + return apply.call(this, sample, now); +}; +const input = QueryOrder.prototype.input; +QueryOrder.prototype.input = function(...args) { + if (!this.ownershipRaceTrace) { + this.ownershipRaceTrace = true; + const write = this.write; + this.write = bytes => { trace({event: 'pty-write', bytes: Buffer.byteLength(bytes)}); return write.call(this, bytes); }; + } + return input.apply(this, args); +}; diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index d8360d3e..d7ba7e55 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -69,7 +69,7 @@ const READS: Record = { for (const shell of ['zsh', 'bash', 'fish'] as const) { const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); - test(`live ${shell}: a waiting read gets the answer typed at once or after a pause; nothing is queued, run or drawn`, {skip: available ? false : `${shell} not installed`, timeout: 240_000}, async () => { + test(`live ${shell}: a waiting read gets immediate or delayed input, with explicit recovery when ambiguous; nothing is queued, run or drawn`, {skip: available ? false : `${shell} not installed`, timeout: 240_000}, async () => { const {sandbox, frontend} = await start(shell); try { for (const reactionMs of [0, 300]) { @@ -82,7 +82,14 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await pause(reactionMs); await type(frontend, read.keys); if (read.enter) frontend.pty.write('\r'); - await frontend.waitFor(read.answered, mark, 10_000); + const replyDeadline = Date.now() + 10_000; + await until(() => read.answered.test(strip(frontend.output.slice(mark))) + || /Can.t tell whether \S+ is reading your typing/u.test(strip(frontend.output.slice(mark))), 10_000, `${what}: answer or explicit ownership choice`); + if (!read.answered.test(strip(frontend.output.slice(mark)))) { + assert.equal(await queued(sandbox), 0, `${what}: ambiguous input is not command approval`); + frontend.pty.write('\u0013'); + } + await frontend.waitFor(read.answered, mark, Math.max(1, replyDeadline - Date.now())); // The answered command completes (its record is journaled after the screen, later on a slower host; Fish's // reader has the terminal, so its output is not journaled) and the shell is back at its prompt. Until then // the program's output can be on screen while the command still runs, and an Enter then queues, as designed. @@ -106,7 +113,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { }); } -test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop holds the line for approval, never run by itself', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { +test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop stays in ownership quarantine and never runs by itself', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { const {sandbox, frontend} = await start('zsh'); try { // Ctrl+Q composes a queue entry; the reader never sees it. Enter then answers the reader. @@ -132,15 +139,14 @@ test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during await pause(entry.endsWith('LOOP') ? 1300 : 0); await type(frontend, entry); frontend.pty.write('\r'); - await until(async () => await queued(sandbox) === 1, 5000, `${entry} to be held`); + await frontend.waitFor(/Can.t tell whether (?:sleep|for) is reading your typing|Typed while (?:sleep|for) ran[^\n]*held, not run/u, mark, 5000); + assert.equal(await queued(sandbox), 0, 'ownership uncertainty never becomes command approval'); await until(async () => (await records(sandbox)).some(record => record.command === command), 15_000, `${command} to complete`); await pause(1500); assert.equal((await records(sandbox)).filter(record => record.command === entry).length, 0, `${entry} did not run by itself`); - const review = frontend.mark; - frontend.pty.write('\r'); - await frontend.waitFor(/Approve and run this command\?/u, review); - frontend.pty.write('\r'); - await until(async () => (await records(sandbox)).filter(record => record.command === entry).length === 1, 15_000, `${entry} to run once, approved`); + assert.match(strip(frontend.output.slice(mark)), /held, not run/u, 'completion invalidates delivery but preserves review'); + frontend.pty.write('\u001b[27u'); + await frontend.run('echo AFTER-QUARANTINE', /AFTER-QUARANTINE/u); } } finally { await sandbox.dispose(); } }); @@ -159,13 +165,18 @@ test('live zsh: a command typed right after a read returns, or right after Ctrl+ await frontend.waitFor(/got:y/u, mark, 10_000); // Entered before the shell's prompt was back, the line was typed while the command ran, so it is held for approval // (#426); entered after, it runs. Either way it runs exactly once, never by itself while unverified. - await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`) || /typed while read ran/u.test(strip(frontend.output.slice(mark))), + await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`) || /[Tt]yped while read ran/u.test(strip(frontend.output.slice(mark))), 15_000, 'the typed command to run, or to be held'); if (!(await records(sandbox)).some(record => record.command === `echo X >> ${marker}`)) { await until(async () => (await records(sandbox)).some(record => record.command.startsWith('read -k 1')), 15_000, 'the read to complete'); const review = frontend.mark; - frontend.pty.write('\r'); - await frontend.waitFor(/Approve and run this command\?/u, review); + if (await queued(sandbox)) { + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?/u, review); + } else { + frontend.pty.write('\t'); + await frontend.waitFor(/echo X >>/u, review); + } frontend.pty.write('\r'); await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`), 15_000, 'the approved command to run'); } diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts index 80c9adcf..83c495d3 100644 --- a/tests/inputOwnershipLook.test.ts +++ b/tests/inputOwnershipLook.test.ts @@ -36,6 +36,7 @@ function fixture() { Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); const originalQueue = frame.session.queue.bind(frame.session); Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); originalQueue(op); }, configurable: true}); + const originalSubmit = frame.app['submit']; Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); let looks = 0; let settle: (result: LookResult | undefined) => void = () => {}; @@ -69,7 +70,7 @@ function fixture() { assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); }; - return {...frame, queueOps, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, + return {...frame, originalSubmit, queueOps, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, interrupts: () => interrupts, looks: () => looks, settle: (result: LookResult | undefined) => settle(result)}; } @@ -93,6 +94,8 @@ test('#426: a reply to a prompt-less read is not queued when the look outlasts 8 // The look finds the shell's own read, holding its terminal and idle. f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, [], 'likely evidence alone cannot submit a held Enter'); + f.app['onInput'](CTRL_S); assert.deepEqual(f.written, ['hello\r'], 'the answer reaches the read, once'); assert.deepEqual(f.adds(), [], 'nothing was queued'); assert.equal(f.text(), ''); @@ -134,8 +137,10 @@ test('#426: keys typed after a held Enter stay behind it, in order', async t => t.mock.timers.tick(SLOW_MS + 100); f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); - assert.deepEqual(f.written, ['hello\r']); - assert.equal(f.text(), 'ls', 'what followed the Enter is the next draft'); + assert.deepEqual(f.written, [], 'likely evidence alone cannot submit a held Enter'); + f.app['onInput'](CTRL_S); + assert.deepEqual(f.written, ['hello\rls'], 'explicit send preserves every held byte in order'); + assert.equal(f.text(), '', 'the explicit send leaves no future command draft'); assert.deepEqual(f.adds(), []); } finally { f.dispose(); } }); @@ -253,7 +258,7 @@ test('#426 timeout: evidence that arrives while typing is held decides it (a hid } finally { f.dispose(); } }); -test('#426: a look that saw the terminal echoing routes type-ahead at once; no held state', async t => { +test('#426: echo permits draft display but Enter without a reader stays held', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -264,9 +269,10 @@ test('#426: a look that saw the terminal echoing routes type-ahead at once; no h assert.equal(f.text(), 'echo next'); f.enter(); await settled(); - assert.deepEqual(f.adds(), ['echo next'], 'queued as soon as the look answered'); + assert.deepEqual(f.adds(), [], 'echo is not reader evidence'); + assert.equal(f.text(), '', 'the draft moved into the hold'); assert.equal(f.looks(), 2, 'one look for the draft, one for the Enter'); - assert.doesNotMatch(f.screen(), HELD); + assert.match(f.screen(), HELD); assert.deepEqual(f.written, []); } finally { f.dispose(); } }); @@ -311,7 +317,7 @@ test('#426: type-ahead is not delayed beyond the look: it appears as soon as the } finally { f.dispose(); } }); -test('#426: an answer to a question the program printed goes as soon as the look answers, never queued', async t => { +test('#426: a held answer to a printed question needs confirmation when the look only infers a reader', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -324,6 +330,8 @@ test('#426: an answer to a question the program printed goes as soon as the look assert.deepEqual(f.adds(), []); f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, [], 'a likely reader cannot submit the held Enter'); + f.app['onInput'](CTRL_S); assert.deepEqual(f.written, ['y\r']); assert.deepEqual(f.adds(), []); } finally { f.dispose(); } @@ -395,3 +403,147 @@ test('#426: Ctrl+C discards held typing, as a terminal flushes its input on inte } finally { f.dispose(); } } }); + +for (const combined of [false, true]) { + test(`#473: echo-only Enter (${combined ? 'combined' : 'separate'} hold) needs confirmation after late likely evidence`, async () => { + const f = fixture(); + try { + f.quickLooks('visible'); + f.begin(READ); + f.type('hello'); + if (!combined) await settled(); + f.enter(); + await settled(); + assert.deepEqual(f.adds(), [], 'an answer is not turned into an approval-held command'); + assert.equal(f.text(), '', 'the entire draft belongs to the hold, not the future prompt'); + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, [], 'late likely evidence does not send'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join(''), 'hello\r', 'explicit send recovers the answer exactly once'); + f.settle('visible'); + await settled(); + assert.equal(f.written.join(''), 'hello\r', 'late lookup cannot duplicate delivery'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } + }); +} + +for (const action of ['interrupt', 'escape', 'complete', 'queueCommand', 'done', 'prompt', 'terminalOwner', 'passthrough'] as const) { + test(`#473: ambiguous Enter followed by ${action} never silently sends or executes`, async () => { + const f = fixture(); + try { + f.quickLooks('visible'); + f.begin(READ); + f.type('hello'); + await settled(); + f.enter(); + await settled(); + if (action === 'done') f.session.emit('inputState', {done: true, timing: {waitedMs: 0, waits: 0}}); + else if (action === 'prompt') f.complete(0); + else if (action === 'terminalOwner') f.session.emit('inputState', {terminalOwner: true, timing: {waitedMs: 0, waits: 0}}); + else if (action === 'passthrough') f.app['onActiveModeChange']('PASSTHROUGH'); + else f.app['handleKey']({kind: action}); + await settled(); + assert.deepEqual(f.written, [], 'neither the answer nor its Enter is sent'); + assert.deepEqual(f.adds(), [], 'nothing queued without a fresh explicit Enter'); + assert.deepEqual(f.submitted, []); + if (action === 'complete' || action === 'queueCommand') { + assert.equal(f.text(), 'hello', 'review restores text without Enter'); + if (action === 'queueCommand') { + f.enter(); + assert.deepEqual(f.adds(), ['hello'], 'explicit queue review Enter queues once'); + assert.equal(f.queueOps.find(op => op.op === 'add')?.op, 'add'); + assert.deepEqual(f.written, []); + } + } else { + assert.equal(f.text(), '', 'cancellation cannot leave a command draft'); + if (action === 'done' || action === 'prompt') assert.equal(f.app['ownershipLook']?.atPrompt, true, 'completion invalidates delivery but keeps typing for explicit review'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join('').includes('hello'), false, 'invalidated capture is never recovered'); + } + } finally { f.dispose(); } + }); +} + +test('#473: a submitted command taking the alternate screen invalidates its held Enter before new raw keys', async () => { + const f = fixture(); + try { + f.quickLooks('visible'); + Object.defineProperty(f.session, 'submit', {value: () => {}, configurable: true}); + f.app['editor'].insert(READ); + await f.originalSubmit.call(f.app); + f.app['onShellExec'](READ); + f.type('hello'); await settled(); f.enter(); await settled(); + assert.deepEqual(f.adds(), []); + f.app['onShellData']('\u001b[?1049h'); + assert.equal(f.app['passthrough'], true, 'the real submitted output callback handed over the terminal'); + assert.equal(f.app['ownershipLook'], undefined, 'ownership change invalidates the old decision'); + f.app['onInput']('X'); + assert.equal(f.written.join(''), 'X', 'only newly typed raw input reaches the new owner'); + } finally { f.dispose(); } +}); + +for (const result of ['unknown', undefined] as const) { + test(`#473: ${result ?? 'older-service'} lookup followed by false-positive likely wait never submits a held Enter`, async () => { + const f = fixture(); + try { + f.quickLooks(result); f.begin('wait'); f.type('hello'); f.enter(); await settled(); + f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); assert.equal(f.text(), ''); + f.app['handleKey']({kind: 'escape'}); f.complete(0); await settled(); + assert.deepEqual(f.submitted, []); assert.equal(f.text(), ''); + } finally { f.dispose(); } + }); +} + +test('#473: a password transition cannot expose or auto-send a confirmation-held reply; Tab reviews without acting', async () => { + const f = fixture(); + try { + f.quickLooks('visible'); f.begin('sleep 1; read -s pw'); f.type(SECRET); await settled(); f.enter(); await settled(); + f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); await settled(); + f.type('X'); + f.nowhere(SECRET, 'later hidden reader'); assert.deepEqual(f.written, []); + f.app['onInput'](TAB); + assert.equal(f.text(), `${SECRET}\nX`, 'review preserves editing order but never replays Enter as submission'); + f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); await settled(); + assert.deepEqual(f.written, [], 'a reader re-report cannot steal the reviewed queue draft'); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +for (const change of ['exec', 'stop'] as const) { + test(`#473: ${change} invalidates confirmation before a stale lookup can recover it`, async () => { + const f = fixture(); + try { + f.quickLooks('visible'); f.begin(READ); f.type('hello'); await settled(); f.enter(); await settled(); + if (change === 'exec') f.app['onShellExec']('echo other'); + else f.app['stop'](0); + f.settle('visible'); await settled(); + assert.equal(f.app['ownershipLook'], undefined); assert.equal(f.text(), ''); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } + }); +} + +test('#473: likely hidden inference keeps even an unfinished held reply private until explicit send', async () => { + const f = fixture(); + try { + f.slowLooks(); f.begin('sleep 1; read -s pw'); f.type(SECRET); + f.found({since: 0, confidence: 'likely', mode: 'hidden'}); await settled(); + f.nowhere(SECRET, 'echo off without reliable reader ownership'); + assert.deepEqual(f.written, [], 'merely likely hidden evidence cannot deliver held bytes'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join(''), SECRET); + } finally { f.dispose(); } +}); + +test('#473: a whole slash action held by the first-key look is still parsed as an NMSh action', async () => { + const f = fixture(); + try { + f.quickLooks('visible'); f.begin('sleep 30'); f.type('/queue'); f.enter(); await settled(); + assert.deepEqual(f.submitted, ['/queue']); + assert.equal(f.app['ownershipLook'], undefined); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipRaceLive.test.ts b/tests/inputOwnershipRaceLive.test.ts new file mode 100644 index 00000000..7409311e --- /dev/null +++ b/tests/inputOwnershipRaceLive.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const entry = fileURLToPath(new URL('./fixtures/ownershipRaceEntry.mjs', import.meta.url)); +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +for (const reactionMs of [0, 300]) { + test(`controlled real PTY: echo-only immediate read after ${reactionMs}ms needs explicit recovery, never command approval`, {skip: !supported, timeout: 60_000}, async t => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const gate = join(sandbox.home, 'ownership-gate'); + const frontend = sandbox.launch([], {cols: 120, rows: 36}, {NMSH_TEST_OWNERSHIP_GATE: gate}, entry); + const trace = () => existsSync(gate + '.jsonl') ? readFileSync(gate + '.jsonl', 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as {event: string; bytes?: number}) : []; + try { + // As in the existing live fixture: do not let an admin poll close an empty service before create/attach. + await frontend.waitFor(/zsh/u); + await until(async () => (await sandbox.sessions()).some(s => s.lastExit !== undefined), 30_000, () => 'authenticated prompt: ' + strip(frontend.output).slice(-1000)); + await frontend.run('echo READY', /READY/u); + const command = `read -r QA_RACE; printf 'RECOVERED=%s\\n' "$QA_RACE"`; + const before = trace().length; + const mark = frontend.mark; + frontend.pty.write(command + '\r'); + await pause(reactionMs); + frontend.pty.write('hello\r'); + // Old source reaches the queue instead. Do not wait for a lucky later inference or resend an answer. + await until(async () => /Can.t tell whether read is reading your typing/u.test(strip(frontend.output.slice(mark))) + || (await sandbox.sessions())[0]?.queued === 1, 5000, 'ownership hold or old-source approval enqueue'); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0, 'echo-only Enter must not become an approval entry'); + assert.match(strip(frontend.output.slice(mark)), /Can.t tell whether read is reading your typing/u); + assert.ok(trace().some(e => e.event === 'sample'), 'the control exercised real probe observations'); + writeFileSync(gate + '.release', ''); + await until(async () => (await sandbox.sessions())[0]?.inputMode === 'line', 10_000, 'genuine waiting-reader evidence after releasing the observation control'); + assert.equal(trace().slice(before).filter(e => e.event === 'pty-write').reduce((sum, e) => sum + e.bytes!, 0), Buffer.byteLength(command + '\r'), 'only the submitted command reached the PTY before confirmation'); + assert.equal((await sandbox.sessions())[0]?.running, command, 'late likely/confirmed evidence cannot auto-answer the quarantine'); + frontend.pty.write('\u0013'); + await frontend.waitFor(/RECOVERED=hello/u, mark, 10_000); + await until(async () => await frontend.completions(command) === 1, 15_000, 'one durable read completion'); + const records = (await sandbox.transcripts().list()).flatMap(s => s.transcript.records); + assert.equal(records.find(r => r.command === command)?.output.match(/RECOVERED=hello/gu)?.length, 1); + assert.equal(records.some(r => r.command === 'hello'), false); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); + await frontend.run('echo AFTER', /AFTER/u); + } catch (error) { + if (error instanceof Error) error.message += '; metadata=' + JSON.stringify(trace()); + throw error; + } finally { t.diagnostic(JSON.stringify({reactionMs, trace: trace()})); await sandbox.dispose(); } + }); +} diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 9d5f6bd8..918ec219 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -201,73 +201,43 @@ test('a second hidden question NMSh sees while the reply is held gets it, never } finally { f.dispose(); } }); -test('an ordinary command typed after a hidden answer, once the terminal echoes again: drawn, never discarded; Enter holds it for approval, Ctrl+Q runs it after', async t => { +test('an ordinary command after a hidden answer: echo permits editing, ambiguous Enter needs a choice, Ctrl+Q explicitly queues', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sudo -k make install', 'Password:'); - f.answerHidden('pw'); - f.state(); - f.tick(300); - f.type('make test'); - f.answer('visible'); - await settled(); - assert.equal(f.text(), 'make test', 'drawn as the next command'); - f.enter(); - f.answer('visible'); - await settled(); - assert.deepEqual(f.adds(), ['make test'], 'kept in the queue, not discarded'); - assert.equal(f.owner.state.entries[0]?.condition, 'approve', 'a plain Enter while sudo runs holds it for approval'); - f.type('make lint'); - f.answer('visible'); - await settled(); + f.answerHidden('pw'); f.state(); f.tick(300); + f.type('make test'); f.answer('visible'); await settled(); + assert.equal(f.text(), 'make test'); + f.enter(); f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), [], 'ownership hold, not approval queue'); f.key('\u0011'); - f.complete(0); - await settled(); - assert.deepEqual(f.dispatched, [], 'the held line is first, so nothing runs before the person approves it'); + assert.equal(f.text(), 'make test', 'Ctrl+Q reviews the held text without submitting'); f.enter(); - f.tick(1000); - f.enter(); - await settled(); - assert.deepEqual(f.dispatched, ['make test'], 'approved, it runs once'); - f.begin('make test'); - f.complete(0); - await settled(); - assert.deepEqual(f.dispatched, ['make test', 'make lint'], 'and the Ctrl+Q command after it, in order, with no approval'); - assert.doesNotMatch(f.screen(), HELD); + assert.deepEqual(f.adds(), ['make test']); + f.type('make lint'); f.answer('visible'); await settled(); f.key('\u0011'); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['make test']); + f.begin('make test'); f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['make test', 'make lint']); } finally { f.dispose(); } }); -test('a reply typed before a later hidden question with no prompt is queued (NMSh could not know), then waits for approval, never running by itself', async t => { +test('a reply entered before a later hidden question stays private and needs explicit send, never runs by itself', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('read -s a; sleep 2; read -s b'); - f.answerHidden('first'); - // The read returned; the command works with the terminal echoing; nothing asks. - f.state(); - f.tick(1500); - f.type(SECRET); - f.answer('visible'); - await settled(); - f.enter(); - f.answer('visible'); - await settled(); - assert.deepEqual(f.adds(), [SECRET], 'queued like any command typed while one runs (documented limit)'); - // The second question, with no prompt: from now on that entry may be its reply. - f.tick(500); - f.hidden(); - await settled(); - const entry = f.owner.state.entries.find(item => item.text === SECRET); - assert.equal(entry?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /hidden input/u, 'a note says why, without the text'); - assert.equal(f.transcript().includes(SECRET), false); - // The person answers the question again; the command succeeds: the entry still does not run. - f.type('second'); - f.enter(); - f.complete(0); - await settled(); - f.neverRun(SECRET, 'after the command succeeded'); + f.answerHidden('first'); f.state(); f.tick(1500); + f.type(SECRET); f.answer('visible'); await settled(); + f.enter(); f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), []); + f.tick(500); f.hidden(); await settled(); + assert.equal(f.written.join(''), 'first\r', 'even later hidden evidence cannot approve ambiguous Enter'); + f.nowhere(SECRET, 'late hidden question'); + f.key('\u0013'); + assert.equal(f.written.join(''), `first\r${SECRET}\r`); + f.complete(0); await settled(); f.neverRun(SECRET, 'after'); } finally { f.dispose(); } }); @@ -417,27 +387,19 @@ test('gate: a password and Enter typed before the program asks: not run, not lef } finally { f.dispose(); } }); -test('gate: a visible reply queued before NMSh saw the read waits for approval once the question is seen; never run after it', async t => { +test('gate: echo-only Enter stays held after a late likely read; explicit send recovers it without approval queueing', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin(`read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`); - // A loaded machine: the look cannot yet tell that the shell's builtin reads, so the Enter queues the reply. - f.type('hello'); - f.answer('visible'); await settled(); - f.enter(); - f.answer('visible'); await settled(); - assert.deepEqual(f.adds(), ['hello'], 'queued (documented limit under load)'); - f.tick(1500); - f.state({since: Date.now() - 1400, confidence: 'likely', mode: 'line'}); - await settled(); - assert.equal(f.owner.state.entries.find(item => item.text === 'hello')?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /asked for input/u); - // The person answers again; the command succeeds; the reply is never run as a command. - f.type('hello'); - f.enter(); - f.complete(0); await settled(); - assert.deepEqual(f.dispatched, [], 'never run after the command'); + f.type('hello'); f.answer('visible'); await settled(); + f.enter(); f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), []); + f.tick(1500); f.state({since: Date.now() - 1400, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, [], 'likely inference cannot release the Enter'); + f.key('\u0013'); + assert.equal(f.written.join(''), 'hello\r'); + f.complete(0); await settled(); assert.deepEqual(f.dispatched, []); } finally { f.dispose(); } }); @@ -458,10 +420,9 @@ test('#426 residual: a reply queued before a timed prompt-less read NMSh never s f.answer('visible'); await settled(); f.enter(); f.answer('visible'); await settled(); - assert.deepEqual(f.adds(), [SECRET], 'queued (NMSh could not know)'); - const entry = f.owner.state.entries.find(item => item.text === SECRET); - assert.equal(entry?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); + assert.deepEqual(f.adds(), [], 'the ambiguous answer stays in the ownership hold'); + assert.equal(f.text(), '', 'no future command draft'); + assert.match(f.screen(), /Can.t tell whether sleep is reading/u); assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); // No question was ever reported: the read gave up unseen, and the command succeeded. f.tick(1500); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index 507fb011..4c77de9d 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -39,17 +39,21 @@ for (const shell of ['zsh', 'bash'] as const) { const command = 'read -rs a; sleep 3; read -rs b; echo "LENGTHS=${#a}/${#b}"'; frontend.pty.write(`${command}\r`); await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + const firstQuestion = (await sandbox.sessions())[0]?.inputSince; frontend.pty.write('first\r'); // The first read returned; `sleep` runs with the terminal echoing again and nothing asks. await pause(400); const typed = frontend.mark; for (const char of SECRET) { frontend.pty.write(char); await pause(15); } frontend.pty.write('\r'); - await frontend.waitFor(/1 queued/u, typed, 10_000); + await frontend.waitFor(/Can.t tell whether read is reading your typing/u, typed, 10_000); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0, 'an early password is not an approval entry'); + const held = frontend.mark; // The second read, with no prompt: NMSh reports it, and the entry may be its reply. - await frontend.waitFor(/asked for hidden input/u, typed, 20_000); - frontend.pty.write('second\r'); - await frontend.waitFor(/LENGTHS=5\/6/u, mark, 20_000); + await until(async () => { const current = (await sandbox.sessions())[0]; return current?.inputMode === 'hidden' && current.inputSince !== firstQuestion; }, 20_000, 'the second hidden reader'); + assert.equal(strip(frontend.output.slice(held)).includes(SECRET), false, 'late hidden input never reveals the capture'); + frontend.pty.write('\u0013'); + await frontend.waitFor(/LENGTHS=5\/10/u, mark, 20_000); const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); await until(async () => (await records()).some(record => record.command === command), 15_000, 'the command to complete'); // Anything that would run after it has had time to start. @@ -60,7 +64,7 @@ for (const shell of ['zsh', 'bash'] as const) { const history = readFileSync(join(sandbox.home, histfile), 'utf8'); assert.match(history, /echo READY/u, 'the shell records history here'); assert.equal(history.includes(SECRET), false, 'not in the shell\'s history'); - assert.match(strip(frontend.output.slice(typed)), /waits for your approval/u); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); } finally { await sandbox.dispose(); } }); @@ -141,7 +145,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await pause(500); for (const char of typed) { frontend.pty.write(char); await pause(15); } frontend.pty.write(runs ? '\u0011' : '\r'); - await frontend.waitFor(new RegExp(`${runs ? 1 : index} (?:queued|waiting)`, 'u'), mark, 10_000); + await frontend.waitFor(runs ? /1 (?:queued|waiting)/u : /Can.t tell whether sleep is reading your typing|Typed while sleep ran[^\n]*held, not run/u, mark, 10_000); await until(async () => await ran() === before + 1, 20_000, 'the command to complete'); if (runs) { await until(async () => (await records()).some(record => record.command === typed), 15_000, 'the real command to run after it'); @@ -150,9 +154,11 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { // Anything that would run after it has had time to start. await pause(2500); assert.equal((await records()).some(record => record.command === typed), false, `${typed}: never run, not in NMSh's journal`); - assert.match(strip(frontend.output.slice(mark)), /never runs by itself/u); + assert.match(strip(frontend.output.slice(mark)), /held, not run/u); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); assert.doesNotMatch(strip(frontend.output.slice(mark)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such|Unknown command)|(?:not found|No such|Unknown command)[^\\n]*${SECRET}`, 'u'), 'the shell never saw it as a command'); + frontend.pty.write('\u001b[27u'); // Explicitly discard this capture before the next independent case. if (shell !== 'fish') { const history = readFileSync(join(sandbox.home, HISTORY[shell][1]), 'utf8').split('\n'); assert.equal(history.some(line => line.replace(/^: \d+:\d+;/u, '') === typed), false, `${typed}: not in the shell's history`); diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts index 5cbfb3d0..b581d625 100644 --- a/tests/inputQuarantine.test.ts +++ b/tests/inputQuarantine.test.ts @@ -67,11 +67,11 @@ for (const reply of REPLIES) { f.begin('sleep 1; read -t 1 QA_REPLY || true'); await f.typeLine(reply); const held = f.entry(reply); - assert.equal(held?.condition, 'approve', 'it waits for approval, whatever it looks like'); - assert.equal(held?.typedWhile, 'sleep', 'the owner knows it was typed while sleep ran'); + assert.equal(held, undefined, 'it stays in the ownership hold, not the approval queue'); + assert.equal(f.text(), '', 'cancellation cannot leave a future command draft'); assert.equal(f.written.some(data => data.includes(reply)), false, 'not written to the terminal'); assert.equal(f.recalled().includes(reply), false, 'not offered by Up as a command typed this session'); - assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); + assert.match(f.screen(), /Can.t tell whether sleep is reading/u, 'the ownership choice is visible'); // No question was ever reported: the read gave up unseen, and the command succeeded. f.tick(1500); f.complete(0); await settled(); @@ -86,7 +86,7 @@ test('quarantine: the held line is shown by where it came from, never by its tex try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1; read -t 1 QA_REPLY || true'); - await f.typeLine(SECRET); + f.owner.holdUnread(`${SECRET}\r`, 'sleep'); assert.equal(f.screen().includes(SECRET), false, 'while the command runs'); assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); f.complete(0); await settled(); @@ -100,7 +100,7 @@ test('quarantine: approving is explicit and needs no display; Tab shows the text try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1'); - await f.typeLine('echo NEXT-QZ'); + f.owner.holdUnread('echo NEXT-QZ\r', 'sleep'); f.complete(0); await settled(); assert.deepEqual(f.dispatched, []); // Enter at the empty composer arms the review; nothing runs from one key. @@ -131,12 +131,12 @@ test('Ctrl+Q is the explicit run-after: a command queued with it while one runs } finally { f.dispose(); } }); -test('the composer says where Enter sends a line while a command runs: held for approval, Ctrl+Q runs it after', async t => { +test('the composer says where Enter sends a line while a command runs: checks ownership, Ctrl+Q runs it after', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('make'); - assert.match(f.screen(), /Enter holds it for approval · Ctrl\+Q runs it after · Ctrl\+S sends to make/u); + assert.match(f.screen(), /Enter checks who reads it · Ctrl\+Q runs it after · Ctrl\+S sends to make/u); } finally { f.dispose(); } }); @@ -284,7 +284,7 @@ test('a held line at the head of the queue never holds the prompt: a command sub try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1'); - await f.typeLine('yes'); + f.owner.holdUnread('yes\r', 'sleep'); f.complete(0); await settled(); f.type('echo NOW'); f.enter(); diff --git a/tests/ptyResizeLive.test.ts b/tests/ptyResizeLive.test.ts index 1b5a94ab..b3b2ea5e 100644 --- a/tests/ptyResizeLive.test.ts +++ b/tests/ptyResizeLive.test.ts @@ -42,9 +42,9 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { frontend.pty.write('\r'); await frontend.waitFor(/❯ line one/u, mark); await pause(500); - // Enter holds it for approval: a note in the transcript and a queue row. + // Enter moves the draft into ownership quarantine; this chrome must not resize the PTY. frontend.pty.write('\r'); - await frontend.waitFor(/never runs by itself|waits for your approval/u, mark); + await frontend.waitFor(/Can.t tell whether sh is reading your typing/u, mark); await pause(500); assert.equal(count(), 0, 'no signal from NMSh\'s own chrome'); frontend.pty.resize(100, 40); diff --git a/tests/queueConditionOwnershipLive.test.ts b/tests/queueConditionOwnershipLive.test.ts index 9297af9f..1ef40a50 100644 --- a/tests/queueConditionOwnershipLive.test.ts +++ b/tests/queueConditionOwnershipLive.test.ts @@ -64,7 +64,7 @@ for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { first.pty.write('w'); await first.waitFor(/runs even after a failure/u, mark); first.pty.write('\u001b[27u'); - await first.waitFor(/Enter holds it for approval · Ctrl\+Q runs it after/u, mark); + await first.waitFor(/Enter checks who reads it · Ctrl\+Q runs it after/u, mark); first.pty.write('\u0003'); await first.waitFor(/Queue paused:[\s\S]*was interrupted/u, mark); await first.run('echo AFTER-INTERRUPT', /AFTER-INTERRUPT/u); diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 858469de..232bda99 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -29,7 +29,8 @@ async function showAndRunHeld(frontend: Frontend, mode: string, run: number, nex frontend.pty.write('\r'); return; } - assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); + assert.ok(mode === 'ahead' && (run % 2 === 1 || /Typed while \S+ ran[^\n]*held, not run/u.test(strip(frontend.output.slice(mark)))), + `only hidden or explicitly quarantined type-ahead is held (${mode}${run})`); assert.equal(new RegExp(`(?