mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35e2b23ea | ||
|
|
005739230c | ||
|
|
a4ef55f174 | ||
|
|
2f8f7daa56 | ||
|
|
ad8b5aa2a7 | ||
|
|
3e54c585af | ||
|
|
55b2dacbcc | ||
|
|
f200aafc8f | ||
|
|
9d876cf6d5 |
No files matched your search
+74
-19
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'] }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user