Files
NetMesh/components/terminal/runtime/terminalSudoAutofill.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

590 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
},
};
};