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

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:
bincxz
2026-06-07 13:47:08 +08:00
parent 4b07b4826a
commit 03ba9595c0
2 changed files with 41 additions and 15 deletions

View File

@@ -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, []);
});

View File

@@ -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;