Compare commits

...
Author SHA1 Message Date
陈大猫 5640383522 Merge branch 'main' into cursor/serial-wake-enter-autologin-6201 2026-10-25 00:53:49 +08:00
Cursor Agentand陈大猫 af57c46bcc fix(serial): notify expired wake Enter and keep continue prompts
A wake CR/LF after the auto-login window had closed returned before
notifyUserInput, so the serial startup fallback stayed armed. Exempt
that wake only while the detector is still active. A blind wake Enter
also no longer sets sentWake, so a later continue prompt still receives
the one-shot response.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 16:40:22 +00:00
Cursor Agentand陈大猫 c03ab890b2 fix(serial): reclassify a wake Enter rewritten into text
An input interceptor that turns a bare CR/LF into a command left
autoLoginInputHandled set from the pre-rewrite bytes, so auto-login
stayed armed. Classify the transformed value when it is no longer
wake-only, and keep the ingress classification when it still is.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 16:14:17 +00:00
Cursor Agentand陈大猫 7739f8b2c7 fix(serial): do not reclassify a queued wake Enter
Ingress already classifies a wake CR/LF for auto-login. Calling
handleUserInput again when that same write left the interceptor
cancelled autofill if login: arrived during the delay, so the saved
password was never sent. Mark the write as already handled and still
write the wake byte once.

Fixes #3906

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 16:02:17 +00:00
Cursor Agentand陈大猫 ac87716436 fix(serial): drop a queued wake Enter once login starts
An input interceptor can hold the wake CR/LF while login: arrives and
the username is sent. Replaying that Enter used to see sentUsername and
disable auto-login, so the password never filled. Keep the ingress
classification and do not write the stale wake into the exchange.

Fixes #3906

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 16:01:05 +00:00
Cursor Agentand陈大猫 11e18b3d1e fix(serial): keep auto-login armed across wake Enter
A bare CR/LF before any login prompt wakes silent serial getty and was
treated as a user takeover, so saved credentials never filled. Leave
that wake armed and still write it to the port.

Fixes #3906

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:41:43 +00:00
4 changed files with 443 additions and 8 deletions

No files matched your search

+26 -1
View File
@@ -73,6 +73,11 @@ function normalizePassword(password) {
return typeof password === "string" ? password : "";
}
// Bare CR/LF used to wake a silent serial console before "login:" appears.
function isAutoLoginWakeInput(data) {
return typeof data === "string" && /^[\r\n]+$/.test(data);
}
function createTelnetAutoLogin(options = {}) {
const username = normalizeUsername(options.username);
const password = normalizePassword(options.password);
@@ -207,7 +212,26 @@ function createTelnetAutoLogin(options = {}) {
notifyIncomplete();
}
},
handleUserInput() {
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
// appears. While the detector is still active that wake is not a
// takeover: keep autofill armed, leave the caller's write on the port,
// and do not consume the one-shot continue-prompt response. A later
// "Press RETURN to continue" must still be answered. Once the detector
// is disabled (expiry, completion, or a prior cancel), the same Enter
// notifies like any other user input so callers can drop a deferred
// startup command. Any other user input still cancels.
handleUserInput(data) {
const wakeOnly = isAutoLoginWakeInput(data);
if (
wakeOnly
&& !disabled
&& !sentUsername
&& !sentPassword
&& !isUsernamePrompt(tail)
&& !isPasswordPrompt(tail)
) {
return;
}
disabled = true;
clearExpiryTimer();
notifyUserInput();
@@ -224,6 +248,7 @@ function createTelnetAutoLogin(options = {}) {
module.exports = {
createTelnetAutoLogin,
isAutoLoginWakeInput,
isCommandPrompt,
isContinuePrompt,
isPasswordPrompt,
+149
View File
@@ -224,6 +224,155 @@ test("telnet auto-login stops when the user starts typing manually", () => {
assert.equal(cancelCount, 1);
});
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
autoLogin.handleUserInput("\r");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("\n");
autoLogin.handleUserInput("\r\n");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
let cancelCount = 0;
let timerFn = null;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref() {} };
},
clearTimeout: () => {},
});
// Quiet device: the 60s window elapses with no prompt, which disables the
// detector without a user-input notification. Serial startup still has its
// 65s fallback armed until this cancellation arrives.
timerFn();
assert.equal(cancelCount, 0);
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// The user's Enter woke a silent console. It must not consume the
// detector's one-shot response when the device then asks to continue.
autoLogin.handleUserInput("\r");
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("\r\nlogin: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
});
test("telnet auto-login still cancels when a normal character arrives before login", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("x");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still cancels an enter after credentials have started", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, ["admin\r"]);
});
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
let clock = 0;
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
});
// After the window, the prompt is recorded but credentials are not sent.
clock = 60_001;
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login avoids common non-prompt login text", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
+27 -7
View File
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
@@ -1713,8 +1713,9 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
&& !payload.autoLoginInputHandled
) {
session.autoLogin?.handleUserInput();
session.autoLogin?.handleUserInput(data);
}
// Encode keystrokes with the SAME charset the output path decodes with so
@@ -1805,6 +1806,8 @@ function writeToSessionWithInterception(
paste = null,
index = 0,
) {
// Stays the original payload until ingress classification marks this write.
let sessionWritePayload = payload;
const writeWithReceipt = (nextData) => {
if (paste && !paste.active) return;
const current = sessions.get(payload.sessionId);
@@ -1817,7 +1820,7 @@ function writeToSessionWithInterception(
paste.finish(index, true);
return;
}
const written = writeToSessionNow(payload, nextData, logRewrite);
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
if (paste) paste.finish(written ? index : undefined);
};
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
@@ -1830,20 +1833,24 @@ function writeToSessionWithInterception(
writeWithReceipt(data);
return;
}
// Cancel the auto-login detector at input ingress, before the asynchronous
// Classify auto-login at input ingress, before the asynchronous
// interception pipeline: a slow interceptor would otherwise leave the
// detector armed while the user's keystrokes are queued, letting a login
// prompt that arrives during that wait trigger a saved-credential
// transmission after the user has already taken over. Same guard
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
// later call there stays a no-op.
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
// credentials start, so mark this write and do not classify those bytes
// again when they reach writeToSessionNow. That preserved classification
// describes the queued bytes only while they stay wake-only; writeIfCurrent
// classifies an interceptor rewrite that is no longer wake-only.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput();
expectedSession.autoLogin?.handleUserInput(data);
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
@@ -1851,6 +1858,19 @@ function writeToSessionWithInterception(
paste?.finish();
return;
}
// An Enter-to-command macro can turn the wake CR/LF classified above
// into ordinary text. Classify that transformed value before it is
// written. autoLoginInputHandled stays set so writeToSessionNow does not
// classify the same write again. A rewrite that is still only CR/LF keeps
// the ingress classification: those bytes are not idempotent once
// credentials have started.
if (
sessionWritePayload.autoLoginInputHandled
&& !isAutoLoginWakeInput(nextData)
&& !isTerminalReportSequence(nextData)
) {
current.autoLogin?.handleUserInput(nextData);
}
writeWithReceipt(nextData);
};
const write = async () => {
@@ -1,6 +1,32 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
function createSerialAutoLoginSession() {
const writes = [];
const events = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: {
write: (data) => {
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
return true;
},
},
};
session.autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => {
session.serialPort.write(data);
},
onUserInput: () => { events.push("cancelled"); },
});
return { session, writes, events };
}
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
@@ -61,6 +87,221 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
}
});
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial character input before login still cancels auto-login", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["x"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorResolved = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (sessionId, data) => {
await new Promise((resolve) => setTimeout(resolve, 20));
interceptorResolved = true;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
assert.deepEqual(events, []);
assert.equal(interceptorResolved, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(interceptorResolved, true);
assert.deepEqual(writes, ["\n"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
await new Promise((resolve) => setTimeout(resolve, 30));
await interceptorGate;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Prompt arrives while the wake byte is still queued in the interceptor.
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
assert.equal(data, "\r");
await new Promise((resolve) => setTimeout(resolve, 20));
// Enter-to-command macro: the bare wake CR is no longer what is written.
return "uptime\r";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
assert.deepEqual(events, []);
assert.deepEqual(writes, []);
const deadline = Date.now() + 500;
while (writes.length < 1 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
assert.equal(data, "\r");
await interceptorGate;
return "\r\n";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, []);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {