Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11757,16 +11757,28 @@ 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);
this.render();
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();
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions tests/inputOwnershipSecrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});
55 changes: 55 additions & 0 deletions tests/inputOwnershipSecretsLive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; <STDIN>'; 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(); }
});
}
Loading