diff --git a/CHANGELOG.md b/CHANGELOG.md index 85270308..64de441c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. -- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input, and a command that ends while typing was held for its check never runs it (a note says so). Type-ahead on an idle machine is unchanged. +- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. Type-ahead on an idle machine is unchanged. +- A command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). ## [0.20.0] - 2026-10-18 diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 77be1168..eb8f0ba5 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -44,6 +44,13 @@ process), so the open question plus an idle process is the best available eviden (`cat`, Python `input()` with no prompt). The shell's own `read` is detected with or without a prompt. - On macOS, a `wait` builtin looks like a line read (the shell holds the terminal, idle); it is shown as "Probably". Linux's kernel report tells them apart. +- The shell's own prompt hooks are never taken for the command reading (#436). After a command ends, zsh and Bash run + their prompt hooks (the person's and NMSh's) before the prompt marker, and a slow hook holds the terminal, idle, as a + builtin `read` does. Each shell's first hook prints an authenticated `done` marker; from it to the next command + nothing is sampled and a sample already under way is dropped. zsh's single `precmd` function and Fish's other + postexec handlers can run before it. +- A question that began before Ctrl+C or Ctrl+Z, reported late, is the one the stop ended: the frontend drops it, so it + never turns what was typed after the stop into a reply (#436). - Programs reading through `poll`/`select` on macOS. - Programs that own the terminal. Fish 4's own `read` is one: it is fish's line editor, with bracketed paste and OSC 133 marks, so NMSh hands it the terminal and fish answers natively. It is not reported as waiting. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index d102ded9..7b81fcbc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -211,7 +211,7 @@ import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; import {OpenLine, promptShaped} from '../session/openLine.js'; -import {completionWaitFact, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {completionWaitFact, heldAtPromptActions, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; import {pauseReason, pausedGlyph, queueGlyph, queuePreview, queueRow, queueBlock, type QueueAffordance} from '../status/queueStatus.js'; import {queueSettingsPreview} from '../status/queueSettingsPreview.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; @@ -221,7 +221,7 @@ import {buildQueueSummary, recognizedExecutables, reviewedTextSafe, type QueueSu import type {QueueTimeline} from '../session/ExecutionTimeline.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; import type {QueueNews} from '../session/SessionClient.js'; -import {directInput, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; +import {directInput, type InputRequest, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; import {directKeyBytes} from '../terminal/keyBytes.js'; import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; @@ -429,8 +429,21 @@ const STOP_TYPE_AHEAD_MS = 2000; const OWNERSHIP_LOOK_MS = 5000; /** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ const OWNERSHIP_LOOK_SLOW_MS = 800; +/** + * Typing begun this soon after an answer to a hidden question may be type-ahead for what comes next rather than a reply + * to a newer question. This only decides whether held typing counts as a probable reply (discarded) or as unconfirmed + * (left for the person at the prompt); it never lets typing run while a look saw hidden input (#426 review). + */ +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'; +/** + * 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). + */ +const REVEALABLE_KEYS = new Set(['text', 'paste', 'enter', 'newline', 'backspace', 'delete', 'deleteWord', 'deleteLineBefore', + 'deleteLineAfter', 'left', 'right', 'wordLeft', 'wordRight', 'lineHome', 'lineEnd', 'bufferHome', 'bufferEnd', 'selectLeft', 'selectRight', + 'selectWordLeft', 'selectWordRight', 'selectLineHome', 'selectLineEnd', 'selectBufferHome', 'selectBufferEnd', 'selectAll']); /** Keys held while NMSh looks at whether the running command reads input (#426). */ interface OwnershipHold { keys: Key[]; @@ -441,6 +454,19 @@ interface OwnershipHold { slow: boolean; /** No usable evidence: the typing stays held and hidden until evidence arrives or the person chooses. */ uncertain: boolean; + /** The session said nothing runs any more (the shell's done marker): the command ended, though its prompt is still coming (#436). */ + ended: boolean; + /** A look saw hidden input: the typing may be a password, so a command that ends never runs it. */ + sawHidden: boolean; + /** + * A look saw hidden input right after NMSh answered a hidden question: that question still closing, or a newer one + * with no prompt. NMSh cannot tell which, so a command that ends never runs this typing either: it waits at the prompt. + */ + sawAnsweredHidden: boolean; + /** The command ended with the typing unconfirmed: held, hidden, at the prompt until the person shows or discards it. */ + atPrompt: boolean; + /** The command line's first word, as the person saw it running. */ + program: string; settle: (how: HoldSettle) => void; } /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ @@ -861,12 +887,25 @@ export class TerminalApp { * answer, discarded rather than run if the command ends first). Drafts started before the command's latest output * are type-ahead for the shell and stay in the composer. */ - private runningDraft?: {startId: number; at: number; answering: boolean}; + /** typeAhead: begun right after Ctrl+C or Ctrl+Z, for the prompt the stop brings back: never a reply (#436). */ + private runningDraft?: {startId: number; at: number; answering: boolean; typeAhead: boolean}; /** * Keys held while the session looks whether the running command waits for input: the first key of a draft (a * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ private ownershipLook?: OwnershipHold; + /** + * When NMSh last sent an answer to the running command's hidden question. Typing that begins right after it is + * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. + */ + private hiddenAnswer?: {startId: number; at: number}; + /** The running command's question whose queued entries were last checked (a question is reported repeatedly). */ + private hiddenQueueCheck?: {startId: number; since: number}; + /** + * A composer draft typed during the running command before it asked for hidden input: it may be the password, typed + * before the prompt appeared. It is kept off the composer while the question is open and held at the prompt after. + */ + private stashedDraft?: {startId: number; text: string; program: string}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -1402,8 +1441,9 @@ export class TerminalApp { private readonly onInput = (data: string): void => { // Before probes classify a read, or inside a native reader, input can already be sensitive. - // Key diagnostics therefore observe only the idle composer, never startup or running-program input. - if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.startupPending && !this.passthrough && !this.externalPassthrough) { + // Key diagnostics therefore observe only the idle composer, never startup or running-program input, nor typing held + // at the prompt because it may be a reply to hidden input (#426). + if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.ownershipLook && !this.startupPending && !this.passthrough && !this.externalPassthrough) { this.logEditorKeyInput(data); } if (this.passthrough && !this.startupPending) { @@ -2318,6 +2358,8 @@ export class TerminalApp { // Ctrl+C is never held, and what was typed before it whose reader NMSh did not know is discarded, as a terminal // flushes its input queue on an interrupt: it never becomes a queued entry or the next command (#426). 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.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; } @@ -2354,7 +2396,8 @@ export class TerminalApp { // is discarded rather than left to run as the next shell command. if (this.running && !this.editor.text && (key.kind === 'text' || key.kind === 'paste')) { // Right after Ctrl+C or Ctrl+Z, typing is type-ahead for the prompt, not a reply. - this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !this.justStopped()}; + const typeAhead = this.justStopped(); + this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !typeAhead, typeAhead}; } // Flow keeps the composer in the document: editing while scrolled back // returns to it first. Scrolling and mouse navigation alone never do. @@ -4403,6 +4446,12 @@ export class TerminalApp { } // Keys still held for this command's look are settled now that it is over (#426). this.ownershipLook?.settle('route'); + // A draft typed before this command asked for hidden input may be the password: held, never back in the composer. + if (this.stashedDraft?.startId === command.startId) { + const stashed = this.stashedDraft; + this.stashedDraft = undefined; + this.holdAtPrompt(stashed.text, stashed.program); + } // A command may have changed what workspace facts say (npm version, terraform workspace select, aws configure). this.contextEngine.invalidate('command'); void this.refreshContext(cwd); @@ -11457,8 +11506,9 @@ export class TerminalApp { const queue = listed?.[0] ?? this.queueLine(columns); if (!this.running) return queue ? (listed ? ['', queue] : [queue, '']) : []; 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. - if (this.ownershipLook?.uncertain) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); + // 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); // 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); @@ -11476,6 +11526,8 @@ 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.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; @@ -11870,6 +11922,23 @@ export class TerminalApp { private onInputState(state: InputState): void { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; + // A question newer than the hidden one NMSh answered: typing from now on may be its reply, not type-ahead. + if (state.request && this.hiddenAnswer && state.request.since > this.hiddenAnswer.at) this.hiddenAnswer = undefined; + // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it + // before it saw the stop): while typing is type-ahead for the prompt, it routes nothing to the program (#436). If it + // is still the session's word when that window closes (the program kept reading through the stop), it applies + // then, so a hidden question goes back to direct, never drawn input. A question asked after the stop is new. + const stoppedAt = this.running.stoppedAt; + if (state.request && stoppedAt !== undefined && this.justStopped() && state.request.since < stoppedAt) { + const {request: _ended, ...current} = state; + const running = this.running; + const reported = state; + const later = setTimeout(() => { + if (this.running === running && this.inputState === current) this.onInputState(reported); + }, Math.max(0, stoppedAt + STOP_TYPE_AHEAD_MS - Date.now())); + later.unref?.(); + state = current; + } this.inputState = state; const draft = this.runningDraft; if (state.terminalOwner) { @@ -11884,17 +11953,99 @@ 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.startId === this.running.startId && draft.at >= request.since && this.editor.text) { + if (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.at >= request.since && this.editor.text) draft.answering = true; + } else if (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. if (request) this.ownershipLook?.settle('route'); this.render(); } + /** + * A question can come after its reply was already typed and entered: with no question to answer yet (or one NMSh + * could not yet see, on a loaded machine), that Enter queued it as a command (#426, documented limit). When the + * command asks for input, the entries queued while it ran may be that reply: they wait for the person's approval + * instead of running after it, so a reply, or a password, never runs as a shell command by itself. Entries queued before the command started are left as they + * are, and so are those queued while this question is already shown (Ctrl+Q): each question is checked once, when it + * is first reported. Its `since` is no boundary: it can be when the shell went idle (a `sleep` before the `read`). + */ + private holdQueuedReplies(request: InputRequest): void { + const running = this.running; + if (!running || !this.queueAvailable()) return; + const hidden = request.mode === 'hidden'; + if (this.hiddenQueueCheck?.startId === running.startId && this.hiddenQueueCheck.since === request.since) return; + this.hiddenQueueCheck = {startId: running.startId, since: request.since}; + const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && entry.condition !== 'approve'); + if (!typed.length) return; + const program = commandWords(running.command)[0] ?? 'The command'; + if (this.queueConditionsAvailable()) { + for (const entry of typed) this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); + const count = typed.length === 1 ? 'the command queued while it ran waits' : `the ${typed.length} commands queued while it ran wait`; + this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; ${count} for your approval (/queue), in case one was its reply.`, INFO); + } else { + // An older session service without per-entry approval: the whole queue waits instead. + this.session.queue({op: 'pause'}); + this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); + } + } + + /** + * When the running command asks for hidden input, a draft typed in the composer during it, before the question, + * may be the password typed before the prompt appeared (a terminal's program would usually flush it). It leaves the + * composer, so it is neither shown nor sent as the answer, and is held at the prompt when the command ends; an Enter + * held with it was for that draft, never an empty answer for the question. + */ + private stashDraftTypedBefore(request: InputRequest): void { + const running = this.running; + const draft = this.runningDraft; + // Type-ahead typed right after Ctrl+C or Ctrl+Z is the person's next command for the prompt (#436), not a reply. + if (!running || !draft || draft.typeAhead || draft.startId !== running.startId || draft.at >= request.since) return; + if (this.queueCompose || this.queueEdit || !this.editor.text) return; + const text = this.editor.text; + this.editor.clear(); + this.runningDraft = undefined; + const program = running.command.trim().split(/\s+/u)[0] || 'the command'; + this.stashedDraft = this.stashedDraft?.startId === running.startId + ? {...this.stashedDraft, text: `${this.stashedDraft.text}\n${text}`} : {startId: running.startId, text, program}; + const hold = this.ownershipLook; + if (hold && !hold.atPrompt && hold.keys.every(key => key.kind === 'enter' || key.kind === 'newline')) hold.settle('discard'); + } + + /** Show held typing in the composer: its text and edits only (REVEALABLE_KEYS), and never its final Enter. */ + private revealHeld(keys: readonly Key[]): void { + const shown = keys.filter(key => REVEALABLE_KEYS.has(key.kind)); + let end = shown.length; + while (end > 0 && shown[end - 1]!.kind === 'enter') end -= 1; + this.replayingHeld = true; + try { for (const item of shown.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } + this.render(); + } + + /** Hold text at the prompt, hidden, until the person shows it (Tab) or discards it (Esc, Ctrl+C); nothing else settles it. */ + private holdAtPrompt(text: string, program: string): void { + const keys: Key[] = text.split('\n').flatMap((line, index) => index ? [{kind: 'newline'} as Key, {kind: 'text', value: line} as Key] : [{kind: 'text', value: line} as Key]); + const existing = this.ownershipLook; + if (existing?.atPrompt) { existing.keys.unshift(...keys); this.render(); return; } + if (existing) existing.settle('route'); + let settled = false; + const hold: OwnershipHold = {keys, startId: -1, answering: false, slow: false, uncertain: true, ended: true, sawHidden: false, + sawAnsweredHidden: false, atPrompt: true, program, settle: how => { + if (settled || (how !== 'reveal' && how !== 'discard')) return; + settled = true; + if (this.ownershipLook === hold) this.ownershipLook = undefined; + if (how === 'reveal') { this.revealHeld(hold.keys); return; } + this.output.addFrontendInteraction(program, 'Your held typing was discarded, not sent or run.', INFO); + this.render(); + }}; + this.ownershipLook = hold; + this.render(); + } + /** * Whether this key decides where typing goes while a command runs and NMSh has no evidence yet that the command * waits for input: the first key of a draft, or an Enter that would otherwise queue. @@ -11915,7 +12066,8 @@ export class TerminalApp { * is kept. When the look saw nothing usable, or never answers within OWNERSHIP_LOOK_MS, the typing stays held and * hidden (uncertain) until evidence arrives or the person chooses: Ctrl+S sends it to the program, Tab shows it in * the composer, Esc discards it. It is never routed on a guess. Ctrl+C discards it, as a terminal flushes its input. - * A command that ends while its typing was held past the slow mark never runs it. + * 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 { const running = this.running!; @@ -11927,12 +12079,25 @@ export class TerminalApp { let settled = false; const settle = (how: HoldSettle) => { if (settled) return; + // Held at the prompt: no evidence, timer or later command settles it, only the person (Tab, Esc, Ctrl+C). + 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; + // 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.answering && !doubted && hold.sawAnsweredHidden && keys.some(typing)) { + clearTimeout(bound); + clearTimeout(slow); + hold.atPrompt = true; + hold.uncertain = true; + this.render(); + return; + } settled = true; clearTimeout(bound); clearTimeout(slow); if (this.ownershipLook === hold) this.ownershipLook = undefined; - const ended = this.running?.startId !== running.startId; - const doubted = hold.slow || hold.uncertain; if (how === 'discard') { if (doubted) discarded('Your held typing was discarded, not sent or run.'); this.render(); @@ -11949,12 +12114,7 @@ export class TerminalApp { return; } if (how === 'reveal') { - // Shown for review, never routed: a final Enter is not replayed, earlier ones become new lines. - let end = keys.length; - while (end > 0 && keys[end - 1]!.kind === 'enter') end -= 1; - this.replayingHeld = true; - try { for (const item of keys.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } - this.render(); + this.revealHeld(keys); return; } // The command ended while NMSh looked. A reply to its open question is discarded, never run; so is typing held @@ -11980,7 +12140,7 @@ export class TerminalApp { }; // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). const doubt = () => { - if (settled) return; + if (settled || hold.atPrompt) return; if (this.running?.startId !== running.startId || this.passthrough || this.inputState?.request || this.inputState?.terminalOwner) { settle('route'); return; } hold.uncertain = true; this.render(); @@ -11989,7 +12149,12 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, settle}; + // Only typing begun just after the answer, with no newer question since, may be type-ahead (#433 review): a second + // hidden question that NMSh has not reported yet must still count its reply as possibly a password. + const startedAt = Date.now(); + const afterHiddenAnswer = () => this.hiddenAnswer?.startId === running.startId && startedAt - this.hiddenAnswer.at <= HIDDEN_ANSWER_TYPE_AHEAD_MS; + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, + sawHidden: false, sawAnsweredHidden: false, atPrompt: false, program, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is @@ -11998,7 +12163,14 @@ 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) return; + if (settled || hold.atPrompt) 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). + if (result === 'hidden') { if (afterHiddenAnswer()) hold.sawAnsweredHidden = true; else hold.sawHidden = true; } + // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing + // held by a command that ended apply now, not those for one still running. + if (result === 'idle') hold.ended = true; const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || (result === undefined && this.session.features.has('input-look-result')); if (!blind) settle('route'); @@ -12028,6 +12200,7 @@ export class TerminalApp { const bytes = directKeyBytes(key); if (!bytes) return; const mode = this.inputState?.request?.mode; + if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnswer = {startId: this.running.startId, at: Date.now()}; this.session.write(bytes); // One key answers a key read; Enter ends a hidden line (as the session's watch also decides). if (mode === 'key' || /[\r\n]/u.test(bytes)) this.answerSent(); diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index c69c8590..9d6e2eab 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -76,6 +76,8 @@ const defaultTimer = (callback: () => void, ms: number) => { export class InputWatch extends EventEmitter { private running?: {command: string; since: number}; private reader = false; + /** The shell said the command is done and runs its prompt hooks: nothing until its prompt is the command's input. */ + private done = false; private readonly line = new OpenLine(); private readonly screen = new AlternateScreenTracker(); private request?: InputRequest; @@ -133,8 +135,22 @@ export class InputWatch extends EventEmitter { this.emit('change', this.state); } + /** + * The shell's first prompt hook ran: the command has finished (#436). A slow hook holds the terminal, idle, exactly + * as a builtin read does, so from here to the prompt nothing is sampled and a sample already under way is dropped. + */ + onDone(at = this.now()): void { + if (!this.running || this.done) return; + this.done = true; + this.cancelTimer?.(); + this.cancelTimer = undefined; + this.generation += 1; + if (this.request) this.closeWait(at, true); + } + onExec(command: string, at = this.now()): void { this.reader = false; + this.done = false; this.closeWait(at, false); this.running = {command, since: at}; this.waitedMs = 0; @@ -172,6 +188,7 @@ export class InputWatch extends EventEmitter { /** The command finished (the shell is back at its prompt): close any wait; the final timing stays readable. */ onPrompt(at = this.now()): void { + this.done = false; if (!this.running) return; this.closeWait(at, false); this.running = undefined; @@ -192,17 +209,20 @@ export class InputWatch extends EventEmitter { let seen: TerminalModes | undefined; for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running) return 'idle'; + if (this.disposed || !this.running || this.done) break; if (!this.options.probe) return 'unavailable'; this.followUp = false; seen = (await this.look(true))?.modes ?? seen; - if (!this.followUp || this.request) break; + if (!this.followUp || this.request || this.done) break; await new Promise(done => { this.setTimer(done, FOLLOW_UP_MS); }); } - if (this.disposed || !this.running) return 'idle'; - if (this.request?.mode === 'hidden') return 'hidden'; - if (!seen) return 'unknown'; - return seen.canonical && !seen.echo ? 'hidden' : 'visible'; + if (this.disposed) return 'idle'; + // Seen hidden input is reported even when the command finished during the look (#436's done): the typing may have + // been a password for it, and the frontend then discards it rather than run it as the next command. + if (this.request?.mode === 'hidden' || (seen && seen.canonical && !seen.echo)) return 'hidden'; + // Done (its prompt hooks run) or over: the shell owns typing again. + if (!this.running || this.done) return 'idle'; + return seen ? 'visible' : 'unknown'; } dispose(): void { @@ -236,7 +256,7 @@ export class InputWatch extends EventEmitter { private schedule(): void { this.cancelTimer?.(); this.cancelTimer = undefined; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running || this.done || !this.options.probe) return; const quiet = this.now() - this.lastActivity; const planned = this.looks === 0 ? (this.line.open ? FIRST_LOOK_OPEN_MS : FIRST_LOOK_MS) : LOOKS_MS[this.looks - 1] ?? (quiet > SLOW_LOOK_AFTER_MS ? SLOW_LOOK_MS : STEADY_LOOK_MS); @@ -246,7 +266,7 @@ export class InputWatch extends EventEmitter { /** One sample, returned even when newer output made it stale; onDemand looks (lookNow) leave the back-off as it was. */ private async look(onDemand = false): Promise { - if (this.disposed || !this.running || this.probing) return undefined; + if (this.disposed || !this.running || this.done || this.probing) return undefined; if (!onDemand) this.looks += 1; if (this.ownsTerminal) { if (this.request) this.closeWait(this.now(), true); @@ -263,6 +283,8 @@ export class InputWatch extends EventEmitter { this.probing = probing; try { await probing; } finally { this.probing = undefined; } if (this.disposed || !this.running) return undefined; + // The command finished during the probe: nothing is applied or scheduled, but the sample is still evidence. + if (this.done) return sample; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); return sample; @@ -270,7 +292,7 @@ export class InputWatch extends EventEmitter { /** Decide from one sample. Exposed for tests through InputWatch.evaluate. */ apply(sample: TerminalSample, now: number): void { - if (!this.running || this.ownsTerminal) return; + if (!this.running || this.done || this.ownsTerminal) return; const busy = this.busy(sample.foreground, now); this.followUp = false; const next = this.decide(sample, busy, now); diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index 7e5455ea..ad8d9cb1 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -10,6 +10,11 @@ export interface ShellMarker { export type ProtocolEvent = | {kind: 'data'; data: string} | {kind: 'reader'} + /** + * The first prompt hook: the command has finished and the shell is running its prompt hooks (the person's and + * NMSh's) until its prompt marker. Nothing the shell does meanwhile is the command reading input (#436). + */ + | {kind: 'done'} | {kind: 'marker'; marker: ShellMarker} /** zsh preexec: a command line is about to run in the foreground. */ | {kind: 'exec'; command: string; historyAllowed?: number}; @@ -81,6 +86,7 @@ export class ShellProtocolDecoder { private emit(payload: string, events: ProtocolEvent[]): void { if (payload === 'reader') { events.push({kind: 'reader'}); return; } + if (payload === 'done') { events.push({kind: 'done'}); return; } if (/^exec2;[01];/u.test(payload)) { events.push({kind: 'exec', command: payload.slice(8), historyAllowed: Number(payload[6])}); return; diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 03b1594f..bbc41401 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -227,6 +227,8 @@ export class ShellSession extends EventEmitter { } } else if (event.kind === 'reader') { if (this.ready && !this.atPrompt) this.inputWatch.onReader(); + } else if (event.kind === 'done') { + if (this.ready && !this.atPrompt) this.inputWatch.onDone(); } else if (event.kind === 'exec') { this.atPrompt = false; this.commandStart = true; diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index 79b6ca4f..8974bcab 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -106,6 +106,8 @@ __nmsh_history_last= ${context.bridgeEnvPath ? bridgeBootstrap('bash', context.bridgeEnvPath) : ''} __nmsh_precmd() { local nmsh_status=$? nmsh_command + # The command is done (#436): the prompt hooks below may hold the terminal; nothing they do is its input. + ${marker('done')} for nmsh_command in "\${__nmsh_user_prompt_command[@]}"; do [[ -n $nmsh_command ]] || continue __nmsh_set_status "$nmsh_status" diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index d4ef5f6f..f56ddb6d 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -86,6 +86,8 @@ function __nmsh_preexec --on-event fish_preexec end function __nmsh_postexec --on-event fish_postexec set -l nmsh_status $status + # The command is done (#436): nothing until the prompt marker is its input. + ${marker('done')} __nmsh_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} $nmsh_status "$PWD" end diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index ca7fde3b..69eca831 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -82,6 +82,14 @@ function nmsh_tty_echo { } ${bridgeEnvPath ? bridgeBootstrap('zsh', bridgeEnvPath) : ''} +# The first prompt hook: the command is done (#436). Hooks after it (the person's, then NMSh's) may hold the +# terminal for a while; nothing they do is the command reading input. It hands the status on to them unchanged. +function nmsh_done { + local nmsh_status=$? + printf '\\e]777;nmsh;${token};done\\a' + return \$nmsh_status +} + function nmsh_precmd { local nmsh_status=$? nmsh_capture_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} @@ -94,7 +102,7 @@ function nmsh_precmd { # Themes such as Powerlevel10k move their own hook to the end of # precmd_functions every cycle; move ours back after it so the next cycle # still blanks last. Their prompt-spacing options must not return either. - precmd_functions=(\${precmd_functions:#nmsh_precmd} nmsh_precmd) + precmd_functions=(nmsh_done \${precmd_functions:#nmsh_(done|precmd)} nmsh_precmd) unsetopt prompt_cr prompt_sp nmsh_tty_echo -echo printf '\\e]777;nmsh;${token};%d;%s\\a' "\$nmsh_status" "\$PWD" @@ -116,6 +124,7 @@ ${shellKnowledgeBootstrap(join(stateDir, '.nmsh-knowledge'))} autoload -Uz add-zsh-hook add-zsh-hook precmd nmsh_precmd add-zsh-hook preexec nmsh_preexec +precmd_functions=(nmsh_done \${precmd_functions:#nmsh_done}) # Startup is over: this shell has read its files. A zsh started from it (a tmux # pane, a nested shell) must be an ordinary zsh, not a second copy of this diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index c304c0a7..55f4f9f4 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -90,6 +90,14 @@ export function heldInputActions(program: string): string { return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it`; } +/** + * 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`; +} + /** * 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. diff --git a/tests/ctrlCTypeAhead.test.ts b/tests/ctrlCTypeAhead.test.ts new file mode 100644 index 00000000..729d6071 --- /dev/null +++ b/tests/ctrlCTypeAhead.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {InputRequest} from '../src/session/inputState.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #436: typing right after Ctrl+C is type-ahead for the prompt the stop brings back. Under load the session's report + * of the question Ctrl+C ended can reach the frontend after the stop; it used to mark what was typed after the stop + * as a reply, and the prompt then discarded it, so the rest of the line ran truncated (`cho Y >> …`, exit 127, seen + * live at load ~30). A question that began before the stop is the one the stop ended: it changes nothing. + * Deterministic: the session's late report is replayed in the order the live log recorded. No shell is started. + */ + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const submitted: string[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + return {...frame, written, submitted, state, type, transcript, text: () => frame.app['editor'].text as string}; +} + +test('#436: the report of the question Ctrl+C ended, arriving late, never turns type-ahead into a discarded reply', () => { + const f = fixture(); + try { + f.begin('read -r reply'); + const asked = Date.now(); + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + f.type('echo Y'); + // The session sampled the read before it saw the interrupt; its report and then the closing one arrive now. + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.state(); + f.tick(20); + f.complete(130); + assert.equal(f.text(), 'echo Y', 'what was typed after Ctrl+C is still the draft at the prompt'); + assert.doesNotMatch(f.transcript(), /reply was discarded/u); + f.type(' >> once'); + f.app['onInput']('\r'); + assert.deepEqual(f.submitted, ['echo Y >> once'], 'the whole line runs, once'); + assert.deepEqual(f.written, [], 'nothing reached the interrupted program'); + } finally { f.dispose(); } +}); + +test('#436: a stale hidden report after Ctrl+C does not route type-ahead to the program; a new question does', () => { + const f = fixture(); + try { + f.begin('read -s secret'); + const asked = Date.now(); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.type('ls'); + assert.equal(f.text(), 'ls', 'type-ahead for the prompt stays in the composer'); + assert.deepEqual(f.written, []); + // A program that survived the interrupt and asks again: that question is new, and keys are its own. + f.tick(50); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.type('x'); + assert.deepEqual(f.written, ['x'], 'keys typed for the new question are its own'); + assert.equal(f.text(), 'ls', 'what was typed before it began stays the draft'); + } finally { f.dispose(); } +}); + +test('#436 review: a question still open after the type-ahead window (the program read through Ctrl+C) applies again', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('stubborn-prompt'); + const asked = Date.now(); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + // The session never saw the wait close: its word is still the hidden question from before the stop. + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.type('ls'); + assert.equal(f.text(), 'ls', 'within the window: type-ahead for the prompt'); + assert.deepEqual(f.written, []); + // The window closes (2 s after the stop) with no newer word from the session. + f.tick(2000); + t.mock.timers.tick(2000); + assert.equal(f.app['inputState']?.request?.mode, 'hidden', 'the open hidden question applies again'); + f.type('pw'); + assert.deepEqual(f.written, ['p', 'w'], 'new keys go straight to the program, never drawn'); + assert.equal(f.text(), 'ls', 'the draft typed as type-ahead is never handed over as a reply'); + } finally { f.dispose(); } +}); diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index ca620c7d..55592c6f 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -40,6 +40,39 @@ test('a shell reader owns input until the command returns, without claiming a wa watch.dispose(); }); +test('#436: after the shell says the command is done, its prompt hooks are never read as the command waiting', async () => { + // A slow hook holds the terminal, idle, with line settings: exactly what a builtin read looks like. + let resolve!: (sample: TerminalSample) => void; + const changes: InputState[] = []; + const watch = new InputWatch({probe: () => new Promise(done => { resolve = done; }), tty: () => '/dev/ttys9'}); + watch.on('change', state => changes.push(state)); + watch.onExec('make', 0); + const looking = watch.lookNow(); + for (let index = 0; index < 3; index++) await Promise.resolve(); + watch.onDone(50); + resolve({modes: {canonical: true, echo: false, signals: true}, foreground: [{pid: 1, state: 'Ss+', cpuMs: 1, command: 'zsh'}]}); + await looking; + assert.equal(watch.state.request, undefined, 'a sample under way when the command finished is dropped'); + assert.ok(changes.every(state => !state.request), 'never reported, not even for a moment'); + await watch.lookNow(); + assert.equal(watch.state.request, undefined, 'and nothing is sampled until the prompt'); + // An open wait is closed by done: its time is counted to that moment. + const asked = new InputWatch({probe: async () => ({modes: {canonical: true, echo: false, signals: true}, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'sudo'}]}), tty: () => '/dev/ttys9', now: () => 100}); + asked.onExec('sudo -k true', 0); + await asked.lookNow(); + assert.equal(asked.state.request?.mode, 'hidden'); + asked.onDone(400); + assert.equal(asked.state.request, undefined); + assert.equal(asked.state.timing.waitedMs, 400, 'the wait counts to the moment the command finished'); + // The next command is watched again. + asked.onPrompt(500); + asked.onExec('read -s x', 600); + await asked.lookNow(); + assert.equal(asked.state.request?.mode, 'hidden'); + watch.dispose(); + asked.dispose(); +}); + test('open line: a prompt left without a newline is open; a finished line, a rewrite or cursor drawing is not', () => { const line = new OpenLine(); line.push('Pass 1: Checking inodes\r\nPadding at end of inode bitmap is not set. Fix? '); diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts new file mode 100644 index 00000000..dba3aabb --- /dev/null +++ b/tests/inputOwnershipDone.test.ts @@ -0,0 +1,330 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {InputWatch} from '../src/session/InputWatch.js'; +import type {InputRequest, InputState, LookResult} from '../src/session/inputState.js'; +import type {TerminalSample} from '../src/session/terminalProbe.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426 (#433) with #436 (#437). Keys typed while NMSh cannot yet tell who reads them are held for a look; the shell's + * `done` marker says the command finished while its prompt hooks still run, before the prompt itself arrives. Held + * typing must then settle as typing for a command that ended: a possible password or a reply is discarded, never + * shown, queued, sent or run; only plain type-ahead is kept for the next command. Evidence of hidden input is never + * lost because the command finished during the look. Stale questions, interrupts, process exit and observations + * that arrive late change none of this. + * + * Deterministic: looks are promises the test settles, samples are given by the test, timers are mocked. No shell runs. + */ + +const SECRET = 'QSECRETQ'; +const SLOW_MS = 800; +const hiddenModes = {canonical: true, echo: false, signals: true}; +const lineModes = {canonical: true, echo: true, signals: true}; +const settled = () => new Promise(done => setImmediate(done)); +const turns = async () => { for (let index = 0; index < 4; index += 1) await Promise.resolve(); }; + +// ------------------------------------------------------------------ the session's watch + +function watchWith(probe: () => Promise) { + let probes = 0; + const changes: InputState[] = []; + const watch = new InputWatch({probe: () => { probes += 1; return probe(); }, tty: () => '/dev/ttys9'}); + watch.on('change', state => changes.push(state)); + return {watch, changes, probes: () => probes}; +} + +test('watch: once the shell says the command is done, a look answers idle and never probes', async () => { + const {watch, changes, probes} = watchWith(async () => ({modes: lineModes, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'make'}]})); + try { + watch.onExec('make', 0); + watch.onDone(10); + assert.equal(await watch.lookNow(), 'idle', 'the shell owns typing again'); + assert.equal(probes(), 0, 'its prompt hooks are not sampled'); + assert.ok(changes.every(state => !state.request)); + } finally { watch.dispose(); } +}); + +test('watch: hidden input seen by a look that the command finished during is still reported hidden, never applied', async () => { + let resolve!: (sample: TerminalSample) => void; + const {watch, changes} = watchWith(() => new Promise(done => { resolve = done; })); + try { + watch.onExec('read -rs -t 1 pw', 0); + const looking = watch.lookNow(); + await turns(); + // The read gave up and the shell's hooks began while the probe was under way; the probe had seen echo off. + watch.onDone(50); + resolve({modes: hiddenModes, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await looking, 'hidden', 'the evidence of a possible password is not dropped'); + assert.equal(watch.state.request, undefined, 'but no question is reported for a command that finished'); + assert.ok(changes.every(state => !state.request)); + assert.equal(await watch.lookNow(), 'idle', 'a later look says the command is over'); + } finally { watch.dispose(); } +}); + +test('watch: visible evidence from a look the command finished during answers idle; the next command is watched again', async () => { + let resolve!: (sample: TerminalSample) => void; + const {watch, changes, probes} = watchWith(() => new Promise(done => { resolve = done; })); + try { + watch.onExec('make', 0); + const looking = watch.lookNow(); + await turns(); + watch.onDone(50); + // A slow prompt hook: line settings, echo on, the shell holding its terminal (zsh and Bash show exactly this). + resolve({modes: lineModes, foreground: [{pid: 1, state: 'Ss+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await looking, 'idle'); + assert.equal(watch.state.request, undefined, 'a hook is never a question'); + assert.ok(changes.every(state => !state.request)); + watch.onPrompt(60); + watch.onExec('read -s x', 70); + const next = watch.lookNow(); + await turns(); + resolve({modes: hiddenModes, foreground: [{pid: 2, state: 'S+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await next, 'hidden', 'the next command is looked at afresh'); + assert.equal(probes(), 2); + } finally { watch.dispose(); } +}); + +// ------------------------------------------------------------------ the frontend's hold + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + 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}); + 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 = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + /** Nothing anywhere a person, a log or the shell could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); + assert.equal(written.some(entry => entry.includes(secret)), false, `${what}: not written to the terminal`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + return {...frame, written, submitted, type, text, adds, screen, transcript, state, nowhere, + enter: () => frame.app['onInput']('\r'), interrupts: () => interrupts, looks: () => looks, + answer: (result: LookResult | undefined) => settle(result)}; +} + +test('done before the prompt: a reply the look saw as hidden is discarded, never drawn, queued, sent or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs -t 1 pw'); + f.type(SECRET); + f.enter(); + f.nowhere(SECRET, 'held'); + // The look saw echo off, but the read had already given up: no question ever arrives; then the shell says done. + f.answer('hidden'); + await settled(); + assert.equal(f.looks(), 2, 'hidden with no question: one more look'); + f.nowhere(SECRET, 'looking again'); + f.answer('idle'); + await settled(); + f.nowhere(SECRET, 'after done'); + assert.match(f.transcript(), /discarded, not run/u, 'a note says what happened, without the text'); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after the prompt'); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.submitted, []); + } finally { f.dispose(); } +}); + +test('done before the prompt: typing held past the slow mark is discarded, never run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('./deploy.sh'); + f.type('yes'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.answer('idle'); + await settled(); + assert.equal(f.text(), '', 'not left in the composer'); + assert.deepEqual(f.adds(), [], 'not queued'); + f.complete(0); + await settled(); + assert.deepEqual(f.submitted, [], 'never run: NMSh never learned whether it was a reply'); + assert.deepEqual(f.written, []); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +test('done before the prompt: plain type-ahead the look answered quickly is kept for the next command, once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('make'); + f.type('echo next'); + f.answer('idle'); + await settled(); + assert.equal(f.text(), 'echo next', 'the next command\'s draft, visible'); + assert.deepEqual(f.written, [], 'nothing went to the program that finished'); + f.complete(0); + await settled(); + assert.equal(f.text(), 'echo next', 'it survives the prompt'); + f.enter(); + assert.deepEqual(f.submitted, ['echo next'], 'and runs once, on the person\'s Enter'); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('type-ahead after answering a hidden question, while a look still sees hidden input, is never run or dropped: it waits for the person', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s x'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type(SECRET); + f.enter(); + assert.equal(f.written.join(''), `${SECRET}\r`, 'the answer went to the hidden read'); + f.tick(20); + // The next command, typed at once: the read has its answer but has not returned yet. + f.type('echo NEXT'); + f.enter(); + // A look still sees hidden input (the question just answered, or a second one with no prompt: NMSh cannot tell), + // then the shell says the command is done. A time window after the answer is no proof that this is not a reply. + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(0); + await settled(); + assert.deepEqual(f.adds(), [], 'not queued'); + assert.deepEqual(f.submitted, [], 'not run by itself'); + assert.equal(f.text(), '', 'not drawn'); + assert.match(f.screen(), /held, not run · Tab shows it · Esc discards it/u, 'kept for the person, who knows what it was'); + f.app['onInput']('\t'); + assert.equal(f.text(), 'echo NEXT', 'Tab shows it in the composer'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NEXT'], 'an ordinary command is never lost: it runs once, on the person\'s Enter'); + f.nowhere(SECRET, 'the answer itself'); + } finally { f.dispose(); } +}); + +test('review: a second hidden question after the first answer: its reply is never run when the command ends', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s -t 1 b'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('first'); + f.enter(); + // The second read asks with no prompt; NMSh has not reported it yet when its reply is typed, well after the first. + f.tick(1500); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'the second reply'); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +test('review: a new hidden question reported after the answer ends the grace at once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true', 'Password:'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('wrong'); + f.enter(); + f.tick(20); + // "Sorry, try again." and a new question, reported and then closed before the next typing (the probe lagged). + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.state(); + f.tick(20); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'the retry'); + } finally { f.dispose(); } +}); + +test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs pw'); + const asked = Date.now(); + f.tick(200); + f.type(SECRET); + f.enter(); + f.app['onInput']('\u0003'); + f.tick(20); + assert.equal(f.interrupts(), 1); + f.nowhere(SECRET, 'after Ctrl+C'); + // The interrupted read's late evidence: the look answers, and the question it ended is reported after the stop. + f.answer('hidden'); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + await settled(); + f.nowhere(SECRET, 'after the late reports'); + // What is typed after Ctrl+C is type-ahead for the prompt: never held, never routed to the read that ended. + f.type('echo Y'); + f.state(); + f.complete(130); + await settled(); + assert.equal(f.text(), 'echo Y'); + f.enter(); + assert.deepEqual(f.submitted, ['echo Y']); + assert.deepEqual(f.written, []); + f.nowhere(SECRET, 'at the end'); + } finally { f.dispose(); } +}); + +test('process exit with the look unanswered, then late evidence: nothing reaches the next prompt or the next command', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true', 'Password:'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after exit'); + assert.match(f.transcript(), /discarded, not run/u); + // The old look answers after the command is gone, then a new command starts: neither sees the old typing. + f.answer('hidden'); + await settled(); + f.begin('cat'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + f.nowhere(SECRET, 'after the late answer and the next command'); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts new file mode 100644 index 00000000..092db791 --- /dev/null +++ b/tests/inputOwnershipSecrets.test.ts @@ -0,0 +1,428 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import type {InputRequest, LookResult} from '../src/session/inputState.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426 security review for the v0.22 release: hidden replies (passwords) next to the timing NMSh cannot observe. + * + * After NMSh sends a hidden answer, a look can still see hidden input: the question just answered (its read has not + * returned yet) or a second question asked with no prompt. NMSh cannot tell these apart, and a time window after the + * answer is not a security boundary. So typing that began while a look saw hidden input is never run by itself: + * a question NMSh then sees gets it as its reply, and a command that ends first leaves it held, hidden, at the prompt + * until the person chooses (Tab shows it, Esc discards it). A reply typed and entered before a question with no prompt + * existed is queued like any command (NMSh could not know); once the command asks for hidden input, such entries wait + * for approval instead of running after it. + * + * Deterministic: looks are promises the test settles, the clock and timers are the test's. No shell runs. + */ + +const SECRET = 'QSECRETQ'; +const SLOW_MS = 800; +const TAB = '\t'; +const settled = () => new Promise(done => setImmediate(done)); +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +function fixture() { + const frame = executionFrameFixture(110, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + const dispatched: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + // The session's real queue: entries added here are what the session would run after the command. + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); frame.owner.request(op); }, configurable: true}); + frame.owner.on('state', (_state, news) => { if (news.dispatched) dispatched.push(news.dispatched.text); }); + 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 = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const hidden = () => state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + /** Never run as a command: not submitted, not dispatched from the queue, not written to the shell. */ + const neverRun = (secret: string, what: string) => { + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run from the composer`); + assert.equal(dispatched.some(entry => entry.includes(secret)), false, `${what}: not run from the queue`); + }; + /** Nothing anywhere a person, a log or the shell could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + neverRun(secret, what); + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(written.some(entry => entry.includes(secret)), false, `${what}: not written to the terminal`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + /** Answer a hidden question NMSh reported, as the person types it. */ + const answerHidden = (reply: string) => { + hidden(); + frame.tick(200); + type(reply); + frame.app['onInput']('\r'); + assert.equal(written.join('').endsWith(`${reply}\r`), true, 'the answer went to the hidden question'); + }; + return {...frame, written, submitted, dispatched, type, text, adds, screen, transcript, state, hidden, neverRun, nowhere, answerHidden, + key: (data: string) => frame.app['onInput'](data), enter: () => frame.app['onInput']('\r'), + interrupts: () => interrupts, looks: () => looks, answer: (result: LookResult | undefined) => settle(result)}; +} + +/** Consecutive questions with no prompt: the second gives up (a timed read) before NMSh could report it. */ +async function secondReplyHeldAtPrompt(f: ReturnType, reply: string) { + f.begin('read -s a; read -s -t 1 b'); + f.answerHidden('first'); + f.tick(20); + // Typed at once for the second question, which has no prompt; NMSh has not reported it. + f.type(reply); + f.enter(); + f.nowhere(reply, 'held'); + // The look sees echo off: the answered question or the new one, NMSh cannot tell. + f.answer('hidden'); + await settled(); + assert.equal(f.looks(), 2, 'hidden input with no question reported: one more look'); + // The second read gives up; the shell says the command is done. + f.tick(500); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); +} + +test('consecutive hidden questions with no prompt: a reply the second one never took is not run, shown or queued; it waits at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + f.nowhere(SECRET, 'at the prompt'); + assert.match(f.screen(), HELD, 'the composer says typing is held and how to choose'); + // Held is not lost to time: no timer settles it. + t.mock.timers.tick(60_000); + await settled(); + f.nowhere(SECRET, 'a minute later'); + assert.match(f.screen(), HELD); + // (A lone ESC byte waits in the key decoder for a possible sequence; the key itself is what Esc delivers.) + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + assert.doesNotMatch(f.screen(), HELD); + assert.match(f.transcript(), /discarded, not sent or run/u, 'a note says what happened, without the text'); + } finally { f.dispose(); } +}); + +test('typing held at the prompt is the person\'s to show: Tab puts it in the composer, never run until their own Enter', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, 'echo NEXT'); + f.neverRun('echo NEXT', 'held'); + assert.equal(f.text(), '', 'not drawn'); + f.key(TAB); + assert.equal(f.text(), 'echo NEXT', 'shown for review, without its Enter'); + f.neverRun('echo NEXT', 'shown'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NEXT'], 'an ordinary command is never lost; it runs once, on the person\'s Enter'); + } finally { f.dispose(); } +}); + +test('typing held at the prompt: more keys stay held, Ctrl+C discards it all, and nothing reaches the shell or the debug key log', async t => { + const f = fixture(); + const logged: string[] = []; + const saved = process.env.NMSH_DEBUG_KEYS; + try { + t.mock.timers.enable({apis: ['setTimeout']}); + Object.defineProperty(f.app, 'logEditorKeyInput', {value: (data: string) => { logged.push(data); }, configurable: true}); + process.env.NMSH_DEBUG_KEYS = '1'; + await secondReplyHeldAtPrompt(f, SECRET); + f.type('MORE'); + f.enter(); + f.nowhere(SECRET, 'more keys'); + f.nowhere('MORE', 'more keys'); + assert.match(f.screen(), HELD); + f.key('\u0003'); + f.nowhere(SECRET, 'after Ctrl+C'); + f.nowhere('MORE', 'after Ctrl+C'); + assert.doesNotMatch(f.screen(), HELD); + assert.equal(logged.join('').includes(SECRET) || logged.join('').includes('MORE'), false, 'held keys are never logged'); + } finally { + if (saved === undefined) delete process.env.NMSH_DEBUG_KEYS; else process.env.NMSH_DEBUG_KEYS = saved; + f.dispose(); + } +}); + +test('typing held at the prompt never reaches the next command (one started from the queue), and late looks change nothing', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + f.begin('make'); + f.answer('visible'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + f.nowhere(SECRET, 'while the next command runs and asks'); + f.complete(0); + await settled(); + f.nowhere(SECRET, 'after it'); + assert.match(f.screen(), HELD, 'still the person\'s to choose'); + } finally { f.dispose(); } +}); + +test('a second hidden question NMSh sees while the reply is held gets it, never drawn', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s b'); + f.answerHidden('first'); + f.tick(20); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.tick(300); + f.hidden(); + await settled(); + // Keys reach a hidden question one by one, as a terminal sends them. + assert.equal(f.written.join(''), `first\r${SECRET}\r`, 'the second question gets its reply, once, in order'); + f.complete(0); + await settled(); + f.neverRun(SECRET, 'after'); + assert.equal(f.screen().includes(SECRET) || f.text().includes(SECRET) || f.adds().some(entry => entry.includes(SECRET)), false); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('an ordinary command typed after a hidden answer, once the terminal echoes again, is type-ahead: never held, never discarded', 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'], 'queued for after the command'); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['make test'], 'and runs after it, once'); + assert.doesNotMatch(f.screen(), HELD); + } 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 => { + 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'); + } finally { f.dispose(); } +}); + +test('entries queued before the command started are the person\'s plan: a hidden question leaves them as they were', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + // Both queued while an earlier command ran; the first then runs and asks for a password. + f.begin('make build'); + f.app['session'].queue({op: 'add', text: 'sudo -k make install'}); + f.app['session'].queue({op: 'add', text: 'make test'}); + f.tick(100); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['sudo -k make install']); + f.tick(10); + f.begin('sudo -k make install', 'Password:'); + f.tick(100); + f.hidden(); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'make test')?.condition, undefined); + } finally { f.dispose(); } +}); + +test('a command queued with Ctrl+Q while the hidden question is shown is the person\'s: re-reports of that question leave it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k make install', 'Password:'); + f.tick(100); + const asked = {since: Date.now(), confidence: 'likely', mode: 'hidden'} as const; + f.state(asked); + await settled(); + // Ctrl+Q: the composer takes the next command for the queue; the program never sees those keys. + f.key('\u0011'); + f.type('make test'); + f.enter(); + assert.deepEqual(f.adds(), ['make test']); + // The same question, reported again (the watch's confidence changes as it samples). + f.tick(500); + f.state({...asked, confidence: 'confirmed'}); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'make test')?.condition, undefined, 'not made to wait for approval'); + assert.doesNotMatch(f.transcript(), /asked for hidden input/u); + } finally { f.dispose(); } +}); + +test('a look slower than the slow mark after a hidden answer, then the command ends: the typing is discarded, never shown or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s -t 1 b'); + f.answerHidden('first'); + f.tick(20); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after the slow look'); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +// ------------------------------------------------------------------ release gate review (v0.22.0, #444) + +test('gate: held typing is released only as typed text; a Ctrl+Q typed while held is not replayed by Tab, so nothing is queued or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + // Ctrl+Q at an idle prompt would queue the composer's text, and an idle queue runs it at once. + f.key('\u0011'); + f.key(TAB); + await settled(); + assert.equal(f.text(), SECRET, 'Tab shows the held text in the composer: the person chose to see it'); + assert.deepEqual(f.adds(), [], 'not queued'); + f.neverRun(SECRET, 'after Ctrl+Q and Tab'); + } finally { f.dispose(); } +}); + +test('gate: while a command runs, a Ctrl+Q typed while typing is held is not replayed by Tab either', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs pw'); + f.type(SECRET); + f.answer('unknown'); await settled(); + f.answer('unknown'); await settled(); + assert.match(f.screen(), /Can.t tell whether read is reading your typing/u); + f.key('\u0011'); + f.key(TAB); + await settled(); + assert.deepEqual(f.adds(), [], 'not queued'); + f.complete(0); await settled(); + f.neverRun(SECRET, 'after the command'); + } finally { f.dispose(); } +}); + +/** A password typed (and, with `enter`, entered) before sudo prints its prompt; the person then answers the prompt. */ +async function typedBeforeThePrompt(f: ReturnType, enter: boolean) { + f.begin('sudo -k true'); + f.tick(50); + f.type(SECRET); + f.answer('visible'); await settled(); + if (enter) f.enter(); + // sudo prints its prompt and turns echo off; NMSh sees the hidden question. + f.tick(300); + f.app['onShellData']('Password:'); + if (enter) { f.answer('hidden'); await settled(); } + f.hidden(); await settled(); + assert.equal(f.screen().includes(SECRET), false, 'not shown while the program asks'); + f.type('pw'); + f.enter(); + f.complete(0); await settled(); +} + +test('gate: a password typed before the program asks is never left as a draft one Enter from running: held at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await typedBeforeThePrompt(f, false); + assert.equal(f.text(), '', 'not back in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not shown again when the command ends'); + assert.match(f.screen(), HELD, 'held for the person'); + f.enter(); + await settled(); + f.neverRun(SECRET, 'after an Enter at the prompt'); + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + } finally { f.dispose(); } +}); + +test('gate: a password and Enter typed before the program asks: not run, not left in the composer', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await typedBeforeThePrompt(f, true); + assert.equal(f.text(), '', 'not back in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not shown again'); + f.enter(); + await settled(); + f.neverRun(SECRET, 'after an Enter at the prompt'); + assert.deepEqual(f.adds(), []); + } 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 => { + 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'); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts new file mode 100644 index 00000000..da7fc929 --- /dev/null +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #426 review, through the real composer, PTY, session service and shell: a command reads a password, works with the + * terminal echoing, then reads a second password with no prompt. The second reply, typed and entered before that + * question exists, can only be queued (nothing asked yet; documented limit). Once the command asks for hidden input the + * entry waits for approval: when the command succeeds it never runs, so the password reaches neither NMSh's journal nor + * the shell's own history file, and the shell never reports it as a command. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); +const SECRET = 'QZSECRETZQ'; + +/** The shell's history written as each command is entered, so the test reads exactly what the shell recorded. */ +const HISTORY = { + zsh: ['.zshrc', '.zhist', 'HISTFILE=~/.zhist\nHISTSIZE=1000\nSAVEHIST=1000\nsetopt INC_APPEND_HISTORY\n'], + bash: ['.bashrc', '.bhist', 'HISTFILE=~/.bhist\nshopt -s histappend\nPROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}"\n'], +} as const; + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a reply entered before a second hidden question with no prompt never runs as a command or reaches history`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [rc, histfile, content] = HISTORY[shell]; + writeFileSync(join(sandbox.home, rc), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + 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); + 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); + // 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); + 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. + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.doesNotMatch(strip(frontend.output.slice(typed)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such)|(?:not found|No such)[^\\n]*${SECRET}`, 'u'), + 'the shell never saw it as a command'); + 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); + } finally { await sandbox.dispose(); } + }); + + test(`live ${shell}: a password typed before the program prints its prompt is never left one Enter from running`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [rc, histfile, content] = HISTORY[shell]; + writeFileSync(join(sandbox.home, rc), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + // As sudo does: a moment's work, then a prompt and a read with echo off. + const command = `sh -c 'sleep 1; printf "QA-%s: " Password; stty -echo; IFS= read -r pw; stty echo; printf "\\nLEN=%s\\n" "\${#pw}"'`; + frontend.pty.write(`${command}\r`); + await pause(300); + // Typed before the prompt exists (no Enter): NMSh can only draw it, as a terminal would echo it. + for (const char of SECRET) { frontend.pty.write(char); await pause(15); } + await frontend.waitFor(/QA-Password: /u, mark, 20_000); + await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + // From here on the earlier typing must never be drawn again, and it must end up held, not in the composer. + const asked = frontend.mark; + for (const char of 'answer') { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/LEN=6/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'); + await frontend.waitFor(/held, not run · Tab shows it · Esc discards it/u, asked, 10_000); + // An Enter at the prompt does not run what was typed before the question. + frontend.pty.write('\r'); + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.equal(strip(frontend.output.slice(asked)).includes(SECRET), false, 'never drawn again once the program asked'); + 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'); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/promptHookLive.test.ts b/tests/promptHookLive.test.ts new file mode 100644 index 00000000..d61721d0 --- /dev/null +++ b/tests/promptHookLive.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #436: after a command ends the shell runs its prompt hooks (the person's own and NMSh's) before it reports its + * prompt. A slow hook holds the terminal, idle, exactly as a builtin `read` does, and the input watch used to report + * it as the finished command waiting for a line: a draft typed meanwhile was then taken for a reply and discarded + * when the prompt came (or, after Ctrl+C, lost in the shell). The shell now says the command is done as its first + * hook, and nothing after that is read as the command's input. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +/** A prompt hook of the person's that holds the shell, idle, for 2 s once ~/slow exists (startup stays fast). */ +const SLOW_HOOK = { + zsh: ['.zshrc', 'zmodload zsh/zselect\nnmsh_test_slow() { [[ -f ~/slow ]] && zselect -t 200; return 0 }\nprecmd_functions+=(nmsh_test_slow)\n'], + bash: ['.bashrc', 'mkfifo ~/.slow-wait 2>/dev/null; exec 9<>~/.slow-wait\nnmsh_test_slow() { [[ -f ~/slow ]] && read -t 2 -u 9; return 0; }\nPROMPT_COMMAND=nmsh_test_slow\n'], +} as const; + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a slow prompt hook is never taken for the command reading input; a draft typed during it survives`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [file, content] = SLOW_HOOK[shell]; + writeFileSync(join(sandbox.home, file), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + writeFileSync(join(sandbox.home, 'slow'), ''); + const mark = frontend.mark; + frontend.pty.write('true\r'); + // The command ends at once; the hook then holds the shell for 2 s. Give the watch time to look during it. + await pause(900); + for (const char of 'echo NEXT-HOOK') { frontend.pty.write(char); await pause(20); } + await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15_000, 'the prompt after the slow hook'); + await pause(400); + const shown = strip(frontend.output.slice(mark)); + assert.doesNotMatch(shown, /[Ww]aiting for input/u, 'the shell running its own hook is not the command waiting'); + assert.doesNotMatch(shown, /reply was discarded/u, 'the draft was never taken for a reply'); + frontend.pty.write('\r'); + await frontend.waitFor(/NEXT-HOOK/u, mark, 15_000); + await until(async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records) + .filter(record => record.command === 'echo NEXT-HOOK').length === 1, 15_000, 'the draft to run once'); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/shellProtocol.test.ts b/tests/shellProtocol.test.ts index e65fd7f7..1dca977a 100644 --- a/tests/shellProtocol.test.ts +++ b/tests/shellProtocol.test.ts @@ -10,6 +10,14 @@ test('only an authenticated reader marker transfers terminal ownership, includin assert.deepEqual(decoder.push('der\u0007Name: '), [{kind: 'reader'}, {kind: 'data', data: 'Name: '}]); }); +test('#436: only an authenticated done marker says the command finished before the prompt hooks run', () => { + const decoder = new ShellProtocolDecoder('token'); + const foreign = '\u001b]777;nmsh;other;done\u0007'; + assert.deepEqual(decoder.push(foreign), [{kind: 'data', data: foreign}], 'another token is ordinary output'); + assert.deepEqual(decoder.push('out\u001b]777;nmsh;token;do'), [{kind: 'data', data: 'out'}]); + assert.deepEqual(decoder.push('ne\u0007\u001b]777;nmsh;token;0;/tmp\u0007'), [{kind: 'done'}, {kind: 'marker', marker: {exitCode: 0, cwd: '/tmp'}}]); +}); + test('extracts split shell markers without leaking them into output', () => { const decoder = new ShellProtocolDecoder('token'); assert.deepEqual(decoder.push('hello\u001B]777;nmsh;tok'), [{kind: 'data', data: 'hello'}]); diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 6daa58ab..6c68ffc6 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -10,7 +10,22 @@ import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.j * command the moment the previous one is shown complete, with no pause; zsh and Bash also type ahead while `read` * still runs. Fish is not held to type-ahead during `read`: fish 4 itself drops keys that are already queued when its * reader starts a terminal query, with any terminal (see docs/development/fish-typeahead.md). + * + * One outcome besides running at once is correct (#426 review): a command typed during a hidden read's answer, when a + * look still saw hidden input and the read's command then ended, may be a reply to a second question with no prompt. + * NMSh holds it at the prompt, never run by itself; the person's Tab shows it and their Enter runs it. It must still + * run exactly once, and only typing ahead during a hidden read may be held. */ +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number): Promise { + assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); + assert.equal(new RegExp(`(? existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); @@ -60,7 +75,11 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { frontend.pty.write(`v${tag}\r`); if (mode === 'after') await frontend.waitFor(new RegExp(`got=v${tag}[\\s\\S]*Completed`, 'u'), mark); frontend.pty.write(`echo NEXT-${tag}\r`); - await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); + await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed|${HELD.source}`, 'u'), mark, 10_000); + if (HELD.test(strip(frontend.output.slice(mark))) && !new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u').test(strip(frontend.output.slice(mark)))) { + await showAndRunHeld(frontend, mode, run, `echo NEXT-${tag}`, mark); + await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); + } assert.match(strip(frontend.output.slice(mark)), new RegExp(`got=v${tag}`, 'u'), 'the answer reached read'); } }