Compare commits

...
11 changed files with 630 additions and 45 deletions

No files matched your search

+74 -19
View File
@@ -108,7 +108,12 @@ import {
resumeScriptRun,
stopScriptRun,
} from "@/application/state/scriptAutomationCoordinator.ts";
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
import {
hasUnresolvedConnectScriptBindings,
resolveConnectScriptsForHost,
shouldMarkConnectAutomationConsumed,
shouldUseFreshSshConnectionForAutomation,
} from "@/domain/hostConnectScripts.ts";
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
@@ -352,6 +357,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const connectScriptsInFlightRef = useRef(false);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
const pendingScriptIdRef = useRef(pendingScriptId);
pendingScriptRef.current = pendingScript;
pendingScriptIdRef.current = pendingScriptId;
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
if (snippet.id) {
return pendingScriptRunIdRef.current === snippet.id;
}
return pendingScriptHandledRef.current === snippet;
}, []);
// Mosh marks status=connected during the SSH handshake so interactive
// prompts remain reachable. Connect/pending scripts must wait until
// mosh-client is ready (#2199). closeSession clears preload ready
@@ -724,6 +739,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [progressValue, setProgressValue] = useState(15);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
reuseConnectionFromSessionId,
);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
@@ -1042,6 +1060,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const passwordPickerEmptyText = t("terminal.passwordPicker.empty");
const sudoHintText = t("terminal.sudoHint.pressEnter");
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId);
const reuseConnectionSourceAttemptedRef = useRef(false);
const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId);
if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) {
previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId;
reuseConnectionSourceRef.current = reuseConnectionFromSessionId;
reuseConnectionSourceAttemptedRef.current = false;
}
const auth = useTerminalAuthState({
host,
pendingAuthRef,
@@ -2137,7 +2163,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
knownHosts,
resolvedChainHosts,
sessionId,
reuseConnectionFromSessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
const currentPendingScript = pendingScriptRef.current;
const currentPendingScriptId = pendingScriptIdRef.current;
const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript)
? !isPendingScriptAlreadyHandled(currentPendingScript)
: Boolean(
currentPendingScriptId
&& pendingScriptRunIdRef.current !== currentPendingScriptId,
);
return shouldUseFreshSshConnectionForAutomation({
host: hostRef.current,
snippets: snippetsRef.current,
vaultInitialized: isVaultInitialized(),
hasPendingScript: hasUnhandledPendingScript,
connectAutomationConsumed: connectScriptsConsumedRef.current,
});
},
onConnectAutomationSnapshotCommitted: () => {
connectScriptsConsumedRef.current = true;
},
isNetworkDevice,
startupCommand,
noAutoRun,
@@ -2314,13 +2362,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingScriptHandledRef.current = null;
}, [pendingScript?.id, pendingScriptId]);
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
if (snippet.id) {
return pendingScriptRunIdRef.current === snippet.id;
}
return pendingScriptHandledRef.current === snippet;
}, []);
useEffect(() => {
if (status !== 'connected') return;
if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) return;
@@ -2337,6 +2378,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
// An empty hydrated vault is a final decision for this connection. Lock it
// before the output-settling delay so a sync arriving during that window
// cannot inject a new connect script into an already-reused SSH session.
if (
!connectScriptsConsumedRef.current
&& resolvedConnectScripts.length === 0
&& shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: isVaultInitialized(),
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
})
) {
connectScriptsConsumedRef.current = true;
}
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
const hasPendingWork = Boolean(pendingOne);
if (!shouldEvaluateConnect && !hasPendingWork) return;
@@ -2348,7 +2405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
: resolveConnectScriptsForHost(host, snippets).filter(
: resolvedConnectScripts.filter(
(item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id),
);
@@ -2360,20 +2417,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
scriptsToRun.push(pendingOne);
}
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|| resolvedConnectScripts.every(
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
);
if (scriptsToRun.length === 0) {
if (
!connectScriptsConsumedRef.current
&& allConnectScriptsDone
&& isVaultInitialized()
&& snippets.length > 0
&& !hasUnresolvedConnectScriptBindings(host, snippets)
) {
if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({
allConnectScriptsDone,
vaultInitialized: isVaultInitialized(),
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
})) {
connectScriptsConsumedRef.current = true;
}
return;
@@ -3181,6 +3235,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
setConnectionReuseAttemptSourceId(undefined);
updateStatus("connecting");
setError(null);
setProgressLogs((prev) => (
@@ -3270,7 +3325,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSerialConnection,
isDisconnectedDialogDismissed,
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId,
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
host,
connectionReuseFellBack,
}),
@@ -426,7 +426,7 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
};
const ctx = createStarterContext({
isNetworkDevice: true,
reuseConnectionFromSessionId: "source-session",
reuseConnectionFromSessionIdRef: { current: "source-session" },
terminalBackend,
});
@@ -436,6 +436,206 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
assert.equal(capturedOptions?.skipShellPidDiscovery, true);
});
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
return `ssh-session-${captured.length}`;
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
const reuseAttempts: Array<string | undefined> = [];
let requiresFreshConnection = true;
let committedAutomationSnapshots = 0;
const automatedStarters = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => requiresFreshConnection,
onConnectAutomationSnapshotCommitted: () => {
committedAutomationSnapshots += 1;
},
reuseConnectionFromSessionIdRef,
setConnectionReuseAttemptSourceId: (sourceSessionId: string | undefined) => {
reuseAttempts.push(sourceSessionId);
},
terminalBackend,
}) as never);
await automatedStarters.startSSH(createTermStub() as never);
await automatedStarters.startSSH(createTermStub() as never);
assert.equal(committedAutomationSnapshots, 0);
requiresFreshConnection = false;
await automatedStarters.startSSH(createTermStub() as never);
assert.equal(committedAutomationSnapshots, 1);
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[2].reuseTransport, undefined);
assert.equal(captured[2].sourceSessionId, undefined);
assert.equal(captured[3].reuseTransport, undefined);
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
});
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
let resolveStart: ((sessionId: string) => void) | undefined;
let commitCount = 0;
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: () => new Promise<string>((resolve) => {
resolveStart = resolve;
}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const startPromise = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
onConnectAutomationSnapshotCommitted: () => {
commitCount += 1;
},
terminalBackend,
}) as never).startSSH(createTermStub() as never);
await Promise.resolve();
assert.equal(commitCount, 0);
assert.ok(resolveStart);
resolveStart("ssh-session");
await startPromise;
assert.equal(commitCount, 1);
});
test("startSSH rechecks the live automation policy before password fallback", async () => {
const captured: Record<string, unknown>[] = [];
let requiresFreshConnection = false;
let commitCount = 0;
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
if (captured.length === 1) {
requiresFreshConnection = true;
throw new Error("Authentication failed");
}
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
password: "login-secret",
},
keys: [{
id: "key-1",
name: "Key",
privateKey: "plain-private-key",
publicKey: "",
source: "embedded",
}],
shouldUseFreshSshConnection: () => requiresFreshConnection,
onConnectAutomationSnapshotCommitted: () => {
commitCount += 1;
},
terminalBackend,
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(commitCount, 0);
});
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
if (captured.length === 1) throw new Error("Authentication failed");
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
const reuseConnectionSourceAttemptedRef = { current: false };
const firstCtx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
},
keys: [{
id: "key-1",
name: "Key",
privateKey: "plain-private-key",
publicKey: "",
source: "embedded",
}],
reuseConnectionFromSessionIdRef,
reuseConnectionSourceAttemptedRef,
shouldUseFreshSshConnection: () => false,
terminalBackend,
});
await createTerminalSessionStarters(firstCtx as never).startSSH(createTermStub() as never);
assert.equal(reuseConnectionSourceAttemptedRef.current, true);
const retryCtx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "password",
password: "corrected-secret",
},
reuseConnectionFromSessionIdRef,
reuseConnectionSourceAttemptedRef,
shouldUseFreshSshConnection: () => false,
terminalBackend,
});
await createTerminalSessionStarters(retryCtx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(reuseConnectionSourceAttemptedRef.current, false);
});
test("startSSH uses the system agent when a synced vault key cannot be decrypted", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
@@ -529,12 +529,27 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
? ctx.host.identityFilePaths
: undefined;
let sourceReuseAttemptedWithinStart = false;
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
useIdentityFiles?: boolean;
useSshAgent?: boolean;
}): Promise<string> => {
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
?? sourceReuseAttemptedWithinStart;
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
if (ctx.reuseConnectionFromSessionIdRef) {
ctx.reuseConnectionFromSessionIdRef.current = undefined;
}
if (sourceSessionId) {
sourceReuseAttemptedWithinStart = true;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = true;
}
}
ctx.setConnectionReuseAttemptSourceId?.(sourceSessionId);
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
// Resolve keepalive per-host: a host can opt into its own values
@@ -546,7 +561,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
globalTerminalSettings,
);
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
return ctx.terminalBackend.startSSHSession({
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
const startedSessionId = await ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
hostname: ctx.host.hostname,
@@ -599,9 +615,23 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// (issue #1204). Only honored on the very first connect attempt; the
// bridge silently falls back to a fresh connection if the source is
// gone, so reconnect/retry after the source closed still works.
sourceSessionId: ctx.reuseConnectionFromSessionId,
sourceSessionId,
// Connect-time automation must see the complete login sequence. An
// explicit Copy/Split keeps its source-session reuse contract, while
// an ordinary open bypasses endpoint/idle transport reuse.
reuseTransport: !sourceSessionId && (requiresFreshSshConnection || isFallbackAfterSourceReuse)
? false
: undefined,
skipShellPidDiscovery: ctx.isNetworkDevice === true,
});
if (!requiresFreshSshConnection) {
ctx.onConnectAutomationSnapshotCommitted?.();
}
sourceReuseAttemptedWithinStart = false;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = false;
}
return startedSessionId;
};
let id: string;
@@ -1,6 +1,6 @@
import type { FitAddon } from "@xterm/addon-fit";
import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import type { PromptLineBreakState } from "./promptLineBreak";
import type {
@@ -147,9 +147,21 @@ export type TerminalSessionStartersContext = {
knownHosts?: KnownHost[];
resolvedChainHosts: Host[];
sessionId: string;
// Source session id to reuse an authenticated SSH connection from when this
// terminal was created from an existing SSH session.
reuseConnectionFromSessionId?: string;
// One-shot source session intent for Copy/Split. Consumed by the first SSH
// attempt so later reconnects do not skip the initial login sequence.
reuseConnectionFromSessionIdRef?: MutableRefObject<string | undefined>;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
// Mirrors the source actually consumed by the current SSH attempt so the UI
// only hides its connecting dialog while Copy/Split reuse is being tried.
setConnectionReuseAttemptSourceId?: (sourceSessionId: string | undefined) => void;
// Connect automation and still-unhandled pending scripts need the initial
// login output. Evaluate per attempt because pending work is one-shot.
shouldUseFreshSshConnection?: () => boolean;
// Commit the no-automation snapshot only after the corresponding backend
// session actually starts, so failed auth attempts do not consume scripts.
onConnectAutomationSnapshotCommitted?: () => void;
isNetworkDevice?: boolean;
startupCommand?: string;
noAutoRun?: boolean;
+8
View File
@@ -421,6 +421,14 @@ test("connection dialog keeps existing local and disconnected behavior", () => {
});
test("connection reuse hides connecting dialog only while reuse is still possible", () => {
assert.equal(
shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId: undefined,
host: host(),
connectionReuseFellBack: false,
}),
false,
);
assert.equal(
shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId: "source-session",
+67
View File
@@ -5,10 +5,13 @@ import {
appendHostConnectScript,
getGlobalConnectScripts,
getHostConnectScriptIds,
hasHostConnectAutomation,
migrateHostConnectScriptIds,
reorderHostConnectScript,
removeHostConnectScript,
resolveConnectScriptsForHost,
shouldMarkConnectAutomationConsumed,
shouldUseFreshSshConnectionForAutomation,
syncHostsForSnippetTargetChange,
} from './hostConnectScripts.ts';
@@ -54,6 +57,70 @@ test('resolveConnectScriptsForHost runs globals before host queue and dedupes',
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
});
test('hasHostConnectAutomation covers host, global, and unresolved connect scripts', () => {
assert.equal(
hasHostConnectAutomation(
{ ...host, connectScriptIds: ['host-script'] },
[script({ id: 'host-script', targets: ['host-a'] })],
),
true,
);
assert.equal(
hasHostConnectAutomation(host, [script({ id: 'global', targetsAllHosts: true })]),
true,
);
assert.equal(
hasHostConnectAutomation({ ...host, loginScriptId: 'not-loaded-yet' }, []),
true,
);
assert.equal(hasHostConnectAutomation(host, []), false);
});
test('fresh SSH automation policy is conservative before vault hydration and for pending scripts', () => {
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: false,
}), true);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: true,
hasPendingScript: true,
}), true);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: true,
}), false);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [script({ id: 'global', targetsAllHosts: true })],
vaultInitialized: true,
connectAutomationConsumed: true,
}), false);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [script({ id: 'global', targetsAllHosts: true })],
vaultInitialized: true,
hasPendingScript: true,
connectAutomationConsumed: true,
}), true);
});
test('empty hydrated vault finalizes the current connection automation decision', () => {
assert.equal(shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: true,
hasUnresolvedBindings: false,
}), true);
assert.equal(shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: false,
hasUnresolvedBindings: false,
}), false);
});
test('append updates host connectScriptIds order', () => {
const snippets = [
script({ id: 'a', targets: ['host-a'] }),
+43
View File
@@ -118,6 +118,49 @@ export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): S
return [...globals, ...hostScripts];
}
/**
* Whether connecting this host can run automation that depends on the initial
* login output. Missing referenced scripts still count: vault hydration or a
* later sync may restore them after the terminal has already started.
*/
export function hasHostConnectAutomation(host: Host, snippets: Snippet[]): boolean {
return hasUnresolvedConnectScriptBindings(host, snippets)
|| resolveConnectScriptsForHost(host, snippets).length > 0;
}
export function shouldUseFreshSshConnectionForAutomation(options: {
host: Host;
snippets: Snippet[];
vaultInitialized: boolean;
hasPendingScript?: boolean;
connectAutomationConsumed?: boolean;
}): boolean {
return options.hasPendingScript === true
|| (
options.connectAutomationConsumed !== true
&& (
!options.vaultInitialized
|| hasHostConnectAutomation(options.host, options.snippets)
)
);
}
/**
* Mark the current connection's automation decision as final once the vault
* has hydrated, even when it hydrated to an empty script list. This prevents a
* later sync from starting a newly arrived global script against a connection
* whose initial login output may already have been skipped.
*/
export function shouldMarkConnectAutomationConsumed(options: {
allConnectScriptsDone: boolean;
vaultInitialized: boolean;
hasUnresolvedBindings: boolean;
}): boolean {
return options.allConnectScriptsDone
&& options.vaultInitialized
&& !options.hasUnresolvedBindings;
}
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const snippet = scriptById(snippets, scriptId);
if (!snippet) return host;
+24 -2
View File
@@ -1108,7 +1108,7 @@ function isStrictAgentAuthFailure(options, err) {
}
function canReuseExistingSession(options) {
if (!options.sourceSessionId || options.x11Forwarding) return false;
if (options.reuseTransport === false || !options.sourceSessionId || options.x11Forwarding) return false;
return Boolean(findReusableSession(sessions, options.sourceSessionId, {
hostname: options.hostname,
port: options.port || 22,
@@ -1300,16 +1300,38 @@ async function startSSHSessionWithRetries(event, options, pendingDialState) {
async function startSSHSessionWrapper(event, options) {
const pendingDialState = { coordination: null };
let sourcePinHolder = null;
let sourceReuseState = options.sourceSessionId && options.reuseTransport !== false
? { attempted: false, session: null }
: null;
if (options.sourceSessionId && options.reuseTransport !== false) {
const sourceAtRequest = findReusableSession(sessions, options.sourceSessionId);
if (sourceAtRequest?.connRef) {
sourcePinHolder = {};
acquireConnectionRef(sourcePinHolder, sourceAtRequest.connRef);
sourceReuseState.session = {
conn: sourceAtRequest.conn,
connRef: sourceAtRequest.connRef,
stream: sourceAtRequest.stream,
_reuseEndpoint: sourceAtRequest._reuseEndpoint,
};
}
}
try {
// Main-process LAN probe so macOS Local Network TCC attributes to Netcatty
// before the (possibly worker-hosted) SSH dial. See #2663 / TN3179.
await ensureMacLocalNetworkAccess(options);
return await startSSHSessionWithRetries(event, options, pendingDialState);
return await startSSHSessionWithRetries(event, {
...options,
...(sourceReuseState ? { _sourceReuseState: sourceReuseState } : {}),
}, pendingDialState);
} catch (err) {
if (pendingDialState.coordination) {
failTransportDial(pendingDialState.coordination, err);
}
throw err;
} finally {
if (sourcePinHolder) releaseConnectionRef(sourcePinHolder);
}
}
@@ -104,6 +104,91 @@ test("simultaneous normal opens of the same host make one physical SSH dial", as
assert.equal(sessions.get("normal-1").connRef.count, 2);
});
test("reuseTransport false makes simultaneous same-host opens dial independently", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.51",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
reuseTransport: false,
};
await Promise.all([
start({ sender: makeSender() }, { ...options, sessionId: "fresh-1" }),
start({ sender: makeSender() }, { ...options, sessionId: "fresh-2" }),
]);
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("fresh-1").conn, sessions.get("fresh-2").conn);
});
test("a sequential ordinary open with reuse disabled bypasses the live same-host transport", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.52",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
await start({ sender: makeSender() }, {
...options,
sessionId: "second",
reuseTransport: false,
});
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("first").conn, sessions.get("second").conn);
});
test("an ordinary open with reuse disabled bypasses an idle same-host transport", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.53",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const firstTransport = first.connRef;
first.stream.emit("close");
assert.equal(firstTransport.state, "idle");
await start({ sender: makeSender() }, {
...options,
sessionId: "second",
reuseTransport: false,
});
assert.equal(getClientConstructCount(), 2);
assert.notEqual(firstTransport.conn, sessions.get("second").conn);
});
function makeSender() {
return {
id: 1,
@@ -312,6 +397,34 @@ test("Copy Tab reuses the source connection instead of dialing fresh", async (t)
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0, "successful reuse should not emit fallback");
});
test("reuseTransport false bypasses a matching live source and connects fresh", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const sourceConn = makeReusableConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "fresh",
hostname: "10.0.0.1",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sourceSessionId: "source",
reuseTransport: false,
},
);
assert.equal(getClientConstructCount(), 1);
assert.equal(sourceConn.openedShells.length, 0);
assert.notEqual(sessions.get("fresh").conn, sourceConn);
});
test("Copy Tab records a distinct remote shell for each shared terminal", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
@@ -443,8 +556,13 @@ test("concurrent Copy Tab requests serialize shell discovery per connection", as
]);
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy-1").shellPid, "222");
assert.equal(sessions.get("copy-2").shellPid, "333");
// The macOS network preflight runs before either request joins the shared
// shell-open queue, so under load either copy may enqueue first. The contract
// is that both remote shells are identified once and never collide.
assert.deepEqual(
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
new Set(["222", "333"]),
);
});
test("concurrent copies keep distinct shell IDs when the source closes immediately", async (t) => {
@@ -477,8 +595,10 @@ test("concurrent copies keep distinct shell IDs when the source closes immediate
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
await copies;
assert.equal(sessions.get("copy-1").shellPid, "222");
assert.equal(sessions.get("copy-2").shellPid, "333");
assert.deepEqual(
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
new Set(["222", "333"]),
);
});
test("Copy Tab preserves the server locale unless the host explicitly overrides it", async (t) => {
@@ -591,19 +711,20 @@ test("source closed while reused shell is pending keeps the connection alive", a
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
// Begin the copy; conn.shell() is deferred, so the reuse path has acquired the
// ref (count -> 2) but not yet attached the session.
// Begin the copy; conn.shell() is deferred. The request pin protects the
// explicit source across preflight, and the shell-open pin protects the
// pending channel (count -> 3) before the real session is attached.
const startPromise = start(
{ sender: makeSender() },
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
);
await new Promise((r) => setImmediate(r));
assert.equal(connRef.count, 2, "reuse pins the connection before opening the shell");
assert.equal(connRef.count, 3, "request and shell-open pins protect the source connection");
// Close the source tab while the copy's shell is still opening.
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
assert.equal(conn.ended, 0, "connection must survive for the pending copy");
assert.equal(connRef.count, 1);
assert.equal(connRef.count, 2);
// Now let the copy's shell open.
conn.flushShell();
@@ -678,6 +799,8 @@ test("synchronous shell failure releases the ref and falls back to fresh", async
test("falls back to a fresh connection when the source is gone", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const otherConn = makeReusableConn();
sessions.set("other", makeSourceSession(otherConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
// sourceSessionId points at a session that doesn't exist -> fresh connect.
@@ -695,6 +818,7 @@ test("falls back to a fresh connection when the source is gone", async (t) => {
},
),
);
assert.equal(otherConn.openedShells.length, 0, "must not substitute another matching transport");
assert.equal(getClientConstructCount(), 1, "should attempt one fresh connection");
assert.deepEqual(
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
+29 -4
View File
@@ -871,9 +871,29 @@ printf '%s\n' '${scanCompleteMarker}'`;
// X11. For X11 hosts we deliberately skip reuse and make a fresh
// connection so the duplicate keeps working X11 forwarding.
const reuseEndpoint = buildConnectionReuseEndpoint(options);
const allowTransportReuse = options.reuseTransport !== false;
// Copy/Split reuse is source-specific. If that source disappears or its
// channel cannot open, fall through to a fresh login instead of silently
// borrowing another live, idle, or in-flight transport for the endpoint.
const allowGeneralTransportReuse = allowTransportReuse && !options.sourceSessionId;
const sourceReuseState = options._sourceReuseState;
const canAttemptSourceReuse = Boolean(
allowTransportReuse
&& options.sourceSessionId
&& !options.x11Forwarding
&& (!sourceReuseState || sourceReuseState.attempted !== true),
);
if (options.sourceSessionId && !options.x11Forwarding) {
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint);
if (canAttemptSourceReuse) {
if (sourceReuseState) sourceReuseState.attempted = true;
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint)
|| (sourceReuseState?.session
? findReusableSession(
new Map([[options.sourceSessionId, sourceReuseState.session]]),
options.sourceSessionId,
reuseEndpoint,
)
: null);
if (sourceSession) {
try {
return await reuseShellSession(event, options, sourceSession, sessionId, log);
@@ -897,7 +917,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
// Idle-park / endpoint reuse: after the last tab returns its lease the
// transport may still be warm. Open a new shell channel without re-auth.
if (!options.x11Forwarding && typeof findTransportByEndpoint === "function") {
if (allowGeneralTransportReuse && !options.x11Forwarding && typeof findTransportByEndpoint === "function") {
// Shell park reuse requires exact agentForwarding match so disabling
// ForwardAgent cannot reattach to a warm conn that still exposes the agent.
const parked = findTransportByEndpoint(reuseEndpoint, { kind: "shell" });
@@ -938,7 +958,12 @@ printf '%s\n' '${scanCompleteMarker}'`;
// for the same compatible endpoint can wait for this leader and then
// open its own channel on the authenticated transport.
let pendingDialCoordination = options._pendingDialState?.coordination || null;
if (!pendingDialCoordination && !options.x11Forwarding && typeof beginTransportDial === "function") {
if (
allowGeneralTransportReuse
&& !pendingDialCoordination
&& !options.x11Forwarding
&& typeof beginTransportDial === "function"
) {
const coordination = beginTransportDial(reuseEndpoint, { kind: "shell" });
if (coordination.role === "reuse" || coordination.role === "join") {
try {
Vendored
+4 -5
View File
@@ -165,11 +165,10 @@ declare global {
// Skip POSIX process discovery when copying a network-device session.
skipShellPidDiscovery?: boolean;
/**
* When false, openSftp must dial a fresh SSH connection and must not
* borrow a parked/shared registry transport. Used for dedicated bulk
* transfer / restart-resume so transfers do not attach to a terminal
* conn that may die or leave checkpoint resume half-bound.
* Default true (browse / MFA-skip reuse of parked transports).
* When false, terminal/SFTP opens must dial a fresh SSH connection and
* must not borrow a live, parked, or in-flight registry transport. Used by
* connect-time terminal automation and dedicated bulk transfers.
* Default true (normal terminal, browse, and MFA-skip reuse).
*/
reuseTransport?: boolean;
}