Merge pull request #1295 from binaricat/codex/fix-issue-1293
Some checks failed
build-packages / bump homebrew tap (push) Has been cancelled
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

fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
This commit is contained in:
陈大猫
2026-06-08 16:49:01 +08:00
committed by GitHub
4 changed files with 97 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { import {
createSudoPasswordAutofill, createSudoPasswordAutofill,
getSingleBracketedPasteLine, getSingleBracketedPasteLine,
isExplicitSudoPrompt,
isSudoPasswordPrompt, isSudoPasswordPrompt,
shouldArmSudoPasswordAutofill, shouldArmSudoPasswordAutofill,
} from "./terminalSudoAutofill"; } from "./terminalSudoAutofill";
@@ -23,6 +24,37 @@ test("isSudoPasswordPrompt detects localized prompts", () => {
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", () => { test("isSudoPasswordPrompt detects color-wrapped prompts", () => {
assert.equal(isSudoPasswordPrompt("\x1b[32m[sudo] password for alice: \x1b[0m"), true); assert.equal(isSudoPasswordPrompt("\x1b[32m[sudo] password for alice: \x1b[0m"), true);
}); });

View File

@@ -11,18 +11,21 @@ const OSC_PATTERN = new RegExp(
// output as a real prompt so a remote can't disguise a fake prompt and trick the // output as a real prompt so a remote can't disguise a fake prompt and trick the
// user into revealing the password. // user into revealing the password.
const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`); const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`);
// A line that ends in a colon and mentions password/密码/口令. Intentionally // A line that mentions password/密码/口令 and optionally ends in a colon.
// broad: filling requires the user to confirm (press Enter), so over-matching // Intentionally broad: filling requires the user to confirm (press Enter), so
// only shows a dismissable hint and never leaks a password to a child program. // 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 = const SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*[:]\s*$/i; /(?:^|[\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 // 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 // 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 // reliable even when command recording (arming) didn't fire for a manually
// typed command (#1284; manual typing's recordedCommand is flaky). // typed command (#1284; manual typing's recordedCommand is flaky).
// Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286). // Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286).
// Colon is optional for Kylin (#1293).
const EXPLICIT_SUDO_PROMPT_PATTERN = const EXPLICIT_SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?\[sudo[^\]]*\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*[:]\s*$/i; /(?:^|[\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|$)/; const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
export const stripTerminalControlSequences = (data: string): string => export const stripTerminalControlSequences = (data: string): string =>
@@ -146,7 +149,16 @@ export const createSudoPasswordAutofill = (_options: {
// Fast path for bulk output: a prompt line ends in a colon, so a chunk // 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 // 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). // is pending (then we must keep watching for the prompt moving on).
if (!pending && !data.includes(":") && !data.includes("")) return data; // 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 = tail.split(/[\r\n]/).pop() ?? tail; const lastLine = tail.split(/[\r\n]/).pop() ?? tail;
const armActive = const armActive =
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil; armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;

View File

@@ -2,9 +2,9 @@ const DEFAULT_TIMEOUT_MS = 60_000;
const TAIL_LIMIT = 2048; const TAIL_LIMIT = 2048;
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
const LAST_LOGIN_PATTERN = /(?:^|[\s([])(?:last|previous)\s+login\s*[:>]\s*$/i; const LAST_LOGIN_PATTERN = /(?:^|[\s([])(?:last|previous)\s+login(?:\s*[:>])?\s*$/i;
const USERNAME_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:user\s*name|username|login|logon|account|userid|user\s*id|user|\u7528\u6237\u540d|\u5e10\u53f7|\u8d26\u53f7|\u767b\u5f55|\u767b\u5165)\s*[:>]\s*$/i; const USERNAME_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:user\s*name|username|login|logon|account|userid|user\s*id|user|\u7528\u6237\u540d|\u5e10\u53f7|\u8d26\u53f7|\u767b\u5f55|\u767b\u5165)(?:\s*[:>])?\s*$/i;
const PASSWORD_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:password|passwd|passcode|passphrase|pass\s*phrase|pin|\u5bc6\u7801|\u53e3\u4ee4)\s*[:>]\s*$/i; const PASSWORD_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:password|passwd|passcode|passphrase|pass\s*phrase|pin|\u5bc6\u7801|\u53e3\u4ee4)(?:\s*[:>])?\s*$/i;
const CONTINUE_PROMPT_PATTERN = /(?:press|hit)\s+(?:[<\[(]?\s*)?(?:return|enter|any\s+key|space)\b(?:\s*[>\]\)])?.*(?:continue|get\s+started|start|begin|started)?\.?\s*$/i; const CONTINUE_PROMPT_PATTERN = /(?:press|hit)\s+(?:[<\[(]?\s*)?(?:return|enter|any\s+key|space)\b(?:\s*[>\]\)])?.*(?:continue|get\s+started|start|begin|started)?\.?\s*$/i;
const COMMAND_PROMPT_PATTERN = /[$#>]\s*$/; const COMMAND_PROMPT_PATTERN = /[$#>]\s*$/;

View File

@@ -236,3 +236,47 @@ test("telnet auto-login avoids common non-prompt login text", () => {
assert.deepEqual(writes, []); assert.deepEqual(writes, []);
}); });
test("telnet auto-login works with Kylin-style prompts without trailing colon", () => {
// Kylin Professional prompts may lack trailing colon or angle-bracket (#1293)
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
});
autoLogin.handleText("Username");
autoLogin.handleText("\r\nPassword");
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login works with Kylin-style Chinese prompts without trailing colon", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
});
autoLogin.handleText("用户名");
autoLogin.handleText("\r\n密码");
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login works with Kylin V10 Input Password prompt from issue #1293", () => {
// Screenshot: "lybing-pc login: lybing" then "Input Password" (no trailing colon)
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "lybing",
password: "secret",
write: (data) => writes.push(data),
});
autoLogin.handleText("Kylin V10 SP1\r\nlybing-pc login: ");
autoLogin.handleText("Input Password");
assert.deepEqual(writes, ["lybing\r", "secret\r"]);
});