diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..c038dd7e 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,22 @@ 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. 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) 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)); + } + /** * 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 +11846,7 @@ export class TerminalApp { return; } if (!text.trim()) return; - this.enqueue(text); + this.enqueue(text, intent === 'enter'); return; } if (intent === 'send') return; @@ -12067,11 +12095,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..4a15a72a 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -426,3 +426,51 @@ 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: 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'); + 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']); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index da7fc929..314dc3f4 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -101,3 +101,58 @@ 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 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, 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`); + 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(); } + }); +}