From 1972110c2d0573ddc80301555c8d5abbfd927c4e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:14:04 +0530 Subject: [PATCH 1/2] Input ownership: a line queued during a command that is not a command waits for approval (#426) The documented #426 limit: a reply typed and entered before a question with no prompt exists is queued (nothing has asked yet). If that read gives up before any look sees it and the command succeeds, the entry ran as a shell command. Looks on a quiet command are seconds apart, and macOS cannot report a program's read at all, so a timed read is easily missed. Reproduced live in zsh, Bash and Fish with `sleep 2; perl -e 'alarm 1; '; true`: the typed "reply" ran and entered NMSh's journal (and so the shell's history and its "command not found" output). A line entered with a plain Enter while a command runs, whose first command word is not one this shell knows (alias, function, builtin, keyword, or an executable by path or on PATH, from the same classifier the highlighter uses), would only fail as a command. It now waits for approval and a note says why without repeating the text; an older session service without per-entry approval pauses the queue instead. Real commands typed ahead still run after the command, as before; Ctrl+Q stays an explicit "queue this command". The approval note for a question NMSh later sees still counts entries already waiting. Residual: a reply that is itself a command name ("yes") typed before an unseen timed read can still run; documented. Tests: inputOwnershipSecrets (residual case fails first; ordinary type-ahead unchanged), inputOwnershipSecretsLive (zsh, Bash, Fish; fails first on all three). Deterministic input, queue and paste suites 319/319. --- src/app/TerminalApp.ts | 38 +++++++++++++++--- tests/inputOwnershipSecrets.test.ts | 45 +++++++++++++++++++++ tests/inputOwnershipSecretsLive.test.ts | 52 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..3ab1c16d 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11757,8 +11757,13 @@ export class TerminalApp { this.render(); } - /** Add the composer's text to the queue. */ - private enqueue(text: string): void { + /** + * Add the composer's text to the queue. `typedWhileRunning`: a plain Enter while a command runs, with no question + * NMSh could see. Such a line may be a reply to a read that has not asked yet (#426); if that read gives up unseen + * and the command succeeds, the line would run as a command. One that does not start with a command this shell + * knows would only fail as a command (a password would reach the shell's history), so it waits for approval instead. + */ + private enqueue(text: string, typedWhileRunning = false): void { if (!this.queueAvailable()) { this.output.addFrontendInteraction('/queue', this.session.features.has('queue') ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); @@ -11766,7 +11771,14 @@ export class TerminalApp { return; } this.syncQueuePolicy(); - this.session.queue({op: 'add', text}); + const waits = typedWhileRunning && !this.startsWithCommand(text); + this.session.queue({op: 'add', text, ...(waits && this.queueConditionsAvailable() ? {condition: 'approve' as const} : {})}); + if (waits) { + const program = commandWords(this.running?.command ?? '')[0] ?? 'the command'; + if (!this.queueConditionsAvailable()) this.session.queue({op: 'pause'}); + // The note never repeats the text: it may be a password. + this.output.addFrontendInteraction(program, `The line queued while ${program} runs does not start with a command this shell knows, so it may be a reply for ${program}; it ${this.queueConditionsAvailable() ? 'waits for your approval' : 'pauses the queue'} (/queue) instead of running by itself.`, INFO); + } this.sessionSubmissions.push({text, slash: false}); if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); this.editor.clear(); @@ -11776,6 +11788,19 @@ export class TerminalApp { this.render(); } + /** + * Whether a queued line's first command word is one this shell knows: an alias, function, builtin or keyword, or an + * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. + */ + private startsWithCommand(text: string): boolean { + const first = text.split('\n').find(line => line.trim()) ?? ''; + const token = this.highlighter.tokenize(graphemes(first), this.semanticService.cache) + .find(item => ['Command', 'KnownCommand', 'Builtin', 'Alias', 'Function', 'UnknownCommand'].includes(item.type)); + if (!token || token.type === 'UnknownCommand') return false; + if (token.type !== 'Command') return true; + return token.text.includes('/') || shellAdapter(this.shellId).builtins.has(token.text) || Boolean(resolveCommand(token.text)); + } + /** * Enter, Ctrl+Q (queue) and Ctrl+S (send to the running program). Input ownership comes first: a program that is * waiting for input (InputWatch's evidence) gets Enter, and only an explicit Ctrl+Q queues then. Otherwise, while @@ -11818,7 +11843,7 @@ export class TerminalApp { return; } if (!text.trim()) return; - this.enqueue(text); + this.enqueue(text, intent === 'enter'); return; } if (intent === 'send') return; @@ -12067,11 +12092,12 @@ export class TerminalApp { 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'); + // Entries already waiting (a line that did not start with a command) are counted, not changed; approved ones are left. + const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && !(entry.condition === 'approve' && entry.approved)); 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'}); + for (const entry of typed) if (entry.condition !== 'approve') 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 { diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 092db791..8d72ea2f 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -426,3 +426,48 @@ test('gate: a visible reply queued before NMSh saw the read waits for approval o assert.deepEqual(f.dispatched, [], 'never run after the command'); } finally { f.dispose(); } }); + +/** + * #426 residual (v0.22 stabilization): a reply typed and entered before a question with no prompt existed is queued (NMSh + * cannot know). If that read gives up before any look sees it (a timed `read -t`, between looks that are seconds apart + * on a quiet command, or a program's read macOS cannot report) and the command succeeds, the entry used to run as a + * shell command: a password into the shell's history and its "command not found" output. A line typed while a command + * runs that does not start with a command this shell knows would only fail as a command; it now waits for approval + * instead of running by itself. Ordinary type-ahead (a real command) still runs after the command, as before. + */ +test('#426 residual: a reply queued before a timed prompt-less read NMSh never saw; the read gives up, the command succeeds: not run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + f.type(SECRET); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), [SECRET], 'queued (NMSh could not know)'); + const entry = f.owner.state.entries.find(item => item.text === SECRET); + assert.equal(entry?.condition, 'approve', 'it waits for approval'); + assert.match(f.transcript(), /does not start with a command/u, 'a note says why'); + assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); + // No question was ever reported: the read gave up unseen, and the command succeeded. + f.tick(1500); + f.complete(0); await settled(); + f.neverRun(SECRET, 'after the command succeeded'); + } finally { f.dispose(); } +}); + +test('#426 residual: ordinary type-ahead is unchanged: a real command typed while one runs still runs after it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + f.type('ls -la'); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'ls -la')?.condition, undefined, 'no approval asked'); + f.tick(1500); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['ls -la']); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index da7fc929..539e5a79 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -101,3 +101,55 @@ for (const shell of ['zsh', 'bash'] as const) { } finally { await sandbox.dispose(); } }); } + +/** + * #426 residual, live: a reply typed and entered before a timed read with no prompt asks (here a program's read, which + * macOS cannot report at all). The read gives up and the command succeeds. The line used to run as a command; one that + * does not start with a command this shell knows now waits for approval, while a real command typed the same way still + * runs after it. + */ +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const perl = ['/usr/bin/perl', '/opt/homebrew/bin/perl', '/usr/local/bin/perl'].find(existsSync); +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = supported && Boolean(perl) && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); + test(`live ${shell}: a reply entered before a timed read nobody saw: the read gives up, the command succeeds; never run, a real command still runs`, {skip: available ? false : `${shell} or perl 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}`} : {}); + if (shell !== 'fish') { const [rc, , content] = HISTORY[shell]; writeFileSync(join(sandbox.home, rc), content); } + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + // Answer the terminal queries Fish's reader waits for, as a real terminal does. + frontend.pty.onData(data => { + if (data.includes('\u001b[c') || data.includes('\u001b[0c')) frontend.pty.write('\u001b[?62;22c'); + if (data.includes('\u001b[6n')) frontend.pty.write('\u001b[1;1R'); + }); + try { + await frontend.waitFor(/zsh|bash|fish/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 records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + const command = `sleep 2; ${perl} -e 'alarm 1; '; true`; + const ran = async () => (await records()).filter(record => record.command === command).length; + // The real command first: an entry left waiting for approval would hold anything queued after it. + for (const [typed, runs] of [['echo QA-NEXT-RAN', true], [SECRET, false]] as const) { + const mark = frontend.mark; + const before = await ran(); + frontend.pty.write(`${command}\r`); + await pause(500); + for (const char of typed) { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/1 (?:queued|waiting)/u, mark, 10_000); + await until(async () => await ran() === before + 1, 20_000, 'the command to complete'); + if (runs) { + await until(async () => (await records()).some(record => record.command === typed), 15_000, 'the real command to run after it'); + continue; + } + // 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.match(strip(frontend.output.slice(mark)), /does not start with a command this shell knows/u); + assert.doesNotMatch(strip(frontend.output.slice(mark)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such|Unknown command)|(?:not found|No such|Unknown command)[^\\n]*${SECRET}`, 'u'), + 'the shell never saw it as a command'); + if (shell !== 'fish') assert.equal(readFileSync(join(sandbox.home, HISTORY[shell][1]), 'utf8').includes(SECRET), false, 'not in the shell\'s history'); + } + } finally { await sandbox.dispose(); } + }); +} From 3d606e7a5996eb1d1bbae7aabd2b5466531eadbd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:46:14 +0530 Subject: [PATCH 2/2] Input ownership: a function definition or plain shell syntax typed ahead is never held (#426) CI on 1972110c: commandQueueLive held `qfn() { echo "fn-$1"; }` (zsh, Bash) for approval, because the name being defined is not yet a command. A function definition, and a line with no command word at all (an assignment, a group), are the person's shell input and run as before; only a first command word the shell does not know waits for approval. The live residual test's control used the reading command too: Linux reports that read, and by design every entry typed during a command that asked waits for approval, a real command included. The control now types ahead during a command that reads nothing. --- src/app/TerminalApp.ts | 7 +++++-- tests/inputOwnershipSecrets.test.ts | 15 +++++++++------ tests/inputOwnershipSecretsLive.test.ts | 9 ++++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3ab1c16d..c038dd7e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11790,13 +11790,16 @@ export class TerminalApp { /** * Whether a queued line's first command word is one this shell knows: an alias, function, builtin or keyword, or an - * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. + * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. Shell + * syntax with no command word (an assignment, a group) and a function definition are the person's shell input. */ private startsWithCommand(text: string): boolean { const first = text.split('\n').find(line => line.trim()) ?? ''; + if (/^\s*(?:function\s+\S|[A-Za-z_][\w.:-]*\s*\(\s*\))/u.test(first)) return true; const token = this.highlighter.tokenize(graphemes(first), this.semanticService.cache) .find(item => ['Command', 'KnownCommand', 'Builtin', 'Alias', 'Function', 'UnknownCommand'].includes(item.type)); - if (!token || token.type === 'UnknownCommand') return false; + if (!token) return true; + if (token.type === 'UnknownCommand') return false; if (token.type !== 'Command') return true; return token.text.includes('/') || shellAdapter(this.shellId).builtins.has(token.text) || Boolean(resolveCommand(token.text)); } diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 8d72ea2f..4a15a72a 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -456,16 +456,19 @@ test('#426 residual: a reply queued before a timed prompt-less read NMSh never s } finally { f.dispose(); } }); -test('#426 residual: ordinary type-ahead is unchanged: a real command typed while one runs still runs after it', async t => { +test('#426 residual: ordinary type-ahead is unchanged: commands, definitions and assignments typed while one runs still run after it', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1; read -t 1 QA_REPLY || true'); - f.type('ls -la'); - f.answer('visible'); await settled(); - f.enter(); - f.answer('visible'); await settled(); - assert.equal(f.owner.state.entries.find(item => item.text === 'ls -la')?.condition, undefined, 'no approval asked'); + const typed = ['ls -la', 'qfn() { echo "fn-$1"; }', 'QUEUE_VAR=kept', 'function qfn2; echo hi; end']; + for (const line of typed) { + f.type(line); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + } + for (const line of typed) assert.equal(f.owner.state.entries.find(item => item.text === line)?.condition, undefined, `${line}: no approval asked`); f.tick(1500); f.complete(0); await settled(); assert.deepEqual(f.dispatched, ['ls -la']); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index 539e5a79..314dc3f4 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -126,10 +126,13 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); await frontend.run('echo READY', /READY/u); const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); - const command = `sleep 2; ${perl} -e 'alarm 1; '; true`; - const ran = async () => (await records()).filter(record => record.command === command).length; + const reading = `sleep 2; ${perl} -e 'alarm 1; '; true`; + // The control's command reads nothing: where the platform reports the read (Linux), every entry typed during a + // command that asked waits for approval by design, a real command too. + const quiet = 'sleep 2; true'; // The real command first: an entry left waiting for approval would hold anything queued after it. - for (const [typed, runs] of [['echo QA-NEXT-RAN', true], [SECRET, false]] as const) { + for (const [typed, runs, command] of [['echo QA-NEXT-RAN', true, quiet], [SECRET, false, reading]] as const) { + const ran = async () => (await records()).filter(record => record.command === command).length; const mark = frontend.mark; const before = await ran(); frontend.pty.write(`${command}\r`);