Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 1723024860 chore: retrigger CI after unrelated TERM-SSHD flake
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 16:08:36 +00:00
Cursor Agentand陈大猫 17f89ca6ef fix(terminal): match live startup host to the SSH dial target
A restored pane can hydrate a saved host with the same id but a new
hostname, port, username, or protocol while startSSH still dials the
frozen snapshot. Require those dial fields to match before reading
hostRef's startup command. Serial stays on the boot snapshot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:52:13 +00:00
Cursor Agentand陈大猫 08355fa5cb fix(terminal): keep hydrated serial startup commands out of auto-login
Serial preflight decides whether to wait for auto-login from the boot
snapshot, then schedules again after the port opens. Reading hostRef at
that second step could type a command hydrated during startSerialSession
into the saved username/password exchange. Live startup-host lookup now
stays on the SSH restore path, including legacy hosts with no protocol.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:44:20 +00:00
Cursor Agentand陈大猫 f1d135f5af fix(terminal): run the live host startup command after restore
Restored SSH boot closes over the pre-hydration host and only restarts
when host.id changes, so scheduleStartupCommand missed a startup command
that landed on hostRef during the dial. Read that command at attach time.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:35:58 +00:00
3 changed files with 309 additions and 2 deletions

No files matched your search

@@ -348,6 +348,62 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
assert.deepEqual(writtenCommands, []);
});
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
const writtenCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let releaseOpen: (() => void) | undefined;
const openPending = new Promise<void>((resolve) => {
releaseOpen = resolve;
});
const bootHost = {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial" as const,
username: "admin",
password: "secret",
};
const hostRef = { current: bootHost };
const backend = buildBackend({
startSerialSession: async () => {
await openPending;
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: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
});
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
// Vault hydration can replace the fallback host after preflight has already
// decided there is no command to hold for auto-login.
hostRef.current = { ...bootHost, startupCommand: "show version" };
releaseOpen?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
autoLoginComplete?.({ sessionId: "session-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(autoLoginComplete, null);
assert.equal(ctx.hasRunStartupCommandRef.current, false);
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'] });
@@ -3653,6 +3653,207 @@ test("restored local reconnect runs the host startup command while automatic ret
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
// Session restore mounts the pane before the vault host (and group defaults)
// replace the fallback snapshot. The dial can still succeed from that
// snapshot; the startup command is only known on the effective host by the
// time the shell is attached.
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const effectiveHost = {
...bootHost,
startupCommand: "echo restored-startup",
startupCommandRunMode: "paste" as const,
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = effectiveHost;
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
sessionWrites.length = 0;
hostRef.current = effectiveHost;
const automaticCtx = createStarterContext({
...ctx,
sessionRef: { current: null },
hasRunStartupCommandRef: { current: false },
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
},
});
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const mismatches = [
{ label: "hostname", patch: { hostname: "moved.example.test" } },
{ label: "port", patch: { port: 2222 } },
{ label: "username", patch: { username: "bob" } },
{ label: "protocol", patch: { protocol: "telnet" as const } },
];
for (const { label, patch } of mismatches) {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
...patch,
startupCommand: "echo moved-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
assert.deepEqual(sessionWrites, [], label);
}
});
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
port: undefined,
startupCommand: "echo restored-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
});
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
@@ -3,6 +3,56 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
/**
* Host startup is scheduled only after the shell attaches. Restored SSH panes
* boot from the session fallback first; vault hydration (including group
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
* from the live host only when that object is still this dial: same id,
* hostname, username, and dial port (`port || 22`), and the same protocol
* when the live host sets one. A missing protocol is a legacy SSH host.
* A saved host edited onto another endpoint since the workspace was saved
* keeps the snapshot, so its command is not typed into the old session.
*
* Serial preflight uses this same resolver to decide whether to wait for
* auto-login, then schedules again after the port opens. Non-SSH transports
* stay on the snapshot, so a command that appears on `hostRef` during the
* open is not typed into a saved username/password exchange.
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
* A different host id is a later pane, not this dial, so keep the snapshot.
*/
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
protocol === "ssh" || protocol === undefined;
const sshDialPort = (port: number | undefined): number => port || 22;
const sameSshDialTarget = (
dial: TerminalSessionStartersContext["host"],
live: TerminalSessionStartersContext["host"],
): boolean => {
if (live.hostname !== dial.hostname) return false;
if ((live.username ?? "") !== (dial.username ?? "")) return false;
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
// A live protocol, when set, must still be the protocol this dial used.
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
return true;
};
const resolveStartupHost = (
ctx: TerminalSessionStartersContext,
): TerminalSessionStartersContext["host"] => {
const live = ctx.hostRef?.current;
if (
prefersLiveStartupHost(ctx.host.protocol)
&& live
&& live.id === ctx.host.id
&& sameSshDialTarget(ctx.host, live)
) {
return live;
}
return ctx.host;
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
@@ -38,7 +88,7 @@ export const resolveStartupCommand = (
ctx: TerminalSessionStartersContext,
options?: { consumeSuppressHostStartupCommand?: boolean },
): string | undefined => {
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
ctx.suppressHostStartupCommandRef.current = false;
}
@@ -96,7 +146,7 @@ export const scheduleStartupCommand = (
const runMode = ctx.startupCommand
? (ctx.multiLineRunMode ?? "paste")
: (ctx.host.startupCommandRunMode ?? "paste");
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
if (runMode === "paste") {
timeoutId = setTimeout(() => {
if (cancelled) return;