[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
589
components/terminal/runtime/terminalSudoAutofill.ts
Normal file
589
components/terminal/runtime/terminalSudoAutofill.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import type { PasswordPromptAssistMode } from "../../../domain/models";
|
||||
|
||||
const ESCAPE_SEQUENCE = "\\x" + "1b";
|
||||
const BELL_SEQUENCE = "\\x" + "07";
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
const ANSI_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, "g");
|
||||
const OSC_PATTERN = new RegExp(
|
||||
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
|
||||
"g",
|
||||
);
|
||||
// SGR conceal (parameter 8) hides the text it wraps. Refuse to treat concealed
|
||||
// output as a real prompt so a remote can't disguise a fake prompt and trick the
|
||||
// user into revealing the password.
|
||||
const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`);
|
||||
// A line that mentions password/密码/口令 and optionally ends in a colon.
|
||||
// Intentionally broad: filling requires the user to confirm (press Enter), so
|
||||
// over-matching only shows a dismissable hint and never leaks a password to a
|
||||
// child program. The colon is optional because Kylin's sudo prompt doesn't
|
||||
// use one (#1293).
|
||||
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).
|
||||
// Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286).
|
||||
// Colon is optional for Kylin (#1293).
|
||||
const EXPLICIT_SUDO_PROMPT_PATTERN =
|
||||
/(?:^|[\r\n])[^\r\n]*?\[sudo[^\]]*\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n::]*(?:[::]\s*)?$/i;
|
||||
// Arm for direct sudo *and* su commands (#2156). Trailing space/end keeps
|
||||
// `sum`/`suspend`/`suuser` out. `sudo` is checked before bare `su`.
|
||||
const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
|
||||
const SU_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?su(?:\s|$)/;
|
||||
const SUDO_OR_SU_COMMAND_PATTERN =
|
||||
/^\s*(?:builtin\s+|command\s+)?su(?:do)?(?:\s|$)/;
|
||||
// Used after confirm-to-fill: only re-open assist on a real auth retry, not on a
|
||||
// subsequent child-program password prompt (e.g. `sudo mysql -p`).
|
||||
const AUTH_RETRY_FAILURE_PATTERN =
|
||||
/sorry,\s*try\s*again|incorrect\s+password|authentication\s+failure|auth(?:entication)?\s+fail|密码(?:错误|不正确)|认证失败|鉴权失败|口令错误/i;
|
||||
// Sudo without the [sudo] tag (Kylin #1293) still scopes the prompt to the user
|
||||
// ("password for alice", "输入密码"). Generic child-program prompts are excluded:
|
||||
// "Enter password:", "Password for user postgres:", etc.
|
||||
const SUDO_SCOPED_BARE_PROMPT_PATTERN =
|
||||
/(?:password\s+for\b|的密码|输入密码|input\s+password)/i;
|
||||
const CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN =
|
||||
/(?:enter\s+password\b|password\s+for\s+user\b)/i;
|
||||
|
||||
type ArmedCommandKind = "sudo" | "su";
|
||||
|
||||
export const stripTerminalControlSequences = (data: string): string =>
|
||||
data.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "");
|
||||
|
||||
export const isSudoPasswordPrompt = (data: string): boolean => {
|
||||
if (CONCEAL_PATTERN.test(data)) return false;
|
||||
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_OR_SU_COMMAND_PATTERN.test(command);
|
||||
|
||||
export const resolveArmedCommandKind = (command: string): ArmedCommandKind | null => {
|
||||
if (SUDO_COMMAND_PATTERN.test(command)) return "sudo";
|
||||
if (SU_COMMAND_PATTERN.test(command)) return "su";
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Sudo prompts without [sudo] that still look like sudo/PAM, not mysql/psql. */
|
||||
export const isSudoScopedBarePasswordPrompt = (data: string): boolean => {
|
||||
if (CONCEAL_PATTERN.test(data)) return false;
|
||||
const plain = stripTerminalControlSequences(data);
|
||||
if (!isSudoPasswordPrompt(plain)) return false;
|
||||
if (CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN.test(plain)) return false;
|
||||
return SUDO_SCOPED_BARE_PROMPT_PATTERN.test(plain);
|
||||
};
|
||||
|
||||
/**
|
||||
* su typically prints a short bare Password: / 密码: line.
|
||||
* Reject SSH/scp style "user@host's password:" and long child prompts even
|
||||
* while an su command arm is still active (e.g. passwordless su -c ssh).
|
||||
*/
|
||||
export const isSuBarePasswordPrompt = (data: string): boolean => {
|
||||
if (CONCEAL_PATTERN.test(data)) return false;
|
||||
const plain = stripTerminalControlSequences(data).replace(/\s+/g, " ").trim();
|
||||
if (!plain) return false;
|
||||
if (CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN.test(plain)) return false;
|
||||
// SSH/scp/rsync remote password prompts always include user@host.
|
||||
if (plain.includes("@")) return false;
|
||||
if (!isSudoPasswordPrompt(plain)) return false;
|
||||
// Whole line should be essentially the password word (+ optional colon).
|
||||
// Keep a small budget for locale variants like "Password: " / "密码:".
|
||||
if (plain.length > 24) return false;
|
||||
return /^(?:password|passwd|密\s*码|口\s*令)\s*[::]?\s*$/i.test(plain);
|
||||
};
|
||||
|
||||
/** Public picker row — never includes the secret. */
|
||||
export type PasswordPromptPickerItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
/** Internal candidate with password for confirm-to-fill. */
|
||||
export type SudoPasswordAutofillCandidate = PasswordPromptPickerItem & {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type PasswordPromptPickerState = {
|
||||
items: PasswordPromptPickerItem[];
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export type SudoPasswordAutofill = {
|
||||
armForCommand: (command: string) => void;
|
||||
handleOutput: (data: string) => string;
|
||||
/** Confirm with the selected (or host) password, or a specific candidate id. */
|
||||
confirmFill: (candidateId?: string) => void;
|
||||
/** Dismiss the open UI without clearing the su/sudo arm (Esc). */
|
||||
cancelHint: () => void;
|
||||
/**
|
||||
* Hard-abort: hide UI and clear the arm (Ctrl+C / interrupt / disconnect).
|
||||
* Unlike cancelHint, a later Password: line will not re-open assist until
|
||||
* a fresh su/sudo command is armed (#2191).
|
||||
*/
|
||||
abort: () => void;
|
||||
/**
|
||||
* Soft-dismiss when the user pastes or types their own secret so Enter is
|
||||
* not hijacked for confirmFill after clipboard paste (#2198).
|
||||
* Returns true when the assist UI was dismissed.
|
||||
*/
|
||||
dismissOnUserContentInput: (data: string) => boolean;
|
||||
isPromptPending: () => boolean;
|
||||
/** True only while the multi-credential picker UI is open (not the hint). */
|
||||
isPickerPending: () => boolean;
|
||||
/**
|
||||
* True when the user dismissed the UI with Esc but the command arm is still
|
||||
* live and the last line still looks like a password prompt — Esc/↑/↓ can
|
||||
* re-open the assist without re-running su/sudo.
|
||||
*/
|
||||
canReshowAssist: () => boolean;
|
||||
/** Re-open assist after a soft dismiss. Returns whether the UI showed. */
|
||||
tryReshowAssist: () => boolean;
|
||||
/**
|
||||
* Picker mode: move selection while the list is open.
|
||||
* Returns true when the selection changed so callers can consume the key.
|
||||
*/
|
||||
moveSelection: (delta: number) => boolean;
|
||||
updatePassword: (password?: string) => void;
|
||||
updateCandidates: (candidates: SudoPasswordAutofillCandidate[]) => void;
|
||||
updateMode: (mode: PasswordPromptAssistMode) => void;
|
||||
};
|
||||
|
||||
const unwrapBracketedPaste = (data: string): string => {
|
||||
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSinglePastedCommand = (
|
||||
data: string,
|
||||
): { command: string; lineEnding: string } | null => {
|
||||
const match = unwrapBracketedPaste(data).match(/^([^\r\n]+)(\r\n|\r|\n)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
command: match[1],
|
||||
lineEnding: match[2],
|
||||
};
|
||||
};
|
||||
|
||||
export const getSingleBracketedPasteLine = (data: string): string | null => {
|
||||
if (!data.startsWith(BRACKETED_PASTE_START) || !data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return null;
|
||||
}
|
||||
const text = unwrapBracketedPaste(data);
|
||||
if (!text || /[\r\n]/.test(text)) return null;
|
||||
return text;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when terminal input is the user supplying their own password text
|
||||
* (typed char or clipboard paste), not Enter confirmation or Esc/Backspace.
|
||||
*
|
||||
* Used to dismiss password-prompt assist so a later Enter submits what the
|
||||
* user pasted instead of hijacking Enter for the host session password
|
||||
* (nested SSH / jump host, #2198).
|
||||
*/
|
||||
export const shouldDismissPasswordAssistOnInput = (data: string): boolean => {
|
||||
if (!data) return false;
|
||||
// Enter alone is handled by the key handler as confirmFill — do not dismiss.
|
||||
if (data === "\r" || data === "\n" || data === "\r\n") return false;
|
||||
// Bracketed paste always means the user is inserting their own text.
|
||||
if (data.startsWith(BRACKETED_PASTE_START) || data.includes(BRACKETED_PASTE_START)) {
|
||||
return true;
|
||||
}
|
||||
// Plain multi-char paste (no leading ESC / CSI).
|
||||
if (data.length > 1 && !data.startsWith("\x1b")) {
|
||||
return true;
|
||||
}
|
||||
// Single printable character — mirrors the key handler's cancelHint path.
|
||||
// Exclude DEL (0x7f); Backspace/Esc are handled separately and must not
|
||||
// count as "user password content" for onData-side dismissal.
|
||||
const code = data.charCodeAt(0);
|
||||
if (data.length === 1 && code >= 32 && code !== 0x7f) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Arm the autofill when a sudo/su command is submitted. The user's input is sent
|
||||
// to the remote verbatim — we never rewrite it — so the terminal echo and cursor
|
||||
// stay correct.
|
||||
export const prepareSudoAutofillInput = (
|
||||
data: string,
|
||||
recordedCommand: string | null,
|
||||
sudoAutofill: SudoPasswordAutofill | null | undefined,
|
||||
): string => {
|
||||
if (!sudoAutofill) return data;
|
||||
if (data === "\r" || data === "\n") {
|
||||
if (recordedCommand) sudoAutofill.armForCommand(recordedCommand);
|
||||
return data;
|
||||
}
|
||||
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return data;
|
||||
}
|
||||
const pastedCommand = getSinglePastedCommand(data);
|
||||
if (pastedCommand) sudoAutofill.armForCommand(pastedCommand.command);
|
||||
return data;
|
||||
};
|
||||
|
||||
const toPickerItems = (
|
||||
candidates: SudoPasswordAutofillCandidate[],
|
||||
): PasswordPromptPickerItem[] =>
|
||||
candidates.map(({ id, label, username }) => ({ id, label, username }));
|
||||
|
||||
// Confirm-to-fill model: when a sudo/su command is armed and a password prompt is
|
||||
// seen, we DON'T send the password — we raise a hint or picker so the UI can
|
||||
// offer confirmation. The password is only written when the user confirms via
|
||||
// confirmFill(). This makes over-broad detection safe: a misfire just shows a
|
||||
// dismissable UI instead of leaking the password.
|
||||
export const createSudoPasswordAutofill = (_options: {
|
||||
mode?: PasswordPromptAssistMode;
|
||||
/** Hint-mode default password (host session password). */
|
||||
password?: string;
|
||||
/** Picker-mode candidates (host + keychain password identities). */
|
||||
candidates?: SudoPasswordAutofillCandidate[];
|
||||
write: (data: string) => void;
|
||||
/** Show/hide the inline hint. Returns whether the hint actually rendered. */
|
||||
onHint?: (active: boolean) => boolean;
|
||||
/**
|
||||
* Show/hide the credential picker. Returns whether the picker actually
|
||||
* rendered. `state` is null when hiding.
|
||||
*/
|
||||
onPicker?: (active: boolean, state: PasswordPromptPickerState | null) => boolean;
|
||||
now?: () => number;
|
||||
}): SudoPasswordAutofill => {
|
||||
const options = {
|
||||
now: () => Date.now(),
|
||||
onHint: (_active: boolean) => false,
|
||||
onPicker: (_active: boolean, _state: PasswordPromptPickerState | null) => false,
|
||||
..._options,
|
||||
};
|
||||
let mode: PasswordPromptAssistMode = options.mode ?? "hint";
|
||||
let password = options.password ?? "";
|
||||
let candidates: SudoPasswordAutofillCandidate[] = options.candidates ?? [];
|
||||
const armWindowMs = 10_000;
|
||||
let tail = "";
|
||||
let armedUntil = Number.NEGATIVE_INFINITY;
|
||||
let armedKind: ArmedCommandKind | null = null;
|
||||
let pending = false;
|
||||
let selectedIndex = 0;
|
||||
let pendingUi: "hint" | "picker" | null = null;
|
||||
/** True after confirmFill until we see success (non-prompt output) or expire. */
|
||||
let postFillRetry = false;
|
||||
/**
|
||||
* User hit Esc while a prompt assist was open. Keep the arm so they can
|
||||
* re-open (Esc/arrows) or so a real re-prompt can auto-show again — but do
|
||||
* not immediately re-fire on the same static Password: line with no new
|
||||
* output.
|
||||
*/
|
||||
let dismissedWhileArmed = false;
|
||||
|
||||
const hasFillMaterial = (): boolean => {
|
||||
if (mode === "off") return false;
|
||||
// Hint mode only uses the session host password — never an arbitrary
|
||||
// keychain identity (that would silently send the wrong secret on Enter).
|
||||
// Picker mode uses the full candidate list.
|
||||
if (mode === "hint") return Boolean(password);
|
||||
return candidates.length > 0 || Boolean(password);
|
||||
};
|
||||
|
||||
/** Hint / single-password path: session password only (not candidates[0]). */
|
||||
const defaultPassword = (): string => password || "";
|
||||
|
||||
const notifyPicker = (active: boolean): boolean => {
|
||||
if (!active) {
|
||||
return options.onPicker(false, null);
|
||||
}
|
||||
return options.onPicker(true, {
|
||||
items: toPickerItems(candidates),
|
||||
selectedIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const hideUi = () => {
|
||||
if (pendingUi === "hint") options.onHint(false);
|
||||
if (pendingUi === "picker") options.onPicker(false, null);
|
||||
pendingUi = null;
|
||||
};
|
||||
|
||||
const isArmActiveNow = (): boolean =>
|
||||
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
|
||||
|
||||
const lastPromptLine = (): string => tail.split(/[\r\n]/).pop() ?? tail;
|
||||
|
||||
const disarm = () => {
|
||||
armedUntil = Number.NEGATIVE_INFINITY;
|
||||
armedKind = null;
|
||||
postFillRetry = false;
|
||||
dismissedWhileArmed = false;
|
||||
tail = "";
|
||||
selectedIndex = 0;
|
||||
if (pending) {
|
||||
pending = false;
|
||||
hideUi();
|
||||
}
|
||||
};
|
||||
|
||||
const tryShowForCurrentTail = (): boolean => {
|
||||
const armActive = isArmActiveNow();
|
||||
const lastLine = lastPromptLine();
|
||||
// Explicit [sudo] may show host-password hint without an arm; full picker
|
||||
// still requires armed su (allowFullPickerForLine).
|
||||
if (!isArmedPromptLine(lastLine, armActive)) return false;
|
||||
const allowFullPicker = allowFullPickerForLine(lastLine, armActive);
|
||||
if (!showAssist(allowFullPicker)) return false;
|
||||
pending = true;
|
||||
postFillRetry = false;
|
||||
dismissedWhileArmed = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isArmedPromptLine = (line: string, armActive: boolean): boolean => {
|
||||
if (isExplicitSudoPrompt(line)) return true;
|
||||
if (!armActive) return false;
|
||||
// su always prompts with a bare Password: line (not Enter password / DB).
|
||||
if (armedKind === "su") return isSuBarePasswordPrompt(line);
|
||||
// sudo: only sudo-scoped prompts, never generic "Enter password:" from
|
||||
// child programs when sudo credentials are already cached.
|
||||
if (armedKind === "sudo") return isSudoScopedBarePasswordPrompt(line);
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Full keychain picker is only for armed `su` prompts (#2156). Sudo keeps
|
||||
* host-password quick-fill only — a multi-identity list after `sudo …` is
|
||||
* too easy to confuse with a child-program password prompt (or a forged
|
||||
* [sudo] line once auth is cached).
|
||||
*/
|
||||
const allowFullPickerForLine = (line: string, armActive: boolean): boolean => {
|
||||
if (!armActive || armedKind !== "su") return false;
|
||||
return isSuBarePasswordPrompt(line);
|
||||
};
|
||||
|
||||
const showHostPasswordHint = (): boolean => {
|
||||
if (!defaultPassword()) return false;
|
||||
if (options.onHint(true)) {
|
||||
pendingUi = "hint";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param allowFullPicker When false (unarmed explicit [sudo] path), only
|
||||
* the session host password may be offered — never the full keychain list.
|
||||
* A forged remote `[sudo] password…` line must not surface other systems'
|
||||
* secrets even though filling still requires a user click (#2156 review).
|
||||
*/
|
||||
const showAssist = (allowFullPicker: boolean): boolean => {
|
||||
if (mode === "off" || !hasFillMaterial()) return false;
|
||||
if (mode === "picker" && allowFullPicker && candidates.length > 0) {
|
||||
selectedIndex = Math.min(selectedIndex, candidates.length - 1);
|
||||
if (notifyPicker(true)) {
|
||||
pendingUi = "picker";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// hint mode, or picker without arm / without multi candidates
|
||||
return showHostPasswordHint();
|
||||
};
|
||||
|
||||
/** Soft dismiss: hide UI but keep arm + tail so Esc/arrows can re-open. */
|
||||
const softDismissPendingUi = () => {
|
||||
if (!pending) return;
|
||||
pending = false;
|
||||
hideUi();
|
||||
dismissedWhileArmed = isArmActiveNow();
|
||||
if (!dismissedWhileArmed) {
|
||||
armedKind = null;
|
||||
armedUntil = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
armForCommand: (command: string) => {
|
||||
// Clear any prior arm/hint first: a non-sudo/su command must not leave a
|
||||
// stale hint that a later prompt could satisfy.
|
||||
disarm();
|
||||
const kind = resolveArmedCommandKind(command);
|
||||
if (!hasFillMaterial() || !kind) return;
|
||||
armedKind = kind;
|
||||
armedUntil = options.now() + armWindowMs;
|
||||
tail = "";
|
||||
},
|
||||
handleOutput: (data: string) => {
|
||||
if (!hasFillMaterial()) 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).
|
||||
// Also check for password keywords because Kylin's sudo prompt doesn't
|
||||
// end with a colon (#1293).
|
||||
if (
|
||||
!pending &&
|
||||
!data.includes(":") &&
|
||||
!data.includes(":") &&
|
||||
!/(?:\bpassword\b|密码|口令)/i.test(data)
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
const lastLine = lastPromptLine();
|
||||
let armActive = isArmActiveNow();
|
||||
if (!armActive) {
|
||||
postFillRetry = false;
|
||||
dismissedWhileArmed = false;
|
||||
armedKind = null;
|
||||
}
|
||||
// Explicit "[sudo] …" always; su arm accepts bare Password:; sudo arm only
|
||||
// accepts sudo-scoped bare prompts (not generic Enter password from mysql).
|
||||
const isPrompt = isArmedPromptLine(lastLine, armActive);
|
||||
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
|
||||
// shell). Clear the pending UI — otherwise a later Enter would send
|
||||
// the password to whatever is now reading input.
|
||||
if (!isPrompt && /[\r\n]/.test(data)) disarm();
|
||||
return data;
|
||||
}
|
||||
if (isPrompt) {
|
||||
// Soft-dismissed (Esc): do not auto-reopen on the same static prompt
|
||||
// with no new line. A real re-prompt (newline / auth-failure text)
|
||||
// clears the dismiss flag and may show again.
|
||||
if (dismissedWhileArmed) {
|
||||
const looksLikeNewPromptCycle =
|
||||
/[\r\n]/.test(data) || AUTH_RETRY_FAILURE_PATTERN.test(tail);
|
||||
if (!looksLikeNewPromptCycle) return data;
|
||||
dismissedWhileArmed = false;
|
||||
}
|
||||
// After a fill, only re-assist when this looks like a real auth retry
|
||||
// (explicit [sudo] again, or failure text in the tail). A bare
|
||||
// Password: from a child program after successful sudo must not reopen.
|
||||
if (postFillRetry) {
|
||||
const looksLikeAuthRetry =
|
||||
isExplicitSudoPrompt(lastLine)
|
||||
|| (armedKind === "su" && AUTH_RETRY_FAILURE_PATTERN.test(tail))
|
||||
|| (armedKind === "sudo" && (
|
||||
isExplicitSudoPrompt(lastLine) || AUTH_RETRY_FAILURE_PATTERN.test(tail)
|
||||
));
|
||||
if (!looksLikeAuthRetry) {
|
||||
postFillRetry = false;
|
||||
armedUntil = Number.NEGATIVE_INFINITY;
|
||||
armedKind = null;
|
||||
return data;
|
||||
}
|
||||
armActive = true;
|
||||
}
|
||||
// Full picker only with strong evidence the prompt is su/sudo itself.
|
||||
// Unarmed / Kylin bare / ambiguous lines stay host-password hint only.
|
||||
tryShowForCurrentTail();
|
||||
}
|
||||
return data;
|
||||
},
|
||||
confirmFill: (candidateId?: string) => {
|
||||
if (!pending) return;
|
||||
let secret = "";
|
||||
if (candidateId) {
|
||||
secret = candidates.find((c) => c.id === candidateId)?.password ?? "";
|
||||
} else if (pendingUi === "picker" && candidates.length > 0) {
|
||||
secret = candidates[selectedIndex]?.password ?? "";
|
||||
} else {
|
||||
// Hint path: only the explicit session password.
|
||||
secret = defaultPassword();
|
||||
}
|
||||
if (!secret) {
|
||||
disarm();
|
||||
return;
|
||||
}
|
||||
options.write(`${secret}\n`);
|
||||
// Clear pending UI. Keep a short arm + postFillRetry flag so a real
|
||||
// sudo/su rejection re-prompt can reopen assist, but a later child
|
||||
// Password: (e.g. after `sudo mysql -p`) will not (#2156 review).
|
||||
pending = false;
|
||||
hideUi();
|
||||
dismissedWhileArmed = false;
|
||||
postFillRetry = true;
|
||||
if (hasFillMaterial()) {
|
||||
armedUntil = options.now() + armWindowMs;
|
||||
tail = "";
|
||||
}
|
||||
},
|
||||
cancelHint: () => {
|
||||
softDismissPendingUi();
|
||||
},
|
||||
dismissOnUserContentInput: (data: string) => {
|
||||
if (!pending || !shouldDismissPasswordAssistOnInput(data)) return false;
|
||||
softDismissPendingUi();
|
||||
return true;
|
||||
},
|
||||
abort: () => {
|
||||
disarm();
|
||||
},
|
||||
isPromptPending: () => pending,
|
||||
isPickerPending: () => pending && pendingUi === "picker",
|
||||
canReshowAssist: () => {
|
||||
if (pending || !dismissedWhileArmed || !hasFillMaterial()) return false;
|
||||
if (!isArmActiveNow()) return false;
|
||||
return isArmedPromptLine(lastPromptLine(), true);
|
||||
},
|
||||
tryReshowAssist: () => {
|
||||
if (pending || !hasFillMaterial()) return false;
|
||||
if (!isArmActiveNow()) {
|
||||
dismissedWhileArmed = false;
|
||||
return false;
|
||||
}
|
||||
return tryShowForCurrentTail();
|
||||
},
|
||||
moveSelection: (delta: number) => {
|
||||
if (!pending || pendingUi !== "picker" || candidates.length === 0) return false;
|
||||
const next =
|
||||
(selectedIndex + delta + candidates.length * 10) % candidates.length;
|
||||
if (next === selectedIndex) return false;
|
||||
selectedIndex = next;
|
||||
notifyPicker(true);
|
||||
return true;
|
||||
},
|
||||
updatePassword: (nextPassword?: string) => {
|
||||
password = nextPassword ?? "";
|
||||
if (!hasFillMaterial()) disarm();
|
||||
},
|
||||
updateCandidates: (next) => {
|
||||
candidates = next ?? [];
|
||||
if (selectedIndex >= candidates.length) {
|
||||
selectedIndex = Math.max(0, candidates.length - 1);
|
||||
}
|
||||
if (!hasFillMaterial()) {
|
||||
disarm();
|
||||
return;
|
||||
}
|
||||
if (pending && pendingUi === "picker") {
|
||||
notifyPicker(true);
|
||||
}
|
||||
},
|
||||
updateMode: (nextMode) => {
|
||||
mode = nextMode;
|
||||
if (!hasFillMaterial()) {
|
||||
disarm();
|
||||
return;
|
||||
}
|
||||
// Mode change while pending: re-show the appropriate UI. Keep the full
|
||||
// picker available only for armed su (same gate as first detection).
|
||||
if (pending) {
|
||||
const armStillActive =
|
||||
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
|
||||
const allowFullPicker = armStillActive && armedKind === "su";
|
||||
hideUi();
|
||||
if (!showAssist(allowFullPicker)) {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user