fix(terminal): hint reliably on explicit [sudo] prompts (#1284)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Sudo hints were flaky for manually typed commands: arming depends on recognizing the submitted line as a command (recordedCommand), which is unreliable while echo round-trips over SSH — so the hint sometimes didn't fire for "sudo -i" / "sudo -s". Explicit "[sudo] …" prompts are sudo-specific, so hint on them without requiring an arm — reliable regardless of command recording. Bare "Password:" still needs the arm window to avoid noise on unrelated prompts (ssh, mysql). Filling still requires explicit Enter, so showing the hint without arming stays safe. Added a colon fast-path so bulk output skips the detection regex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -108,10 +108,20 @@ test("does not arm when the hint cannot be shown (overlay unavailable)", () => {
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("no hint until a sudo command is submitted", () => {
|
||||
test("a bare Password prompt does not hint until a sudo command is submitted", () => {
|
||||
const { autofill, hints } = make();
|
||||
autofill.handleOutput("Password: ");
|
||||
assert.deepEqual(hints, []);
|
||||
});
|
||||
|
||||
test("an explicit [sudo] prompt hints without a recorded sudo command", () => {
|
||||
// The [sudo] tag is sudo-specific, so we hint even when arming didn't fire —
|
||||
// manual typing's recordedCommand is flaky (#1281/#1284), and the hint only
|
||||
// pastes on explicit Enter, so showing it is safe.
|
||||
const { autofill, hints } = make();
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
assert.deepEqual(hints, []);
|
||||
assert.deepEqual(hints, [true]);
|
||||
assert.equal(autofill.isPromptPending(), true);
|
||||
});
|
||||
|
||||
test("no hint without a saved password", () => {
|
||||
@@ -172,7 +182,7 @@ test("keeps the hint pending when sudo re-prompts after a wrong password", () =>
|
||||
assert.deepEqual(hints, [true]);
|
||||
});
|
||||
|
||||
test("an expired arm shows no hint", () => {
|
||||
test("an expired arm shows no hint for a bare prompt", () => {
|
||||
const writes: string[] = [];
|
||||
const hints: boolean[] = [];
|
||||
let now = 1_000;
|
||||
@@ -184,7 +194,7 @@ test("an expired arm shows no hint", () => {
|
||||
});
|
||||
autofill.armForCommand("sudo whoami");
|
||||
now += 31_000;
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
autofill.handleOutput("Password: ");
|
||||
assert.deepEqual(hints, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+
|
||||
// only shows a dismissable hint and never leaks a password to a child program.
|
||||
const SUDO_PROMPT_PATTERN =
|
||||
/(?:^|[\r\n])[^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n::]*[::]\s*$/i;
|
||||
// An explicit sudo prompt carries the sudo-specific "[sudo]" tag. No other tool
|
||||
// prompts this way, so we hint on it WITHOUT requiring an arm — keeping the hint
|
||||
// reliable even when command recording (arming) didn't fire for a manually
|
||||
// typed command (#1284; manual typing's recordedCommand is flaky).
|
||||
const EXPLICIT_SUDO_PROMPT_PATTERN =
|
||||
/(?:^|[\r\n])[^\r\n]*?\[sudo\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n::]*[::]\s*$/i;
|
||||
const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
|
||||
|
||||
export const stripTerminalControlSequences = (data: string): string =>
|
||||
@@ -26,6 +32,11 @@ export const isSudoPasswordPrompt = (data: string): boolean => {
|
||||
return SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
|
||||
};
|
||||
|
||||
export const isExplicitSudoPrompt = (data: string): boolean => {
|
||||
if (CONCEAL_PATTERN.test(data)) return false;
|
||||
return EXPLICIT_SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
|
||||
};
|
||||
|
||||
export const shouldArmSudoPasswordAutofill = (command: string): boolean =>
|
||||
SUDO_COMMAND_PATTERN.test(command);
|
||||
|
||||
@@ -129,14 +140,21 @@ export const createSudoPasswordAutofill = (_options: {
|
||||
tail = "";
|
||||
},
|
||||
handleOutput: (data: string) => {
|
||||
if (!password || armedUntil === Number.NEGATIVE_INFINITY) return data;
|
||||
if (options.now() > armedUntil) {
|
||||
disarm();
|
||||
return data;
|
||||
}
|
||||
if (!password) return data;
|
||||
tail = `${tail}${data}`.slice(-1024);
|
||||
// Fast path for bulk output: a prompt line ends in a colon, so a chunk
|
||||
// with no colon can't be completing one. Skip the regex work unless a hint
|
||||
// is pending (then we must keep watching for the prompt moving on).
|
||||
if (!pending && !data.includes(":") && !data.includes(":")) return data;
|
||||
const lastLine = tail.split(/[\r\n]/).pop() ?? tail;
|
||||
const isPrompt = isSudoPasswordPrompt(lastLine);
|
||||
const armActive =
|
||||
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
|
||||
// Explicit "[sudo] …" prompts are sudo-specific → hint regardless of arm,
|
||||
// so it's reliable even when arming didn't fire (#1284). Bare "Password:"
|
||||
// only hints inside the arm window, to avoid noise on unrelated prompts
|
||||
// (ssh, mysql, …).
|
||||
const isPrompt =
|
||||
isExplicitSudoPrompt(lastLine) || (armActive && isSudoPasswordPrompt(lastLine));
|
||||
if (pending) {
|
||||
// The prompt moved on: a new line arrived and the latest line is no
|
||||
// longer a password prompt (sudo timed out / failed / returned to the
|
||||
@@ -146,13 +164,11 @@ export const createSudoPasswordAutofill = (_options: {
|
||||
return data;
|
||||
}
|
||||
if (isPrompt) {
|
||||
// Only arm a pending confirmation if the hint actually rendered. If the
|
||||
// overlay is unavailable (e.g. autocomplete disabled), don't intercept
|
||||
// Enter — the user would have no visible cue and could leak the password.
|
||||
// Only mark pending if the hint actually rendered. If the overlay is
|
||||
// unavailable (e.g. autocomplete disabled), don't intercept Enter — the
|
||||
// user would have no visible cue and could leak the password.
|
||||
if (options.onHint(true)) {
|
||||
pending = true;
|
||||
} else {
|
||||
disarm();
|
||||
}
|
||||
}
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user