Compare commits

...
25 changed files with 1335 additions and 5 deletions

No files matched your search

+4
View File
@@ -827,6 +827,10 @@ export const enTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
'serial.field.username': 'Username',
'serial.field.password': 'Password',
'serial.field.autoLoginDesc': 'When set, Login/Password prompts on the serial console are answered automatically with these saved credentials.',
'serial.field.clearPassword': 'Clear saved password',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
+4
View File
@@ -827,6 +827,10 @@ export const esTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
'serial.connectAndSave': 'Conectar y guardar',
'serial.edit.title': 'Configuración del puerto serial',
'serial.field.username': 'Usuario',
'serial.field.password': 'Contraseña',
'serial.field.autoLoginDesc': 'Si se establecen, los mensajes Login/Password de la consola serial se responden automáticamente con estas credenciales guardadas.',
'serial.field.clearPassword': 'Borrar la contraseña guardada',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Se requiere autenticación',
+4
View File
@@ -834,6 +834,10 @@ export const ruTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
'serial.connectAndSave': 'Подключить и сохранить',
'serial.edit.title': 'Настройки последовательного порта',
'serial.field.username': 'Имя пользователя',
'serial.field.password': 'Пароль',
'serial.field.autoLoginDesc': 'Если задано, запросы Login/Password в последовательной консоли автоматически заполняются этими сохранёнными учётными данными.',
'serial.field.clearPassword': 'Очистить сохранённый пароль',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Требуется аутентификация',
@@ -918,6 +918,10 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
'serial.field.username': '用户名',
'serial.field.password': '密码',
'serial.field.autoLoginDesc': '设置后,串口终端出现 Login/Password 登录提示时,会自动填充此处保存的用户名和密码。',
'serial.field.clearPassword': '清除已保存的密码',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
@@ -918,6 +918,10 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '連線並儲存',
'serial.edit.title': '序列埠設定',
'serial.field.username': '使用者名稱',
'serial.field.password': '密碼',
'serial.field.autoLoginDesc': '設定後,序列埠終端出現 Login/Password 登入提示時,會自動填入此處儲存的使用者名稱和密碼。',
'serial.field.clearPassword': '清除已儲存的密碼',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要驗證',
+7
View File
@@ -202,6 +202,11 @@ export const useTerminalBackend = () => {
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const notifyUserInput = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.notifySessionUserInput?.(sessionId);
}, []);
const interruptSession = useCallback((sessionId: string, trace?: NetcattyTerminalInterruptTrace) => {
const bridge = netcattyBridge.get();
if (bridge?.interruptSession) {
@@ -593,6 +598,7 @@ export const useTerminalBackend = () => {
getSessionDistroInfo,
getServerStats,
writeToSession,
notifyUserInput,
interruptSession,
resizeSession,
clearSessionPtyBuffer,
@@ -675,6 +681,7 @@ export const useTerminalBackend = () => {
getSessionDistroInfo,
getServerStats,
writeToSession,
notifyUserInput,
interruptSession,
resizeSession,
clearSessionPtyBuffer,
+80 -1
View File
@@ -2,7 +2,7 @@
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
import { ChevronDown, ChevronUp, Eye, EyeOff, Save, Tag, Usb, X } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
@@ -75,9 +75,22 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Form state
const [label, setLabel] = useState(initialData.label);
const [username, setUsername] = useState(initialData.username || '');
const [password, setPassword] = useState(initialData.password || '');
// Tracks whether the user edited the password field this session. An empty
// password is a meaningful serial credential (the auto-login detector
// submits a blank line at a Password prompt), so an explicit edit to blank
// must be saved as '' while an untouched field preserves the stored value.
const [passwordChanged, setPasswordChanged] = useState(false);
// Explicit "remove the saved credential" flag. An empty password field is a
// meaningful serial credential (a present blank password), so blanking the
// input alone cannot restore `password` to undefined — the user needs a
// dedicated control to delete the credential entirely.
const [passwordCleared, setPasswordCleared] = useState(false);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
@@ -140,6 +153,16 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
// distinguishes an absent password from a present empty string and
// submits the required blank line at a Password prompt. An explicit edit
// saves exactly what the user typed (including a blank line); the
// dedicated clear control removes the credential entirely (undefined);
// an untouched field preserves the stored credential as-is.
password: passwordChanged
? (passwordCleared ? undefined : password)
: (initialData.password ?? undefined),
tags,
group,
charset,
@@ -276,6 +299,62 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>
{/* Login credentials (auto-login) */}
<div className="space-y-2">
<Label htmlFor="serial-username">{t('serial.field.username')}</Label>
<Input
id="serial-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('serial.field.username')}
autoComplete="off"
/>
<div className="relative">
<Input
id="serial-password"
value={password}
type={showPassword ? 'text' : 'password'}
onChange={(e) => {
setPassword(e.target.value);
setPasswordChanged(true);
if (e.target.value !== '') {
setPasswordCleared(false);
}
}}
placeholder={t('serial.field.password')}
autoComplete="off"
className="pr-16"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
{(initialData.password !== undefined || password !== '') && (
<button
type="button"
onClick={() => {
setPassword('');
setPasswordChanged(true);
setPasswordCleared(true);
}}
title={t('serial.field.clearPassword')}
aria-label={t('serial.field.clearPassword')}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
)}
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('serial.field.autoLoginDesc')}
</p>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
@@ -0,0 +1,419 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
const buildBackend = (overrides: Partial<Record<string, unknown>> = {}) => ({
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
...overrides,
});
const buildCtx = (backend: unknown, extra: Record<string, unknown> = {}) => ({
host: {
id: "serial-1",
label: "Serial: ttyUSB0",
hostname: "/dev/ttyUSB0",
protocol: "serial",
charset: "UTF-8",
},
keys: [],
identities: [],
sessionId: "session-1",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
},
terminalSettings: {},
terminalBackend: backend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
bootEpochRef: { current: 0 },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
...extra,
});
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
test("startSerial passes saved host credentials for auto-login", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const backend = buildBackend({
startSerialSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "serial-session";
},
});
await createTerminalSessionStarters(buildCtx(backend, {
host: {
id: "serial-1",
label: "Serial: ttyUSB0",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
password: "secret",
},
}) as never).startSerial(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.username, "admin");
assert.equal(capturedOptions.password, "secret");
});
test("startSerial omits credentials when none are saved", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const backend = buildBackend({
startSerialSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "serial-session";
},
});
await createTerminalSessionStarters(buildCtx(backend) as never).startSerial(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.username, undefined);
assert.equal("password" in capturedOptions, false);
});
test("startSerial rejects an undecryptable saved password instead of partial auto-login", async () => {
let started = false;
const errors: (string | null)[] = [];
const backend = buildBackend({
startSerialSession: async () => {
started = true;
return "serial-session";
},
});
await createTerminalSessionStarters(buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
password: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
},
setError: (message: string | null) => errors.push(message),
}) as never).startSerial(term as never);
// A saved username paired with an undecryptable password must not start a
// partial auto-login; ask the user to re-enter the credential instead.
assert.equal(started, false);
assert.ok(errors[0]);
});
test("startSerial waits for auto-login before running the startup command", async () => {
const writtenCommands: string[] = [];
const executedCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let resolveCommand: (() => void) | null = null;
const commandWritten = new Promise<void>((resolve) => {
resolveCommand = resolve;
});
const backend = buildBackend({
startSerialSession: async () => "serial-session",
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
resolveCommand?.();
},
});
const ctx = buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
password: "secret",
startupCommand: "show version",
},
onCommandExecuted: (command: string) => {
executedCommands.push(command);
},
});
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
assert.ok(autoLoginComplete);
// The startup command must not fire on the default 600ms delay while the
// main-process auto-login is still answering prompts.
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(writtenCommands, []);
assert.deepEqual(executedCommands, []);
autoLoginComplete?.({ sessionId: "session-1" });
await Promise.race([
commandWritten,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for startup command")), 1000),
),
]);
assert.deepEqual(writtenCommands, ["show version\r"]);
assert.deepEqual(executedCommands, ["show version"]);
});
test("startSerial schedules the startup command when auto-login completes before attach", async () => {
const writtenCommands: string[] = [];
const executedCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
const backend = buildBackend({
startSerialSession: async () => {
// The login exchange completes before the startSerialSession promise
// resolves, so the completion event fires while the session is still
// unattached. The startup command must be scheduled after attach, not
// dropped against the unset session ref.
autoLoginComplete?.({ sessionId: "session-1" });
return "serial-session";
},
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
password: "secret",
startupCommand: "show version",
},
onCommandExecuted: (command: string) => {
executedCommands.push(command);
},
});
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
await Promise.race([
new Promise<void>((resolve) => {
const tick = () => {
if (writtenCommands.length > 0) {
resolve();
return;
}
setTimeout(tick, 20);
};
tick();
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for startup command")), 2000),
),
]);
assert.deepEqual(writtenCommands, ["show version\r"]);
assert.deepEqual(executedCommands, ["show version"]);
});
test("startSerial runs the startup command without waiting when no credentials are saved", async () => {
const writtenCommands: string[] = [];
const backend = buildBackend({
startSerialSession: async () => "serial-session",
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
startupCommand: "show version",
},
});
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
await Promise.race([
new Promise<void>((resolve) => {
const tick = () => {
if (writtenCommands.length > 0) {
resolve();
return;
}
setTimeout(tick, 20);
};
tick();
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for startup command")), 2000),
),
]);
assert.deepEqual(writtenCommands, ["show version\r"]);
});
test("startSerial does not arm the fallback when auto-login is cancelled before attach", async () => {
const writtenCommands: string[] = [];
let autoLoginCancelled: ((evt: { sessionId: string }) => void) | null = null;
const backend = buildBackend({
startSerialSession: async () => {
// A stalled exchange (e.g. only a username is saved and the device moves
// on to a Password prompt the detector cannot answer) cancels auto-login
// before the startSerialSession promise resolves.
autoLoginCancelled?.({ sessionId: "session-1" });
return "serial-session";
},
onTelnetAutoLoginComplete: () => noop,
onTelnetAutoLoginCancelled: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginCancelled = cb;
return noop;
},
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
startupCommand: "show version",
},
});
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
assert.ok(autoLoginCancelled);
// The 65s quiet-device fallback must not be armed after the cancellation,
// or it would type the startup command into the pending password prompt.
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(writtenCommands, []);
});
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const writes: string[] = [];
const completions: Array<(evt: {sessionId: string; bootEpoch: number}) => void> = [];
const backend = buildBackend({
startSerialSession: async () => 'session-1',
onTelnetAutoLoginComplete: (_id: string, cb: typeof completions[number]) => {
completions.push(cb);
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_id: string, data: string) => writes.push(data),
});
const ctx = buildCtx(backend, {
host: { id: 'serial-1', protocol: 'serial', username: 'admin', startupCommand: 'show version', startupCommandRunMode: runMode === 'lineDelay' ? 'lineDelay' : 'paste' },
terminalSettings: { startupCommandDelayMs: 100 },
noAutoRun: runMode === 'noAutoRun',
});
const starters = createTerminalSessionStarters(ctx as never);
await starters.startSerial(term as never);
completions[0]({sessionId: 'session-1', bootEpoch: 0});
ctx.bootEpochRef.current = 1;
ctx.hasRunStartupCommandRef.current = false;
await starters.startSerial(term as never);
t.mock.timers.tick(100);
assert.deepEqual(writes, []);
completions[1]({sessionId: 'session-1', bootEpoch: 1});
t.mock.timers.tick(100);
assert.deepEqual(writes, [runMode === 'noAutoRun' ? 'show version' : 'show version\r']);
});
}
test('serial quick connect does not synthesize auto-login credentials', async () => {
const { createSerialTerminalSession } = await import('../../../application/state/sessionFactories');
const { resolveTerminalSessionHost } = await import('../../../domain/terminalHostResolution');
const session = createSerialTerminalSession('quick', {path: '/dev/ttyUSB0', baudRate: 115200});
const host = resolveTerminalSessionHost({session, hosts: [], groupConfigs: [], proxyProfiles: [], localOs: 'macos'});
let options: Record<string, unknown> = {};
const backend = buildBackend({startSerialSession: async (value: Record<string, unknown>) => {options = value; return 'quick';}});
await createTerminalSessionStarters(buildCtx(backend, {host}) as never).startSerial(term as never);
assert.equal(options.username, undefined);
assert.equal(options.password, undefined);
});
for (const sleepBeforeCompletion of [true, false]) {
test(`serial startup survives tab sleep (before completion: ${sleepBeforeCompletion})`, async (t) => {
t.mock.timers.enable({apis: ['setTimeout']});
const writes: string[] = [];
let complete: (evt: {sessionId: string; bootEpoch: number}) => void = noop;
const backend = buildBackend({
startSerialSession: async () => 'session-1',
onTelnetAutoLoginComplete: (_id: string, cb: typeof complete) => { complete = cb; return noop; },
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_id: string, data: string) => writes.push(data),
});
const isBootActiveRef = {current: true};
const ctx = buildCtx(backend, {
host: {id: 'serial-1', protocol: 'serial', username: 'admin', startupCommand: 'show version'},
terminalSettings: {startupCommandDelayMs: 100}, isBootActiveRef,
});
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
if (sleepBeforeCompletion) isBootActiveRef.current = false;
complete({sessionId: 'session-1', bootEpoch: 0});
isBootActiveRef.current = false;
t.mock.timers.tick(100);
assert.deepEqual(writes, ['show version\r']);
});
}
@@ -1947,12 +1947,137 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
// Serial auto-login (#3417): mirror Telnet — the main process answers
// Login/Password prompts with the credentials saved on the host. When a
// startup command is pending, defer it until auto-login finishes so it is
// not typed at a login prompt. Quiet devices (no prompt, no banner) never
// emit a completion event, so fall back after the main-process auto-login
// window (60s) plus margin.
const SERIAL_AUTO_LOGIN_FALLBACK_MS = 65_000;
// A sleeping tab keeps its established serial connection. Only an abort
// or a new connection generation invalidates its delayed startup work.
const isSerialConnectionCurrent = () => options?.signal?.aborted !== true
&& (ctx.bootEpochRef?.current ?? 0) === bootEpoch;
let disposeAutoLoginComplete: (() => void) | undefined;
let disposeAutoLoginCancelled: (() => void) | undefined;
let cancelPendingStartupCommand: (() => void) | undefined;
let autoLoginFallbackTimer: ReturnType<typeof setTimeout> | undefined;
let serialSessionId = ctx.sessionId;
// A login exchange can complete before the startSerialSession promise
// resolves, so the completion event may arrive while the session is not
// attached yet. Record it and schedule the startup command only after
// attach, otherwise scheduleStartupCommand marks it as run and its timer
// then drops it against the unset ctx.sessionRef.current.
let autoLoginCompletedBeforeAttach = false;
// Auto-login can be cancelled (stalled exchange or user input) before the
// startSerialSession promise resolves; the fallback must not be armed
// after attach in that case, or it would blindly type the startup command
// at whatever prompt is pending.
let autoLoginCancelledBeforeAttach = false;
let autoLoginAttached = false;
const clearAutoLoginFallbackTimer = () => {
if (autoLoginFallbackTimer) {
clearTimeout(autoLoginFallbackTimer);
autoLoginFallbackTimer = undefined;
}
};
const disposeAutoLoginListener = () => {
disposeAutoLoginComplete?.();
disposeAutoLoginComplete = undefined;
};
const disposeAutoLoginCancelListener = () => {
disposeAutoLoginCancelled?.();
disposeAutoLoginCancelled = undefined;
};
const cleanupSerialStartupWait = () => {
clearAutoLoginFallbackTimer();
disposeAutoLoginListener();
disposeAutoLoginCancelListener();
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
};
const scheduleStartupAfterAutoLogin = () => {
if (!isSerialConnectionCurrent()) {
cleanupSerialStartupWait();
return;
}
disposeAutoLoginListener();
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, serialSessionId, () => {
cancelPendingStartupCommand = undefined;
disposeAutoLoginCancelListener();
}, isSerialConnectionCurrent);
};
try {
logger.info("[Serial] Starting serial session", {
port: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
});
const serialUsername = (ctx.host.username ?? "").trim();
const serialPassword = sanitizeCredentialValue(ctx.host.password);
// Mirror the Telnet path: an undecryptable saved password must not start
// a partial auto-login (username without password), which would leave a
// startup command waiting on a password prompt that is never answered.
if (isEncryptedCredentialPlaceholder(ctx.host.password)) {
const message = tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
);
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const hasSerialAutoLoginCredentials = Boolean(
serialUsername || serialPassword !== undefined,
);
const commandToRun = resolveStartupCommand(ctx);
const waitsForAutoLogin = Boolean(
commandToRun &&
hasSerialAutoLoginCredentials &&
ctx.terminalBackend.onTelnetAutoLoginComplete,
);
if (waitsForAutoLogin) {
disposeAutoLoginComplete = ctx.terminalBackend.onTelnetAutoLoginComplete?.(
ctx.sessionId,
(evt) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
clearAutoLoginFallbackTimer();
if (!autoLoginAttached) {
disposeAutoLoginListener();
autoLoginCompletedBeforeAttach = true;
return;
}
scheduleStartupAfterAutoLogin();
},
);
disposeAutoLoginCancelled = ctx.terminalBackend.onTelnetAutoLoginCancelled?.(
ctx.sessionId,
(evt) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
if (!autoLoginAttached) {
autoLoginCancelledBeforeAttach = true;
}
cleanupSerialStartupWait();
},
);
}
const id = await ctx.terminalBackend.startSerialSession({
sessionId: ctx.sessionId,
path: ctx.serialConfig.path,
@@ -1964,7 +2089,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
...(hasSerialAutoLoginCredentials
? { username: serialUsername || undefined, password: serialPassword }
: {}),
});
serialSessionId = id;
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
@@ -1973,18 +2102,51 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
convertLfToCrlf: true,
onExit: () => cleanupSerialStartupWait(),
})) {
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
cleanupSerialStartupWait();
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
// Serial connection is established once the session is attached to the terminal.
autoLoginAttached = true;
ctx.updateStatus("connected");
ctx.setProgressValue(100);
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
if (waitsForAutoLogin) {
if (autoLoginCompletedBeforeAttach) {
// Login already completed before the session attached; schedule now
// that ctx.sessionRef.current points at this session.
scheduleStartupAfterAutoLogin();
return;
}
if (autoLoginCancelledBeforeAttach) {
// Auto-login was cancelled before the session attached (stalled
// exchange at a prompt the detector cannot answer, or the user took
// over). The quiet-device fallback must not fire: the startup
// command would be consumed as the answer to the pending prompt.
cleanupSerialStartupWait();
return;
}
// Arm the fallback only now that the port is open and the session is
// attached: the main-process 60s auto-login window starts when the
// port's open callback creates the detector, so a slow/busy port open
// must not consume the fallback budget.
autoLoginFallbackTimer = setTimeout(() => {
autoLoginFallbackTimer = undefined;
if (!disposeAutoLoginComplete) return;
if ((ctx.bootEpochRef?.current ?? 0) !== bootEpoch) return;
scheduleStartupAfterAutoLogin();
}, SERIAL_AUTO_LOGIN_FALLBACK_MS);
return;
}
scheduleStartupCommand(ctx, term, id, undefined, isSerialConnectionCurrent);
} catch (err) {
cleanupSerialStartupWait();
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
@@ -101,6 +101,8 @@ export type TerminalBackendApi = {
cb: (sessionId: string, sourceSessionId?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number; logRewrite?: ProgrammaticCommandLogRewrite }) => void;
/** Signal user input for buffered-input modes (serial line mode) so main-process auto-login cancels. */
notifyUserInput?: (sessionId: string) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
closeSession: (sessionId: string, options?: { bootEpoch?: number }) => void | Promise<void>;
@@ -103,6 +103,7 @@ import {
resolveMiddleClickBehavior,
} from "./middleClickBehavior";
import { handleSerialLineModeInput } from "./serialLineInput";
import { isTerminalReportSequence } from "./terminalReportSequence";
import {
doesKittyEncodingPreserveShiftEnter,
getShiftEnterSubmittedInput,
@@ -209,6 +210,7 @@ type TerminalBackendApi = {
openExternalAvailable: () => boolean;
openExternal: (url: string) => Promise<void>;
writeToSession: (sessionId: string, data: string) => void;
notifyUserInput?: (sessionId: string) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
signalPluginConnection?: (
sessionId: string,
@@ -1329,6 +1331,20 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ctx.serialLineMode &&
ctx.serialLineBufferRef
) {
// Line mode never reaches writeToSession until Enter, so buffered
// keystrokes are invisible to the main-process auto-login detector.
// The first buffered keystroke means the user is taking control:
// cancel the detector exactly like character-mode input would.
// Submit (\r/\n) and Ctrl+C already write to the session, which
// cancels it on that path. Automatic terminal-report replies (DA1,
// CPR, ...) also surface through onData, but they originate from the
// device negotiating with xterm, not from the user — the main process
// excludes them from its write-path cancellation via
// isTerminalReportSequence, so apply the same classification here.
const isSessionWrite = dataToWrite === "\r" || dataToWrite === "\n" || dataToWrite === "\x03";
if (!isSessionWrite && !isTerminalReportSequence(dataToWrite)) {
ctx.terminalBackend.notifyUserInput?.(id);
}
handleSerialLineModeInput(dataToWrite, {
bufferRef: ctx.serialLineBufferRef,
localEcho: ctx.serialLocalEcho,
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isTerminalReportSequence } from "./terminalReportSequence";
test("classifies automatic terminal-report replies as reports", () => {
assert.equal(isTerminalReportSequence("\x1b[?1;2c"), true); // DA1 reply
assert.equal(isTerminalReportSequence("\x1b[>0;95;0c"), true); // DA2 reply
assert.equal(isTerminalReportSequence("\x1b[12;34R"), true); // CPR reply
assert.equal(isTerminalReportSequence("\x1b[?12;34R"), true); // DECXCPR reply
assert.equal(isTerminalReportSequence("\x1b[0n"), true); // DSR reply
assert.equal(isTerminalReportSequence("\x1b[?62;1;2;6;9;15;22c"), true);
assert.equal(isTerminalReportSequence("\x1b[I"), true);
assert.equal(isTerminalReportSequence("\x1b[O"), true);
assert.equal(isTerminalReportSequence("\x1b[?1u"), true); // Kitty query reply
assert.equal(isTerminalReportSequence("\x1b[2;1$y"), true); // DECRPM reply
assert.equal(isTerminalReportSequence("\x1b[8;24;80t"), true);
assert.equal(isTerminalReportSequence("\x1b]11;rgb:0c/0c/0c\x07"), true);
assert.equal(isTerminalReportSequence("\x1bP0!r\x1b\\"), true); // DCS reply
});
test("does not classify typed text or submit keys as reports", () => {
assert.equal(isTerminalReportSequence("enable"), false);
assert.equal(isTerminalReportSequence("\r"), false);
assert.equal(isTerminalReportSequence("\n"), false);
assert.equal(isTerminalReportSequence("\x03"), false);
assert.equal(isTerminalReportSequence("\x7f"), false);
assert.equal(isTerminalReportSequence("\x1b[A"), false); // Arrow key
assert.equal(isTerminalReportSequence("\x1b[200~pasted\x1b[201~"), false);
assert.equal(isTerminalReportSequence(""), false);
});
@@ -0,0 +1,28 @@
/* eslint-disable no-control-regex -- classification is intentionally byte-level */
// Renderer-side twin of electron/bridges/terminalReportSequence.cjs. Keep the
// two classifiers in sync: terminal-originated automatic replies (DA1, CPR,
// DSR, mode reports, ...) travel through xterm's public onData event alongside
// real user input, and callers must not treat them as user keystrokes.
function isTerminalReportSequence(data: string): boolean {
if (typeof data !== "string" || data.length === 0) return false;
// Focus in/out reports: ESC [ I / ESC [ O
if (data === "\x1b[I" || data === "\x1b[O") return true;
// CPR / DECXCPR / DA1 / DA2 / DSR: ESC [ (?|>)? digits/semicolons (R|c|n)
if (/^\x1b\[[?>]?[0-9;]*[Rcn]$/.test(data)) return true;
// Kitty keyboard mode query reply: ESC [ ? digits u
if (/^\x1b\[\?[0-9]+u$/.test(data)) return true;
// ANSI/DEC mode report (DECRPM): ESC [ (?) mode ; state $ y
if (/^\x1b\[\??[0-9]+;[0-4]\$y$/.test(data)) return true;
// Xterm window/cell/character-size reports: ESC [ (4|6|8) ; height ; width t
if (/^\x1b\[(?:4|6|8);[0-9]+;[0-9]+t$/.test(data)) return true;
// Xterm dynamic-color query replies: OSC (4;index|10|11|12) ; rgb:... (BEL|ST)
if (/^\x1b\](?:4;[0-9]{1,3}|1[012]);rgb:[0-9a-f]{1,4}\/[0-9a-f]{1,4}\/[0-9a-f]{1,4}(?:\x07|\x1b\\)$/i.test(data)) return true;
// DCS replies (XTGETTCAP / DECRQSS, etc.): ESC P ... ESC \
if (/^\x1bP[\s\S]*\x1b\\$/.test(data)) return true;
return false;
}
export { isTerminalReportSequence };
/* eslint-enable no-control-regex */
@@ -50,7 +50,9 @@ export const scheduleStartupCommand = (
term: XTerm,
id: string,
onSettled?: () => void,
isCurrentAttempt?: () => boolean,
): (() => void) | undefined => {
if (isCurrentAttempt && !isCurrentAttempt()) return undefined;
const commandToRun = resolveStartupCommand(ctx, { consumeSuppressHostStartupCommand: true });
if (!commandToRun || ctx.hasRunStartupCommandRef.current) return undefined;
@@ -62,7 +64,8 @@ export const scheduleStartupCommand = (
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const sessionIsCurrent = () =>
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId;
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId
&& (!isCurrentAttempt || isCurrentAttempt());
// noAutoRun (snippet "type but don't execute"): type the command as-is, no
// Enter and no line-splitting — unchanged behavior.
+27
View File
@@ -588,3 +588,30 @@ test("group notes survive sanitization but are not connection defaults", () => {
assert.equal(resolveGroupDefaults("Project", [config]).notes, undefined);
assert.equal(resolveGroupDefaults("Project/Servers", [config]).notes, undefined);
});
test("applyGroupDefaults does not inherit group SSH credentials into serial hosts", () => {
const groupDefaults: Partial<GroupConfig> = {
username: "group-ssh-user",
password: "group-ssh-password",
savePassword: true,
authMethod: "password",
};
const result = applyGroupDefaults(
host({ protocol: "serial", username: undefined, password: undefined }),
groupDefaults,
);
assert.equal(result.username, undefined);
assert.equal(result.password, undefined);
});
test("applyGroupDefaults still inherits non-credential defaults into serial hosts", () => {
const result = applyGroupDefaults(
host({ protocol: "serial", username: undefined, charset: undefined }),
{ username: "group-ssh-user", charset: "UTF-8" },
);
assert.equal(result.username, undefined);
assert.equal(result.charset, "UTF-8");
});
+7
View File
@@ -206,9 +206,16 @@ export function applyGroupDefaults(
host.telnetPassword !== undefined ||
primaryTelnetHasManualSharedCredentials
);
// Serial hosts: username/password on a serial host are the auto-login
// credentials (#3417) sent to whatever device answers on the port. Group
// defaults are SSH credentials — never inherit them into a serial host, or
// connecting a serial editor with blank credential fields would transmit the
// group's SSH password to anything that prints a login prompt.
const isSerialHost = host.protocol === 'serial';
for (const key of INHERITABLE_KEYS) {
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
if (isSerialHost && SSH_CREDENTIAL_KEYS.has(key)) continue;
if (key === 'password' && effective.savePassword === false) continue;
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
if (key === 'proxyProfileId') {
+1 -1
View File
@@ -66,7 +66,7 @@ function buildFallbackHostFromSession(
id: session.hostId,
label: session.hostLabel || "Local Terminal",
hostname: session.hostname || "localhost",
username: session.username || "local",
username: session.username || (fallbackProtocol === "serial" ? "" : "local"),
port: session.port ?? 22,
os: fallbackProtocol === "local" ? localOs : "linux",
group: "",
+4
View File
@@ -112,6 +112,10 @@ function disposeDisplacedSessionResources(session, sessionId) {
if (!session || session._displacedDisposed) return;
session._displacedDisposed = true;
try { session.zmodemSentry?.cancel?.(); } catch { /* ignore */ }
// A displaced auto-login detector's expiry timer must not fire: its
// onIncomplete callback would otherwise report against the replacement
// session that now owns the registry slot.
try { session.autoLogin?.cancel?.(); } catch { /* ignore */ }
try { session.discardPendingData?.(); } catch { /* ignore */ }
try { session.releaseTelnetGeneration?.(); } catch { /* ignore */ }
// Exit handlers for Mosh/ET/serial bail out once the registry no longer
@@ -85,6 +85,21 @@ test("claimSessionSlot replaces an older boot epoch and marks it superseded", ()
}
});
test("claimSessionSlot cancels the displaced session's auto-login detector", () => {
const sessions = new Map();
let cancelCalls = 0;
const older = {
bootEpoch: 1,
autoLogin: { cancel() { cancelCalls += 1; } },
};
sessions.set("s1", older);
const newer = {};
const result = claimSessionSlot(sessions, "s1", newer, 4);
assert.equal(result.ok, true);
assert.equal(sessions.get("s1"), newer);
assert.equal(cancelCalls, 1);
});
test("sessionMatchesBootEpoch ignores closes for a different generation", () => {
assert.equal(sessionMatchesBootEpoch({ bootEpoch: 3 }, 1), false);
assert.equal(sessionMatchesBootEpoch({ bootEpoch: 3 }, 3), true);
+68 -1
View File
@@ -81,8 +81,11 @@ function createTelnetAutoLogin(options = {}) {
const write = typeof options.write === "function" ? options.write : () => {};
const onComplete = typeof options.onComplete === "function" ? options.onComplete : () => {};
const onUserInput = typeof options.onUserInput === "function" ? options.onUserInput : () => {};
const onIncomplete = typeof options.onIncomplete === "function" ? options.onIncomplete : () => {};
const now = typeof options.now === "function" ? options.now : Date.now;
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
const setTimer = typeof options.setTimeout === "function" ? options.setTimeout : (fn, ms) => setTimeout(fn, ms);
const clearTimer = typeof options.clearTimeout === "function" ? options.clearTimeout : (id) => clearTimeout(id);
let tail = "";
let sentWake = false;
@@ -91,12 +94,21 @@ function createTelnetAutoLogin(options = {}) {
let disabled = !hasCredentials;
let completed = false;
let userInputNotified = false;
let incompleteNotified = false;
let expiryTimer;
const startedAt = now();
const isExpired = () => timeoutMs >= 0 && now() - startedAt > timeoutMs;
const clearExpiryTimer = () => {
if (expiryTimer) {
clearTimer(expiryTimer);
expiryTimer = undefined;
}
};
const complete = () => {
if (completed) return;
completed = true;
clearExpiryTimer();
onComplete();
};
const hasSentCredentials = () => sentPassword || sentUsername;
@@ -114,11 +126,49 @@ function createTelnetAutoLogin(options = {}) {
userInputNotified = true;
onUserInput();
};
// The exchange stalled: credentials were sent but the device still sits at
// a login/password prompt the detector cannot answer. Callers use this to
// avoid blindly running post-login actions against the pending prompt.
const notifyIncomplete = () => {
if (incompleteNotified || completed) return;
incompleteNotified = true;
onIncomplete();
};
// The expiry must not depend on the device sending more bytes: a device
// that rejects the saved credentials, reprints Login/Password and then goes
// silent never triggers the handleText-after-expiry check below, so without
// a timer the incomplete state is never reported and callers would blindly
// type their startup command into the pending login prompt.
const handleExpiry = () => {
expiryTimer = undefined;
if (completed || disabled) return;
disabled = true;
if (hasSentCredentials() && (isUsernamePrompt(tail) || isPasswordPrompt(tail))) {
notifyIncomplete();
}
};
if (timeoutMs >= 0) {
expiryTimer = setTimer(handleExpiry, timeoutMs);
// Do not keep the process alive just for this timer.
expiryTimer?.unref?.();
}
return {
handleText(text) {
if (disabled || isExpired()) {
if (isExpired()) {
disabled = true;
// Keep observing output after expiry: a slow-booting device can
// surface its first Login/Password prompt after the window elapsed
// without any credentials having been sent. Report the pending
// prompt so callers can cancel deferred post-login actions instead
// of blindly typing them into it. Credentials are never sent here.
tail = `${tail}${text || ""}`.slice(-TAIL_LIMIT);
if (isUsernamePrompt(tail) || isPasswordPrompt(tail)) {
notifyIncomplete();
}
return;
}
if (disabled) {
return;
}
@@ -147,11 +197,28 @@ function createTelnetAutoLogin(options = {}) {
sendLine(password);
completeIfReady();
}
// No password is configured but the device is now asking for one: the
// exchange cannot proceed on its own. Report the stall so callers do
// not schedule post-login actions that would be consumed as the
// password. The detector stays enabled: if the device moves on to a
// command prompt, completeIfReady can still fire.
if (!disabled && !completed && !hasPassword && isPasswordPrompt(tail)) {
notifyIncomplete();
}
},
handleUserInput() {
disabled = true;
clearExpiryTimer();
notifyUserInput();
},
// Tear down the detector without emitting anything: used when the owning
// session is displaced by a reconnect, so the stale expiry timer can no
// longer fire onIncomplete against the replacement session.
cancel() {
disabled = true;
clearExpiryTimer();
},
};
}
+225
View File
@@ -280,3 +280,228 @@ test("telnet auto-login works with Kylin V10 Input Password prompt from issue #1
assert.deepEqual(writes, ["lybing\r", "secret\r"]);
});
test("telnet auto-login notifies incomplete when the device asks for a password that is not saved", () => {
const writes = [];
let completed = false;
let incomplete = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
write: (data) => writes.push(data),
onComplete: () => { completed = true; },
onIncomplete: () => { incomplete += 1; },
});
autoLogin.handleText("Username: ");
autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(writes, ["admin\r"]);
assert.equal(completed, false);
assert.equal(incomplete, 1);
// The detector stays alive: if the device moves on to a command prompt the
// exchange can still complete.
autoLogin.handleText("\r\nrouter# ");
assert.equal(completed, true);
assert.equal(incomplete, 1);
});
test("telnet auto-login notifies incomplete when the window expires mid-exchange", () => {
let clock = 0;
let incomplete = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: () => {},
now: () => clock,
timeoutMs: 60_000,
onIncomplete: () => { incomplete += 1; },
});
autoLogin.handleText("Username: ");
autoLogin.handleText("\r\nPassword: ");
// Login failed: the device re-prompts and the detector cannot recover.
autoLogin.handleText("\r\nUsername: ");
assert.equal(incomplete, 0);
clock = 60_001;
autoLogin.handleText("\r\nUsername: ");
assert.equal(incomplete, 1);
// Idempotent: further chunks after expiry do not re-notify.
autoLogin.handleText("more output");
assert.equal(incomplete, 1);
});
test("telnet auto-login does not notify incomplete for quiet devices", () => {
let clock = 0;
let incomplete = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
write: () => {},
now: () => clock,
timeoutMs: 60_000,
onIncomplete: () => { incomplete += 1; },
});
autoLogin.handleText("\r\nWelcome banner\r\n");
clock = 60_001;
autoLogin.handleText("\r\nmore banner");
// Quiet devices never interact, so the caller's quiet-device fallback
// remains valid.
assert.equal(incomplete, 0);
});
test("telnet auto-login owns an expiry timer: rejected login gone silent reports incomplete", () => {
let incomplete = 0;
let timerFn = null;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: () => {},
timeoutMs: 60_000,
onIncomplete: () => { incomplete += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref: () => {} };
},
clearTimeout: () => {},
});
// The device rejects the saved credentials and re-prompts.
autoLogin.handleText("Username: ");
autoLogin.handleText("\r\nPassword: ");
autoLogin.handleText("\r\nUsername: ");
// Then it goes silent: no further bytes arrive, so handleText is never
// called after expiry. The detector's own timer must report the stall.
assert.equal(typeof timerFn, "function");
assert.equal(incomplete, 0);
timerFn();
assert.equal(incomplete, 1);
});
test("telnet auto-login expiry timer stays silent for quiet devices", () => {
let incomplete = 0;
let timerFn = null;
createTelnetAutoLogin({
username: "admin",
write: () => {},
timeoutMs: 60_000,
onIncomplete: () => { incomplete += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref: () => {} };
},
clearTimeout: () => {},
});
// No credentials were ever sent, so the caller's quiet-device fallback
// (typing the startup command) remains valid.
timerFn();
assert.equal(incomplete, 0);
});
test("telnet auto-login expiry timer does not fire after a completed exchange", () => {
let completed = 0;
let incomplete = 0;
let timerFn = null;
const timers = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: () => {},
timeoutMs: 60_000,
onComplete: () => { completed += 1; },
onIncomplete: () => { incomplete += 1; },
setTimeout: (fn) => {
timerFn = fn;
const id = { unref: () => {} };
timers.push(id);
return id;
},
clearTimeout: () => {
const index = timers.indexOf(timerFn);
if (index >= 0) timers.splice(index, 1);
},
});
autoLogin.handleText("Username: ");
autoLogin.handleText("\r\nPassword: ");
autoLogin.handleText("\r\nrouter# ");
assert.equal(completed, 1);
// The exchange completed; firing a stale timer must be a no-op.
timerFn();
assert.equal(completed, 1);
assert.equal(incomplete, 0);
});
test("cancel() disables the detector and clears the expiry timer", () => {
let incomplete = 0;
let completed = 0;
let timerFn;
const timers = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: () => {},
timeoutMs: 60_000,
onComplete: () => { completed += 1; },
onIncomplete: () => { incomplete += 1; },
setTimeout: (fn) => {
timerFn = fn;
const id = { unref: () => {} };
timers.push(id);
return id;
},
clearTimeout: (id) => {
const index = timers.indexOf(id);
if (index >= 0) timers.splice(index, 1);
},
});
// Credentials were sent, so a later expiry would report an incomplete
// exchange. Cancelling (as when the session is displaced by a reconnect)
// must disarm that path entirely.
autoLogin.handleText("Username: ");
autoLogin.handleText("\r\nPassword: ");
autoLogin.cancel();
timerFn();
autoLogin.handleText("\r\nPassword: ");
assert.equal(completed, 0);
assert.equal(incomplete, 0);
assert.deepEqual(timers, []);
});
test("telnet auto-login keeps observing login prompts that arrive after expiry", () => {
let clock = 0;
let incomplete = 0;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onIncomplete: () => { incomplete += 1; },
});
// Slow-booting device: only a banner before the window expires, first
// login prompt arrives afterwards.
autoLogin.handleText("\r\nWelcome banner\r\n");
clock = 60_001;
autoLogin.handleText("\r\nUsername: ");
autoLogin.handleText("\r\nPassword: ");
// The pending prompt must be reported so the caller cancels its
// deferred startup command; no credentials may be sent after expiry.
assert.equal(incomplete, 1);
assert.deepEqual(writes, []);
});
+84 -1
View File
@@ -1310,6 +1310,8 @@ async function startSerialSession(event, options) {
onData(buf) {
const decoded = serialDecoderRef.current.write(buf);
if (!decoded) return;
const liveSession = sessions.get(sessionId);
liveSession?.autoLogin?.handleText(decoded);
const contents = electronModule.webContents.fromId(session.webContentsId);
emitTerminalSessionData(contents, sessionId, decoded, {
session,
@@ -1334,6 +1336,52 @@ async function startSerialSession(event, options) {
});
session.zmodemSentry = serialZmodemSentry;
// Serial auto-login (issue #3417): reuse the Telnet login-assist
// detector to answer Login/Password prompts with the credentials saved
// on the host. Writes go straight to the port (not writeToSession) so
// they are not treated as user input and cannot cancel themselves.
const hasSerialAutoLoginCredentials =
(typeof options.username === "string" && options.username.trim().length > 0)
|| typeof options.password === "string";
if (hasSerialAutoLoginCredentials) {
const emitAutoLoginEvent = (channel) => {
// Guard against this session having been displaced by a serial
// reconnect that reused the same sessionId: the replacement owns
// the registry slot and its bootEpoch, so a stale event stamped
// with the new epoch would make the renderer cancel the
// replacement session's pending startup command.
if (sessions.get(sessionId) !== session) return;
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send(channel, {
sessionId,
bootEpoch: session.bootEpoch ?? options.bootEpoch,
});
};
session.autoLogin = createTelnetAutoLogin({
username: options.username,
password: options.password,
write(data) {
try {
serialPort.write(encodeTerminalInput(data, session.encoding));
} catch { /* port closing — ignore */ }
},
onComplete() {
emitAutoLoginEvent("netcatty:telnet:auto-login-complete");
},
onUserInput() {
emitAutoLoginEvent("netcatty:telnet:auto-login-cancelled");
},
onIncomplete() {
// Stalled/expired exchange (e.g. the device asks for a password
// but none is saved, or the auto-login window elapsed while the
// device still sits at a login prompt). Treat it like a
// cancellation so the renderer does not blindly run the startup
// command against the pending prompt.
emitAutoLoginEvent("netcatty:telnet:auto-login-cancelled");
},
});
}
serialPort.on('data', (data) => {
if (sessions.get(sessionId) !== session) return;
if (session.ymodemActive) return;
@@ -1497,7 +1545,11 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}
try {
if (session.type === 'telnet-native' && !payload.automated) {
if (
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
session.autoLogin?.handleUserInput();
}
@@ -1594,6 +1646,21 @@ function writeToSessionWithInterception(
writeToSessionNow(payload, data, logRewrite);
return;
}
// Cancel the auto-login detector 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.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput();
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
if (!current || current !== expectedSession || current.closed) return;
@@ -1623,6 +1690,19 @@ function writeToSessionWithInterception(
});
}
// Line-mode serial input is buffered in the renderer and only reaches
// writeToSession on Enter, so the auto-login detector would otherwise stay
// armed while the user is already typing. The renderer notifies us on the
// first buffered keystroke so the detector is cancelled the same way it is
// for character-mode input.
function notifySessionUserInput(event, payload) {
const session = sessions.get(payload?.sessionId);
if (!session) return;
if (session.type === 'telnet-native' || session.type === 'serial') {
session.autoLogin?.handleUserInput();
}
}
function writeToSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
@@ -2336,6 +2416,7 @@ function registerHandlers(ipcMain, options = {}) {
"netcatty:resize",
"netcatty:pty:clear",
"netcatty:flow:ack",
"netcatty:terminal:user-input",
].forEach((channel) => registerWorkerSend(ipcMain, terminalWorkerManager, channel));
ipcMain.on("netcatty:flow", (event, payload) => {
if (payload?._flowArbitrated === true) {
@@ -2371,6 +2452,7 @@ function registerHandlers(ipcMain, options = {}) {
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
ipcMain.handle("netcatty:telnet:getEchoMode", getTelnetEchoMode);
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:terminal:user-input", notifySessionUserInput);
ipcMain.on("netcatty:interrupt", interruptSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:pty:clear", clearSessionPtyBuffer);
@@ -2551,6 +2633,7 @@ module.exports = {
receiveSerialYmodem,
listSerialPorts,
writeToSession,
notifySessionUserInput,
setSessionEncoding,
resizeSession,
clearSessionPtyBuffer,
@@ -0,0 +1,123 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: { write: () => true },
autoLogin: { handleUserInput: () => { userInputs += 1; } },
};
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
assert.equal(userInputs, 1);
terminalBridge.writeToSession({}, { sessionId: "s", data: "y", automated: true });
assert.equal(userInputs, 1);
// xterm replies to ANSI queries (cursor position, DA1, ...) travel through
// onData without `automated`; they must not cancel auto-login.
terminalBridge.writeToSession({}, { sessionId: "s", data: "\x1b[24;80R" });
assert.equal(userInputs, 1);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode buffered input notification cancels auto-login", () => {
let userInputs = 0;
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: { write: () => true },
autoLogin: {
// Mirror createTelnetAutoLogin.handleUserInput: idempotent cancel.
handleUserInput() {
if (this.disabled) return;
this.disabled = true;
userInputs += 1;
},
disabled: false,
},
};
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
// Serial line mode buffers keystrokes in the renderer and only writes to
// the session on Enter, so cancellation is signalled via a dedicated
// notification instead of the write path.
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
assert.equal(userInputs, 1);
// Idempotent: further notifications do not re-notify.
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
assert.equal(userInputs, 1);
// Unknown sessions are ignored.
terminalBridge.notifySessionUserInput({}, { sessionId: "missing" });
assert.equal(userInputs, 1);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: { write: (d) => writes.push(Buffer.from(d)) },
};
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "ls\r" });
assert.deepEqual(writes.map((b) => b.toString()), ["ls\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial auto-login cancels at input ingress before a slow interceptor resolves", async () => {
let userInputs = 0;
let interceptorResolved = false;
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: { write: () => true },
autoLogin: {
// Mirror createTelnetAutoLogin.handleUserInput: idempotent cancel.
handleUserInput() {
if (this.disabled) return;
this.disabled = true;
userInputs += 1;
},
disabled: 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: "ls\r" });
// Cancellation must happen synchronously at ingress, not only after the
// asynchronous interceptor resolves.
assert.equal(userInputs, 1);
assert.equal(interceptorResolved, false);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(userInputs, 1);
} finally {
terminalBridge.cleanupAllSessions();
}
});
+3
View File
@@ -356,6 +356,9 @@ function createPreloadApi(ctx) {
: undefined,
});
},
notifySessionUserInput: (sessionId) => {
ipcRenderer.send("netcatty:terminal:user-input", { sessionId });
},
interruptSession: (sessionId, trace) => {
const sanitizedTrace = sanitizeInterruptTrace(trace);
if (ctx.terminalUrgentInputPorts?.postInterrupt?.(sessionId, sanitizedTrace)) {
+9
View File
@@ -144,6 +144,9 @@ declare global {
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
// Optional auto-login credentials saved on the host (#3417)
username?: string;
password?: string;
charset?: string;
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
}): Promise<string>;
@@ -335,6 +338,12 @@ declare global {
logRewrite?: { sentCommand: string; displayCommand: string };
},
): void;
/**
* Report interactive user input for sessions where keystrokes are buffered
* in the renderer (serial line mode), so main-process login-assist
* detectors see the user taking control.
*/
notifySessionUserInput?(sessionId: string): void;
interruptSession?(sessionId: string, trace?: NetcattyTerminalInterruptTrace): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
/**