Some checks failed
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 / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
728 lines
27 KiB
TypeScript
728 lines
27 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
createSudoPasswordAutofill,
|
|
getSingleBracketedPasteLine,
|
|
isExplicitSudoPrompt,
|
|
isSudoPasswordPrompt,
|
|
shouldArmSudoPasswordAutofill,
|
|
shouldDismissPasswordAssistOnInput,
|
|
} from "./terminalSudoAutofill";
|
|
|
|
// --- isSudoPasswordPrompt: relaxed — any password/密码/口令 line ending in a
|
|
// colon. Over-matching is safe now because filling requires explicit confirm. ---
|
|
|
|
test("isSudoPasswordPrompt detects sudo and PAM prompts", () => {
|
|
assert.equal(isSudoPasswordPrompt("[sudo] password for alice: "), true);
|
|
assert.equal(isSudoPasswordPrompt("Password: "), true);
|
|
assert.equal(isSudoPasswordPrompt("password for alice: "), true);
|
|
assert.equal(isSudoPasswordPrompt("[sudo: [sudo] password for alice: ] Password: "), true);
|
|
});
|
|
|
|
test("isSudoPasswordPrompt detects localized prompts", () => {
|
|
assert.equal(isSudoPasswordPrompt("[sudo] alice 的密码:"), true);
|
|
assert.equal(isSudoPasswordPrompt("密码:"), true);
|
|
assert.equal(isSudoPasswordPrompt("请输入密码: "), true);
|
|
});
|
|
|
|
test("isSudoPasswordPrompt matches Kylin-style prompts without trailing colon", () => {
|
|
// Kylin Professional: sudo prompt has no [sudo] tag and no trailing colon (#1293)
|
|
assert.equal(isSudoPasswordPrompt("密码"), true);
|
|
assert.equal(isSudoPasswordPrompt("用户 的密码"), true);
|
|
assert.equal(isSudoPasswordPrompt("密码 "), true);
|
|
// Exact prompts from issue #1293 screenshots (sudo -s on Kylin V10)
|
|
assert.equal(isSudoPasswordPrompt("输入密码"), true);
|
|
assert.equal(isSudoPasswordPrompt("Input Password"), true);
|
|
});
|
|
|
|
test("isExplicitSudoPrompt matches Kylin-style prompts", () => {
|
|
// Kylin-style [sudo] prompt without trailing colon
|
|
assert.equal(isExplicitSudoPrompt("[sudo] 密码"), true);
|
|
assert.equal(isExplicitSudoPrompt("[sudo] password for alice"), true);
|
|
});
|
|
|
|
test("handleOutput hints on Kylin screenshot sudo prompts when armed", () => {
|
|
const { autofill, hints, writes } = make();
|
|
autofill.armForCommand("sudo -s");
|
|
autofill.handleOutput("输入密码");
|
|
assert.deepEqual(hints, [true]);
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
|
|
const english = make();
|
|
english.autofill.armForCommand("sudo -s");
|
|
english.autofill.handleOutput("Input Password");
|
|
assert.deepEqual(english.hints, [true]);
|
|
assert.deepEqual(english.writes, []);
|
|
});
|
|
|
|
test("isSudoPasswordPrompt detects color-wrapped prompts", () => {
|
|
assert.equal(isSudoPasswordPrompt("\x1b[32m[sudo] password for alice: \x1b[0m"), true);
|
|
});
|
|
|
|
test("isSudoPasswordPrompt ignores ordinary output", () => {
|
|
assert.equal(isSudoPasswordPrompt("try sudo if the password is required\n"), false);
|
|
assert.equal(isSudoPasswordPrompt("the password was changed\n"), false);
|
|
assert.equal(isSudoPasswordPrompt("sudo: command not found\n"), false);
|
|
});
|
|
|
|
test("isSudoPasswordPrompt refuses concealed prompt text", () => {
|
|
assert.equal(isSudoPasswordPrompt("\x1b[8m[sudo] password for alice: \x1b[0m"), false);
|
|
});
|
|
|
|
// --- arm + hint (confirm-to-fill) ---
|
|
|
|
const make = (password = "secret") => {
|
|
const writes: string[] = [];
|
|
const hints: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
password,
|
|
write: (d) => writes.push(d),
|
|
onHint: (active) => {
|
|
hints.push(active);
|
|
return true; // hint overlay shown successfully
|
|
},
|
|
});
|
|
return { autofill, writes, hints };
|
|
};
|
|
|
|
test("shows a hint (not a fill) when a sudo prompt appears", () => {
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
assert.equal(
|
|
autofill.handleOutput("[sudo] password for alice: "),
|
|
"[sudo] password for alice: ",
|
|
);
|
|
assert.deepEqual(hints, [true]);
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
});
|
|
|
|
test("confirmFill writes the password and clears the hint", () => {
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["secret\n"]);
|
|
assert.deepEqual(hints, [true, false]);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
});
|
|
|
|
test("cancelHint clears the hint without filling", () => {
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
autofill.cancelHint();
|
|
assert.deepEqual(writes, []);
|
|
assert.deepEqual(hints, [true, false]);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
});
|
|
|
|
// --- paste dismisses assist so Enter is not hijacked (#2198) ---
|
|
|
|
test("shouldDismissPasswordAssistOnInput detects paste and typed content", () => {
|
|
assert.equal(shouldDismissPasswordAssistOnInput("remote-secret"), true);
|
|
assert.equal(shouldDismissPasswordAssistOnInput("\x1b[200~remote-secret\x1b[201~"), true);
|
|
assert.equal(shouldDismissPasswordAssistOnInput("x"), true);
|
|
// Enter is confirmFill, not user content
|
|
assert.equal(shouldDismissPasswordAssistOnInput("\r"), false);
|
|
assert.equal(shouldDismissPasswordAssistOnInput("\n"), false);
|
|
// Control keys are handled separately
|
|
assert.equal(shouldDismissPasswordAssistOnInput("\x7f"), false);
|
|
assert.equal(shouldDismissPasswordAssistOnInput("\x1b"), false);
|
|
assert.equal(shouldDismissPasswordAssistOnInput(""), false);
|
|
});
|
|
|
|
test("clipboard paste dismisses pending hint so confirmFill no longer fires", () => {
|
|
// Nested SSH: assist offers jump-host password, user pastes the remote host
|
|
// password from clipboard, then presses Enter — Enter must submit the paste,
|
|
// not append the saved host password (#2198).
|
|
const { autofill, writes, hints } = make("jump-host-password");
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
|
|
assert.equal(
|
|
autofill.dismissOnUserContentInput("\x1b[200~remote-host-password\x1b[201~"),
|
|
true,
|
|
);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
assert.deepEqual(hints, [true, false]);
|
|
|
|
// Simulated Enter after paste must not inject the jump-host password
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("plain multi-char paste dismisses pending hint", () => {
|
|
const { autofill, writes, hints } = make("jump-host-password");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.dismissOnUserContentInput("remote-host-password"), true);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
assert.deepEqual(hints, [true, false]);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("Enter alone does not dismiss via dismissOnUserContentInput", () => {
|
|
const { autofill, hints } = make();
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.dismissOnUserContentInput("\r"), false);
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
assert.deepEqual(hints, [true]);
|
|
});
|
|
|
|
test("Esc soft-dismiss keeps arm so assist can re-open on the same Password prompt", () => {
|
|
const writes: string[] = [];
|
|
const pickerActives: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onPicker: (active) => {
|
|
pickerActives.push(active);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("su root");
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
autofill.cancelHint();
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.equal(autofill.canReshowAssist(), true);
|
|
// Same static prompt: more output without a new line must not auto-reopen
|
|
autofill.handleOutput("");
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
// Explicit re-open (Esc / arrows in the UI)
|
|
assert.equal(autofill.tryReshowAssist(), true);
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
autofill.confirmFill("host");
|
|
assert.deepEqual(writes, ["host-secret\n"]);
|
|
});
|
|
|
|
test("abort hard-disarms so a later bare Password requires a fresh su arm (#2191)", () => {
|
|
// Ctrl+C aborts the remote su. Soft-dismiss would leave dismissedWhileArmed
|
|
// and block the next bare Password: without a leading newline.
|
|
const pickerActives: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: () => {},
|
|
onPicker: (active) => {
|
|
pickerActives.push(active);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
autofill.abort();
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.equal(autofill.canReshowAssist(), false);
|
|
// Stale arm gone: bare Password without a new su must not open the picker
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
// Fresh arm after interrupt works again
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
});
|
|
|
|
test("confirmFill does nothing when no prompt is pending", () => {
|
|
const { autofill, writes } = make();
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("does not arm when the hint cannot be shown (overlay unavailable)", () => {
|
|
// If onHint reports the hint could not render (e.g. autocomplete disabled, no
|
|
// ghost overlay), we must NOT leave a pending arm — otherwise Enter would
|
|
// submit the sudo password with no visible confirmation.
|
|
const writes: string[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
password: "secret",
|
|
write: (d) => writes.push(d),
|
|
onHint: () => false,
|
|
});
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("a bare Password prompt does not hint until a su command is submitted", () => {
|
|
const { autofill, hints } = make();
|
|
autofill.handleOutput("Password: ");
|
|
assert.deepEqual(hints, []);
|
|
// sudo-armed bare Password: is too generic (mysql/ssh); su-armed is expected
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("Password: ");
|
|
assert.deepEqual(hints, []);
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.deepEqual(hints, [true]);
|
|
});
|
|
|
|
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, [true]);
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
});
|
|
|
|
test("no hint without a saved password", () => {
|
|
const { autofill, hints } = make("");
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.deepEqual(hints, []);
|
|
});
|
|
|
|
test("hint fires once across chunked prompt output", () => {
|
|
const { autofill, hints } = make();
|
|
autofill.armForCommand("sudo apt update");
|
|
autofill.handleOutput("[sudo] password ");
|
|
autofill.handleOutput("for alice: ");
|
|
assert.deepEqual(hints, [true]);
|
|
});
|
|
|
|
test("cached sudo then child Enter password does not assist", () => {
|
|
// sudo auth already cached: `sudo mysql -p` goes straight to mysql's
|
|
// "Enter password:" — must not offer the host SSH password.
|
|
const { autofill, hints, writes } = make();
|
|
autofill.armForCommand("sudo mysql -p");
|
|
autofill.handleOutput("Enter password: ");
|
|
assert.deepEqual(hints, []);
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
});
|
|
|
|
test("sudo-scoped bare prompts still assist when armed", () => {
|
|
// Kylin / PAM without [sudo] tag (#1293)
|
|
const kylink = make();
|
|
kylink.autofill.armForCommand("sudo -s");
|
|
kylink.autofill.handleOutput("输入密码");
|
|
assert.deepEqual(kylink.hints, [true]);
|
|
|
|
const scoped = make();
|
|
scoped.autofill.armForCommand("sudo whoami");
|
|
scoped.autofill.handleOutput("password for alice: ");
|
|
assert.deepEqual(scoped.hints, [true]);
|
|
});
|
|
|
|
test("a later non-sudo command disarms the pending hint", () => {
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.deepEqual(hints, [true]);
|
|
autofill.armForCommand("mysql -p"); // non-sudo/su command clears the arm
|
|
assert.deepEqual(hints, [true, false]);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("clears a pending hint when output moves past the prompt", () => {
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
// user never pressed Enter; sudo times out and returns to the shell
|
|
autofill.handleOutput("\r\nsudo: timed out reading password\r\nalice@host:~$ ");
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
assert.deepEqual(hints, [true, false]); // hint was hidden
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []); // a later Enter no longer sends the password
|
|
});
|
|
|
|
test("keeps the hint pending when sudo re-prompts after a wrong password", () => {
|
|
const { autofill, hints } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
autofill.handleOutput("\r\nSorry, try again.\r\n[sudo] password for alice: ");
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
assert.deepEqual(hints, [true]);
|
|
});
|
|
|
|
test("an expired arm shows no hint for a bare prompt", () => {
|
|
const writes: string[] = [];
|
|
const hints: boolean[] = [];
|
|
let now = 1_000;
|
|
const autofill = createSudoPasswordAutofill({
|
|
password: "secret",
|
|
now: () => now,
|
|
write: (d) => writes.push(d),
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("su -");
|
|
now += 31_000;
|
|
autofill.handleOutput("Password: ");
|
|
assert.deepEqual(hints, []);
|
|
});
|
|
|
|
test("handleOutput passes data through unchanged", () => {
|
|
const { autofill } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
assert.equal(
|
|
autofill.handleOutput("Reading package lists...\r\n"),
|
|
"Reading package lists...\r\n",
|
|
);
|
|
});
|
|
|
|
test("getSingleBracketedPasteLine extracts single-line bracketed paste content", () => {
|
|
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\x1b[201~"), "sudo whoami");
|
|
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\rpwd\x1b[201~"), null);
|
|
});
|
|
|
|
test("shouldArmSudoPasswordAutofill arms direct sudo and su commands", () => {
|
|
assert.equal(shouldArmSudoPasswordAutofill("sudo whoami"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("command sudo whoami"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("builtin sudo whoami"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("su"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("su -"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("su root"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("su - root"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("su -l alice"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("command su -"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("builtin su"), true);
|
|
assert.equal(shouldArmSudoPasswordAutofill("echo '[sudo] password for alice:'"), false);
|
|
assert.equal(shouldArmSudoPasswordAutofill("cat sudo.log"), false);
|
|
// Word-boundary: do not arm unrelated commands that only start with "su"
|
|
assert.equal(shouldArmSudoPasswordAutofill("sum file"), false);
|
|
assert.equal(shouldArmSudoPasswordAutofill("suspend"), false);
|
|
assert.equal(shouldArmSudoPasswordAutofill("suricata -T"), false);
|
|
assert.equal(shouldArmSudoPasswordAutofill("echo su"), false);
|
|
});
|
|
|
|
test("shows a hint for su Password prompt when armed", () => {
|
|
// su asks for the target account password with a bare "Password:" line
|
|
// (no [sudo] tag), so arming is required (#2156).
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("su -");
|
|
assert.equal(autofill.handleOutput("Password: "), "Password: ");
|
|
assert.deepEqual(hints, [true]);
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["secret\n"]);
|
|
});
|
|
|
|
test("su to a named user arms the same confirm-to-fill path", () => {
|
|
const { autofill, writes, hints } = make();
|
|
autofill.armForCommand("su alice");
|
|
autofill.handleOutput("Password: ");
|
|
assert.deepEqual(hints, [true]);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["secret\n"]);
|
|
});
|
|
|
|
test("hint mode does not fall back to an unrelated keychain identity", () => {
|
|
// Without a session password, hint must stay silent even when password
|
|
// identities exist — Enter would otherwise paste the wrong secret.
|
|
const writes: string[] = [];
|
|
const hints: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "hint",
|
|
candidates: [
|
|
{ id: "identity:root", label: "Root", username: "root", password: "root-secret" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.deepEqual(hints, []);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("mode off never hints even for explicit sudo prompts", () => {
|
|
const writes: string[] = [];
|
|
const hints: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "off",
|
|
password: "secret",
|
|
write: (d) => writes.push(d),
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.deepEqual(hints, []);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
});
|
|
|
|
test("isPickerPending is false during hint mode", () => {
|
|
const { autofill } = make();
|
|
autofill.armForCommand("sudo whoami");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.equal(autofill.moveSelection(1), false);
|
|
});
|
|
|
|
test("picker mode opens the credential list and fills the selected secret", () => {
|
|
const writes: string[] = [];
|
|
const pickerStates: Array<{ items: { id: string }[]; selectedIndex: number } | null> = [];
|
|
const hints: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
password: "host-secret",
|
|
candidates: [
|
|
{ id: "host", label: "Host", username: "alice", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", username: "root", password: "root-secret" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
onPicker: (active, state) => {
|
|
pickerStates.push(active ? { items: state!.items, selectedIndex: state!.selectedIndex } : null);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPromptPending(), true);
|
|
assert.equal(pickerStates.length, 1);
|
|
assert.equal(pickerStates[0]?.items.length, 2);
|
|
assert.equal(pickerStates[0]?.selectedIndex, 0);
|
|
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
assert.equal(autofill.moveSelection(1), true);
|
|
assert.equal(pickerStates.at(-1)?.selectedIndex, 1);
|
|
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["root-secret\n"]);
|
|
assert.equal(pickerStates.at(-1), null);
|
|
|
|
// sudo never opens the multi-identity picker — host-password hint only
|
|
const sudoPicker = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
password: "host-secret",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: () => {},
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
onPicker: (active, state) => {
|
|
pickerStates.push(active ? { items: state!.items, selectedIndex: state!.selectedIndex } : null);
|
|
return true;
|
|
},
|
|
});
|
|
sudoPicker.armForCommand("sudo whoami");
|
|
sudoPicker.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(sudoPicker.isPickerPending(), false);
|
|
assert.equal(sudoPicker.isPromptPending(), true);
|
|
});
|
|
|
|
test("picker confirmFill can target a specific candidate id", () => {
|
|
const writes: string[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onPicker: () => true,
|
|
});
|
|
autofill.armForCommand("su root");
|
|
autofill.handleOutput("Password: ");
|
|
autofill.confirmFill("identity:root");
|
|
assert.deepEqual(writes, ["root-secret\n"]);
|
|
});
|
|
|
|
test("picker reopens after a wrong password when still armed", () => {
|
|
const writes: string[] = [];
|
|
const pickerActives: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "wrong" },
|
|
{ id: "identity:root", label: "Root", password: "right" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onPicker: (active) => {
|
|
pickerActives.push(active);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
autofill.confirmFill("host");
|
|
assert.deepEqual(writes, ["wrong\n"]);
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
// Remote rejects and re-prompts — picker should open again for another pick
|
|
autofill.handleOutput("\r\nSorry, try again.\r\nPassword: ");
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
autofill.confirmFill("identity:root");
|
|
assert.deepEqual(writes, ["wrong\n", "right\n"]);
|
|
});
|
|
|
|
test("does not re-assist a child password prompt after successful fill", () => {
|
|
// `sudo mysql -p`: after the sudo password is accepted, mysql's own
|
|
// "Enter password:" must not reopen assist with the host secret.
|
|
const { autofill, hints, writes } = make();
|
|
autofill.armForCommand("sudo mysql -p");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.deepEqual(hints, [true]);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["secret\n"]);
|
|
autofill.handleOutput("\r\nEnter password: ");
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
assert.deepEqual(hints, [true, false]); // only the original sudo hint
|
|
});
|
|
|
|
test("picker does not open for Enter password when sudo is already cached", () => {
|
|
const writes: string[] = [];
|
|
const pickerActives: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onPicker: (active) => {
|
|
pickerActives.push(active);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("sudo mysql -p");
|
|
autofill.handleOutput("Enter password: ");
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.deepEqual(pickerActives, []);
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("picker does not open for database-style Password for user prompts after sudo", () => {
|
|
const hints: boolean[] = [];
|
|
const pickerActives: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
password: "host-secret",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: () => {},
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
onPicker: (active) => {
|
|
pickerActives.push(active);
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("sudo -u postgres psql -h db");
|
|
autofill.handleOutput("Password for user postgres: ");
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.deepEqual(pickerActives, []);
|
|
assert.deepEqual(hints, []);
|
|
});
|
|
|
|
test("picker opens for su bare Password after arm", () => {
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:root", label: "Root", password: "root-secret" },
|
|
],
|
|
write: () => {},
|
|
onPicker: () => true,
|
|
});
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.equal(autofill.isPickerPending(), true);
|
|
});
|
|
|
|
test("passwordless su -c ssh does not open picker for remote password", () => {
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:other", label: "Other", password: "other-secret" },
|
|
],
|
|
write: () => {},
|
|
onPicker: () => true,
|
|
});
|
|
autofill.armForCommand("su bob -c 'ssh other-host'");
|
|
autofill.handleOutput("bob@other-host's password: ");
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.equal(autofill.isPromptPending(), false);
|
|
});
|
|
|
|
test("picker mode requires arm before offering the full keychain list", () => {
|
|
// Unarmed explicit [sudo] must not surface every identity — a remote can
|
|
// forge that line. Host-password hint remains allowed (#2156 security).
|
|
const writes: string[] = [];
|
|
const pickerStates: Array<unknown> = [];
|
|
const hints: boolean[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
password: "host-secret",
|
|
candidates: [
|
|
{ id: "host", label: "Host", password: "host-secret" },
|
|
{ id: "identity:other", label: "Other", password: "other-secret" },
|
|
],
|
|
write: (d) => writes.push(d),
|
|
onHint: (a) => {
|
|
hints.push(a);
|
|
return true;
|
|
},
|
|
onPicker: (_active, state) => {
|
|
pickerStates.push(state);
|
|
return true;
|
|
},
|
|
});
|
|
// No armForCommand — forged remote prompt only
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
assert.equal(autofill.isPickerPending(), false);
|
|
assert.deepEqual(pickerStates, []);
|
|
assert.deepEqual(hints, [true]);
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["host-secret\n"]);
|
|
});
|
|
|
|
test("picker mode does not expose passwords in onPicker payload", () => {
|
|
let seen: unknown = null;
|
|
const autofill = createSudoPasswordAutofill({
|
|
mode: "picker",
|
|
candidates: [{ id: "host", label: "Host", password: "top-secret" }],
|
|
write: () => {},
|
|
onPicker: (_active, state) => {
|
|
seen = state;
|
|
return true;
|
|
},
|
|
});
|
|
autofill.armForCommand("su -");
|
|
autofill.handleOutput("Password: ");
|
|
assert.ok(seen && typeof seen === "object");
|
|
const json = JSON.stringify(seen);
|
|
assert.equal(json.includes("top-secret"), false);
|
|
});
|