Files
NetMesh/electron/bridges/sshBridge/startSession.cjs

2660 lines
119 KiB
JavaScript
Raw Normal View History

/* eslint-disable no-undef */
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
const {
setBufferedOutputBytes,
shouldAcceptSessionOutput,
shouldProcessSessionOutput,
} = require("../terminalFlowAck.cjs");
const {
filterTerminalInterruptOutput,
takePendingInterruptOutputMeta,
} = require("../terminalInterruptOutputGate.cjs");
const {
logTerminalInterruptDrainDropSample,
logTerminalOutputDropSample,
} = require("../terminalInterruptDiagnostics.cjs");
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
const { getAttachHomeWebContentsId } = require("../terminalAttachRestore.cjs");
const { openBoundedSshShellCallback } = require("../boundedSshChannelOpen.cjs");
const { listInteractiveShellPids: listInteractiveShellPidsShared } = require("../sshInteractiveShells.cjs");
const {
shouldConfirmReusedShellLiveness,
resolveReusedShellLivenessMs,
waitForReusedShellLiveness,
} = require("../sshIdleParkPolicy.cjs");
const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
} = require("../macLocalNetworkAccess.cjs");
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
const MAX_SSH_CONNECTION_TIMEOUT_MS = 3600000;
const COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS = 30000;
/**
* Fan out netcatty:exit to the primary contents plus any attach-home owner
* (AI observe popup rebind) so neither side is left stale.
*/
function safeSendSessionExit(ctx, primaryContents, sessionId, payload) {
const { safeSend, electronModule, sessions } = ctx;
const seen = new Set();
const sendTo = (contents) => {
if (!contents || typeof contents.id !== "number" || seen.has(contents.id)) return;
seen.add(contents.id);
try {
safeSend(contents, "netcatty:exit", payload);
} catch {
// ignore destroyed renderers
}
};
sendTo(primaryContents);
try {
const live = sessions?.get?.(sessionId);
if (typeof live?.webContentsId === "number") {
sendTo(electronModule?.webContents?.fromId?.(live.webContentsId));
}
} catch {
// ignore
}
try {
const homeId = getAttachHomeWebContentsId(sessionId);
if (typeof homeId === "number") {
sendTo(electronModule?.webContents?.fromId?.(homeId));
}
} catch {
// ignore
}
}
function normalizeSshConnectionTimeoutMs(value, fallback) {
return Number.isFinite(value) && value >= 1000 && value <= MAX_SSH_CONNECTION_TIMEOUT_MS
? Math.round(value)
: fallback;
}
function resolveSshConnectionTimeouts(options = {}) {
return {
tcpConnectTimeoutMs: normalizeSshConnectionTimeoutMs(
options.sshTcpConnectTimeoutMs,
SSH_TCP_CONNECT_TIMEOUT_MS,
),
authReadyTimeoutMs: normalizeSshConnectionTimeoutMs(
options.sshAuthReadyTimeoutMs,
SSH_AUTH_READY_TIMEOUT_MS,
),
};
}
function isSshAuthFailure(err) {
const message = err?.message?.toLowerCase() || "";
return err?.level === "client-authentication" ||
message.includes("all configured authentication methods failed") ||
message.includes("authentication failed") ||
message.includes("too many authentication failures") ||
/permission denied\s*\(/.test(message) ||
message.includes("no authentication methods available");
}
function userVisibleSshErrorMessage(err, options = {}) {
const firstHop = resolveFirstTcpEndpoint(options);
return annotateMacLocalNetworkErrorMessage(err?.message || String(err || ""), {
hostname: options.hostname || options.host,
firstHopHostname: firstHop.skipProbe ? "" : firstHop.hostname,
firstHopResolvedAddress: firstHop.skipProbe
? ""
: (options._macLocalNetworkResolvedFirstHop || options.firstHopResolvedAddress || ""),
skipProbe: firstHop.skipProbe === true,
});
}
function hasSelectedAgentIdentity(options) {
const hasInlinePublicKey = Array.isArray(options?.agentPublicKeys)
&& options.agentPublicKeys.some((key) => typeof key === "string" && key.trim().length > 0);
const hasReferencedIdentity = Array.isArray(options?.identityFilePaths)
&& options.identityFilePaths.some((filePath) => typeof filePath === "string" && filePath.trim().length > 0);
return hasInlinePublicKey || hasReferencedIdentity;
}
function shouldOfferAgentForLogin(options, connectOpts) {
const selectedMethod = options?.authMethod;
const hasRestrictedSelectedAgent = selectedMethod === "key"
&& options?.useSshAgent === true
&& options?.identitiesOnly === true
&& hasSelectedAgentIdentity(options);
const isStrictMethod = selectedMethod === "password"
|| selectedMethod === "key"
|| selectedMethod === "certificate";
return (!isStrictMethod || hasRestrictedSelectedAgent)
&& options?.useSshAgent !== false
&& Boolean(connectOpts?.agent);
}
function shouldPrepareSystemAgentForLogin(options) {
if (options?.authMethod === "password" || options?.authMethod === "certificate") return false;
if (options?.authMethod !== "key") return true;
return options?.useSshAgent === true
&& options?.identitiesOnly === true
&& hasSelectedAgentIdentity(options);
}
function resolveUnlockedEncryptedKeysForAuth(options, strictAgentSelection) {
const selectedMethod = options?.authMethod;
const hasStrictMethod = selectedMethod === "password"
|| selectedMethod === "key"
|| selectedMethod === "certificate";
return strictAgentSelection || hasStrictMethod ? [] : (options?._unlockedEncryptedKeys || []);
}
function shouldPromoteCachedAuthMethod(authMethod, cachedMethod) {
if (!cachedMethod) return false;
if (authMethod === "auto") {
return cachedMethod === "agent" || cachedMethod.startsWith("publickey");
}
if (authMethod === "password" || authMethod === "key" || authMethod === "certificate") {
return false;
}
return true;
}
async function applyAgentForwarding(
options,
connectOpts,
resolveForwardingAgentSocket,
) {
if (!options?.agentForwarding) return connectOpts;
const alreadyResolved = Object.prototype.hasOwnProperty.call(
options,
"_resolvedForwardingAgentSocket",
);
const forwardingAgent = alreadyResolved
? options._resolvedForwardingAgentSocket
: await resolveForwardingAgentSocket(options.identityAgent, options);
if (forwardingAgent) {
connectOpts.agent = forwardingAgent;
connectOpts.agentForward = true;
}
return connectOpts;
}
async function prepareAgentForwardingOptions(options, resolveForwardingAgentSocket) {
if (!options?.agentForwarding) return options;
if (Object.prototype.hasOwnProperty.call(options, "_resolvedForwardingAgentSocket")) {
return {
...options,
forwardingAgentSocket: options._resolvedForwardingAgentSocket || "",
};
}
const forwardingAgent = await resolveForwardingAgentSocket(options.identityAgent, options);
return {
...options,
_resolvedForwardingAgentSocket: forwardingAgent || null,
forwardingAgentSocket: forwardingAgent || "",
};
}
function createStartSessionApi(ctx) {
with (ctx) {
const listInteractiveShellPids = (conn) => listInteractiveShellPidsShared(conn, {
quoteShellArg,
});
const listInteractiveShellPidsResilient = async (conn, opts = {}) => {
const attempts = Math.max(1, Number(opts.attempts) || 1);
const backoffMs = Math.max(1, Number(opts.backoffMs) || 150);
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
let last = { available: false, pids: [] };
for (let attempt = 0; attempt < attempts; attempt += 1) {
const delayMs = attempt === 0
? initialDelayMs
: backoffMs * attempt;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
last = await listInteractiveShellPids(conn);
if (last.available || !last.rateLimited) return last;
}
return last;
};
const ensureConcurrentJoinShellIdentity = async (connRef, options) => {
if (!connRef || options.skipShellPidDiscovery) return true;
const current = [...sessions.entries()].filter(([, candidate]) => (
candidate?.connRef === connRef
&& candidate?.stream
));
if (current.length === 0) return false;
if (current.every(([, candidate]) => candidate.shellPid)) return true;
if (current.length !== 1) return false;
const [[sessionId, candidate]] = current;
const discovery = await listInteractiveShellPids(connRef.conn);
const live = sessions.get(sessionId);
if (
discovery.available
&& discovery.pids.length === 1
&& live === candidate
&& live.connRef === connRef
&& live.stream === candidate.stream
&& !live.shellPid
) {
live.shellPid = String(discovery.pids[0]);
}
return Boolean(
live === candidate
&& live.connRef === connRef
&& live.stream === candidate.stream
&& live.shellPid
);
};
const waitForNewInteractiveShellPid = async (conn, previousPids, opts = {}) => {
const previous = new Set(previousPids);
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
const backoffMs = Math.max(1, Number(opts.backoffMs) || 50);
if (initialDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const discovery = await listInteractiveShellPids(conn);
if (!discovery.available) {
// Bastion rate limits can reject the post-open discovery exec just
// after a retried shell open. Back off and try again instead of
// permanently leaving the copied session without a shellPid.
if (discovery.rateLimited && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
continue;
}
return null;
}
const newPids = discovery.pids.filter((pid) => !previous.has(pid));
if (newPids.length === 1) return newPids[0];
if (newPids.length > 1) return null;
if (attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
}
}
return null;
};
/**
* Wire up a freshly-opened shell channel (PTY stream) for a session:
* output buffering, ZMODEM handling, encoding, exit/close reporting and
* teardown. Shared by both the fresh-connection path and the connection
* reuse path (issue #1204) so duplicated tabs behave identically to the
* original tab once their channel is open.
*
* `isReused` only affects diagnostics/log labelling; lifecycle correctness
* is governed entirely by the reference-counted connection descriptor
* (sshConnectionPool.cjs). The stream "close"/transport handlers always
* release this session's hold via releaseConnectionRef, which tears the
* shared transport down only when the last channel is gone.
*/
function setupShellSession({
conn,
stream,
options,
sessionId,
event,
log,
detachX11Forwarding,
chainConnections,
isReused,
}) {
const session = {
conn,
stream,
// Only the owning (fresh) session is responsible for the jump-host
// chain; reused channels share it via the connRef descriptor and must
// not carry their own copy (otherwise closing a reused tab would end
// the chain out from under its siblings).
chainConnections: isReused ? [] : chainConnections,
webContentsId: event.sender.id,
// Store connection info for MCP host discovery
hostname: options.host || options.hostname || '',
username: options.username || '',
label: options.label || '',
// Host file-transfer preference for session-backed SFTP/SCP opens
// (Catty/MCP/clipboard paste call openSftpForSession without protocol).
sftpFileProtocol: options.sftpFileProtocol || options.fileProtocol || 'auto',
systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined,
lastIdlePrompt: '',
lastIdlePromptAt: 0,
_promptTrackTail: '',
// SSH server identification string (the `software` part of
// `SSH-2.0-<software>`). ssh2 captures this during the header
// exchange and stores it on the client as `_remoteVer` — it
// is available by the time 'ready' fires, so the renderer can
// use it to detect network-device vendors without running any
// additional exec channels. See domain/host.ts
// `detectVendorFromSshVersion`.
remoteSshVersion: (conn && typeof conn._remoteVer === 'string') ? conn._remoteVer : '',
// The actual SSH target this connection authenticated to. Used to make
// sure a "Copy Tab" reuse opens its channel on a connection going to the
// *same* host — a saved host edited after the source connected must not
// silently run commands on the old machine (issue #1204 review).
_reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(options, {
agentForwarding: options._actualAgentForwarding ?? options.agentForwarding,
})),
tcpLatencyDirect:
!Array.isArray(options.jumpHosts) || options.jumpHosts.length === 0
? !options.proxy
: false,
cols: options.cols || 80,
rows: options.rows || 24,
};
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
if (!claim.ok) {
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
try { stream?.close?.(); } catch { /* ignore */ }
try { if (!isReused) conn?.end?.(); } catch { /* ignore */ }
throw supersededError;
}
openTerminalOutputSession?.(sessionId, event.sender);
// Attach the shared connection descriptor to this session. The caller owns
// the reference *count*: the fresh-connection path calls createConnectionRef
// after this returns; the reuse path calls acquireConnectionRef *before*
// issuing the async shell request (so the connection can't be released out
// from under a pending channel open). We only record the descriptor here.
if (options._connRef) {
session.connRef = options._connRef;
}
// Start real-time session log stream if configured. The token is stored
// on the session so the connection-level error/timeout/close handlers can
// stop the stream when they clean up the owner session directly.
let logStreamToken = null;
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
logStreamToken = sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.hostLabel || options.hostname || '',
hostname: options.hostname || '',
directory: options.sessionLog.directory,
format: options.sessionLog.format || 'txt',
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
}
session._logStreamToken = logStreamToken;
// Coalesce shell output and deliver it to the renderer on the next
// event-loop turn (see ptyOutputBuffer) rather than on a fixed timer,
// so interactive echo isn't held back by the batch interval. A size
// cap still forces an immediate flush for bursts of output.
const {
bufferData,
flushPaced: flushBufferPaced,
takePendingEntry: takePendingBuffer,
discard: discardBuffer,
} = createPtyOutputBuffer((data, meta) => {
if (sessions.get(sessionId) !== session) return;
const contents = event.sender;
emitTerminalSessionData(contents, sessionId, data, {
session,
cols: session.cols,
rows: session.rows,
meta,
});
}, {
onPendingBytesChange: (bytes) => {
if (sessions.get(sessionId) === session) setBufferedOutputBytes(session, bytes);
},
shouldAcceptOutput: () => sessions.get(sessionId) === session && shouldAcceptSessionOutput(session),
});
session.flushPendingData = flushBufferPaced;
session.takePendingData = takePendingBuffer;
session.discardPendingData = discardBuffer;
const getCurrentSessionWebContents = () => {
const currentId = sessions.get(sessionId)?.webContentsId;
if (typeof currentId === "number") {
const current = electronModule?.webContents?.fromId?.(currentId);
if (current) return current;
}
return event.sender;
};
const getCurrentSessionWebContentsId = () =>
getCurrentSessionWebContents()?.id ?? event.sender.id;
const sshZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
if (sessions.get(sessionId) !== session) return;
const decoder = getSessionDecoder(sessionId, "stdout");
const decoded = decoder.write(buf);
const output = filterTerminalInterruptOutput(session, decoded);
if (!output.accepted) {
logTerminalInterruptDrainDropSample(session, {
sessionId,
stream: "stdout",
droppedBytes: output.droppedBytes,
reason: output.reason,
accepted: false,
});
return;
}
if (output.droppedBytes > 0) {
logTerminalInterruptDrainDropSample(session, {
sessionId,
stream: "stdout",
droppedBytes: output.droppedBytes,
reason: output.reason,
accepted: true,
});
}
if (!output.data) return;
const outputMeta = takePendingInterruptOutputMeta(session);
trackSessionIdlePrompt(session, output.data);
bufferData(output.data, outputMeta);
sessionLogStreamManager.appendData(sessionId, output.data);
},
writeToRemote(buf) {
try { return stream.write(buf); } catch { return true; /* ignore */ }
},
waitForTransportDrain(drainOpts = {}) {
// ssh2 buffers up to its 2 MiB channel window before write() returns
// false. Watch writableLength progress so healthy slow links can take
// longer than one timeout window while a fully stalled peer is bounded.
return waitForWritableDrain(stream, {
...drainOpts,
progressIntervalMs: 1000,
// ssh2 keeps writableLength unchanged until one queued write fully
// completes, but shrinks _chunk on each channel-window adjustment.
// Include both so partial frame delivery counts as progress.
getProgressValue: () => (
(Number(stream.writableLength) || 0) + (stream._chunk?.length || 0)
),
});
},
interruptRemote() {
try { stream.signal?.("INT"); } catch { /* ignore */ }
},
probeReceiveConflicts(names, { signal } = {}) {
return probeReceiveConflicts(sessions.get(sessionId), names, { signal });
},
removeRemoteFiles(paths, { signal } = {}) {
return removeRemoteFiles(sessions.get(sessionId), paths, { signal });
},
restoreRemoteModes(entries, { signal } = {}) {
return restoreRemoteModes(sessions.get(sessionId), entries, { signal });
},
requestOverwriteDecision(filename, { signal } = {}) {
return new Promise((resolve) => {
const requestId = randomUUID();
let settled = false;
const cleanup = () => {
clearTimeout(timer);
try { signal?.removeEventListener("abort", onAbort); } catch { /* ignore */ }
zmodemOverwritePending.delete(requestId);
};
const finish = (decision) => {
if (settled) return;
settled = true;
cleanup();
resolve(decision);
};
const onAbort = () => finish({ action: "cancel", applyToRest: false });
const timer = setTimeout(() => {
finish({ action: "skip", applyToRest: false });
}, 120000);
zmodemOverwritePending.set(requestId, (payload) => finish({
action: payload.action,
applyToRest: !!payload.applyToRest,
}));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
safeSend(getCurrentSessionWebContents(), "netcatty:zmodem:overwrite-request", {
sessionId, requestId, filename,
});
});
},
getWebContents() {
return getCurrentSessionWebContents();
},
selectUploadFiles: selectZmodemUploadFiles
? () => selectZmodemUploadFiles(getCurrentSessionWebContentsId(), sessionId)
: undefined,
selectDownloadDirectory: selectZmodemDownloadDirectory
? () => selectZmodemDownloadDirectory(getCurrentSessionWebContentsId(), sessionId)
: undefined,
label: "SSH",
});
session.zmodemSentry = sshZmodemSentry;
stream.on("data", (data) => {
if (sessions.get(sessionId) !== session) return;
if (!shouldProcessSessionOutput(session, sshZmodemSentry)) {
logTerminalOutputDropSample(session, {
sessionId,
stream: "stdout",
bytes: Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data)),
});
return;
}
if (session.blockUntargetedCwdProbe && session.pendingCwdRecoveryAfterUserCommand) {
session.pendingCwdRecoveryAfterUserCommand = false;
session.allowCwdRecovery = true;
}
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
// In normal mode, sentry's onData callback handles decoding and buffering.
sshZmodemSentry.consume(data);
});
stream.stderr?.on("data", (data) => {
if (sessions.get(sessionId) !== session) return;
if (!shouldProcessSessionOutput(session)) {
logTerminalOutputDropSample(session, {
sessionId,
stream: "stderr",
bytes: Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data)),
});
return;
}
// stderr is not used for ZMODEM — decode normally
const decoder = getSessionDecoder(sessionId, "stderr");
const decoded = decoder.write(data);
const output = filterTerminalInterruptOutput(session, decoded);
if (!output.accepted) {
logTerminalInterruptDrainDropSample(session, {
sessionId,
stream: "stderr",
droppedBytes: output.droppedBytes,
reason: output.reason,
accepted: false,
});
return;
}
if (output.droppedBytes > 0) {
logTerminalInterruptDrainDropSample(session, {
sessionId,
stream: "stderr",
droppedBytes: output.droppedBytes,
reason: output.reason,
accepted: true,
});
}
if (!output.data) return;
const outputMeta = takePendingInterruptOutputMeta(session);
bufferData(output.data, outputMeta);
sessionLogStreamManager.appendData(sessionId, output.data);
});
// Capture the real exit code from the remote process.
// "exit" fires when the remote shell/process exits normally;
// "close" fires whenever the channel closes (could be network drop).
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits.
let streamExitCode = 0;
let streamExited = false;
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : 0;
streamExited = typeof code === "number" && !signal;
});
let closeFinalized = false;
stream.on("close", () => {
log("shell stream closed", {
sessionId,
hostname: options.hostname,
streamExitCode,
streamExited,
reused: !!isReused,
transportError: sessions.get(sessionId)?._transportError,
});
const finalizeClose = () => {
if (closeFinalized) return;
closeFinalized = true;
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
if (detachX11Forwarding) {
detachX11Forwarding();
detachX11Forwarding = null;
}
// Only send exit if session hasn't already been cleaned up by
// conn.once("close") — which fires before stream.on("close")
// in ssh2 when the transport drops.
const liveSession = sessions.get(sessionId);
if (liveSession === session) {
const contents = event.sender;
const transportError = liveSession?._transportError;
if (liveSession?.closed) {
// Explicit close already notified every attached renderer.
} else if (transportError) {
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
exitCode: 1,
error: transportError,
reason: "error",
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
} else {
// A shell TMOUT auto-logout is a clean exit (numeric code, no
// signal) — identical to a user-typed `exit` by code/signal —
// so detect it via the banner the shell prints just before
// exiting and report it as a timeout. That keeps the tab open
// for reconnect instead of auto-closing it (#1062 / #977).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
exitCode: streamExitCode,
reason,
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
}
liveSession?.zmodemSentry?.cancel();
// Release this channel's hold on the shared connection. The transport
// (and any jump-host chain) is only ended once the last channel is
// gone, so closing a reused tab — or the original tab while a copy is
// still open — leaves the siblings connected.
releaseConnectionRef(liveSession);
closeTerminalOutputSession?.(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
}
};
flushBufferPaced(finalizeClose);
});
// Pre-seed encoding from host charset if it's a GB variant. Seed BOTH the
// output decoder (sessionEncodings) and the terminal input encoder
// (session.encoding, read by terminalBridge.writeToSession) so they agree
// from the first byte — otherwise a GB18030 host decoded output as GB18030
// while encoding keystrokes as UTF-8 until the user re-picked the encoding
// (issue #1216). The gate matches the renderer's two-value encoding state
// (Terminal.tsx) so behavior for other/arbitrary charsets is unchanged:
// the renderer pushes the effective encoding via setEncoding on attach,
// and that handler keeps both halves in sync.
const initialEncoding = normalizeTerminalEncoding(options.charset);
if (initialEncoding === "gb18030") {
sessionEncodings.set(sessionId, "gb18030");
session.encoding = "gb18030";
}
// Run startup command if specified. Encode it with the same charset the
// interactive input path uses (issue #1216) so a startup command with
// non-ASCII characters reaches a GB18030 host correctly too.
if (options.startupCommand) {
setTimeout(() => {
stream.write(encodeTerminalInput(`${options.startupCommand}\n`, session.encoding));
}, 300);
}
return session;
}
/**
* Open a new interactive shell channel on an already-authenticated SSH
* connection borrowed from `sourceSession`, instead of dialing a fresh
* connection. This is what makes "Copy Tab" skip a second MFA prompt
* (issue #1204): the SSH transport and its authentication are reused; only
* a new session channel is requested.
*
* Resolves with `{ sessionId }` on success. Throws on failure so the caller
* can fall back to a normal fresh connection.
*/
async function openReusedShellSerialized(
event,
options,
sourceSession,
sessionId,
log,
connRef,
refHolder,
reuseOpts = {},
) {
const cols = options.cols || 80;
const rows = options.rows || 24;
const sender = event.sender;
const conn = sourceSession.conn;
// Bastions (jumpHosts or direct targets) rate-limit rapid session channel
// opens ("channelOpen too offen"). Opening a discovery exec *before* the
// shell burns that budget and Copy Tab falls back to a fresh login
// (issue #2704). Open the shell first; discover shellPid afterwards.
const configuredBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
const discoveryBackoffMs = Number.isFinite(configuredBackoffMs) && configuredBackoffMs > 0
? configuredBackoffMs
: 150;
const shellPidsBeforeOpen = [...sessions.values()]
.filter((candidate) => candidate?.connRef === connRef && candidate.shellPid)
.map((candidate) => String(candidate.shellPid));
log("reusing existing connection for new shell channel", {
sessionId,
sourceSessionId: options.sourceSessionId,
hostname: options.hostname,
});
const sendProgress = (status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", {
sessionId, hop: 1, total: 1, label: options.hostname, status, error,
});
}
};
sendProgress('shell');
const shellOptions = {
env: {
COLORTERM: "truecolor",
...(options.env || {}),
},
};
// Pin the shared connection *before* issuing the async shell request.
// Otherwise, if the source tab is closed while conn.shell() is pending,
// releaseConnectionRef could drop the count to zero and end the connection
// out from under the channel we're opening. We hold a temporary session
// object as the ref holder, then hand the ref over to the real session
// once the channel opens. On any failure we release this hold so the count
// is restored.
return new Promise((resolve, reject) => {
let settled = false;
let onConnError = null;
let abortOpenedStream = null;
const retryAbortController = new AbortController();
const pendingBootSignal = options._passphraseSignal || null;
const abortRetryFromPendingBoot = () => {
retryAbortController.abort(
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
);
abortOpenedStream?.(
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
);
};
const cleanupConnectionGuard = () => {
if (onConnError) conn.removeListener("error", onConnError);
};
const cleanupPendingBootGuard = () => {
abortOpenedStream = null;
pendingBootSignal?.removeEventListener?.("abort", abortRetryFromPendingBoot);
};
const cleanupReuseGuards = () => {
cleanupConnectionGuard();
cleanupPendingBootGuard();
};
if (pendingBootSignal?.aborted) {
abortRetryFromPendingBoot();
} else {
pendingBootSignal?.addEventListener?.("abort", abortRetryFromPendingBoot, { once: true });
}
const failReuse = (err) => {
if (settled) return;
settled = true;
cleanupReuseGuards();
retryAbortController.abort(err);
// Release the hold we took up-front so the source's reference count is
// not leaked when we fall back to a fresh connection.
releaseConnectionRef(refHolder);
reject(err);
};
// If the borrowed connection dies before the channel opens, surface it
// as a normal failure so the caller's catch can fall back to a fresh
// connection. Removed once the channel opens so we don't leave a stray
// listener on the shared connection (the owner's own error handler stays
// responsible thereafter).
onConnError = (connErr) => {
failReuse(connErr);
};
conn.once("error", onConnError);
if (
connRef.allowShellReuse === false
|| Number(connRef.pendingAbandonedShellOpens) > 0
) {
conn.removeListener("error", onConnError);
failReuse(new Error("Transport is no longer reusable for shells"));
return;
}
try {
const rateLimitBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
openBoundedSshShellCallback(
conn,
{
term: "xterm-256color",
cols,
rows,
},
shellOptions,
(err, stream) => {
cleanupConnectionGuard();
if (settled) {
// Connection already failed; close any channel that still opened
// and drop the hold (failReuse already released, so guard with the
// settled check above means we only get here post-failure).
if (stream) { try { stream.close(); } catch { /* ignore */ } }
return;
}
if (err) {
log("reused shell open failed", { sessionId, hostname: options.hostname, error: err.message });
sendProgress('error', `Failed to open shell: ${err.message}`);
failReuse(err);
return;
}
if (connRef.allowShellReuse === false) {
if (stream) { try { stream.close(); } catch { /* ignore */ } }
failReuse(new Error("Transport is no longer reusable for shells"));
return;
}
sendProgress('connected');
abortOpenedStream = (abortError) => {
try { stream.close(); } catch { /* ignore */ }
failReuse(abortError);
};
if (pendingBootSignal?.aborted) {
abortOpenedStream(
pendingBootSignal.reason || new Error("SSH session start was cancelled"),
);
return;
}
const finishReusedShellOpen = (prefetchedChunks = []) => {
cleanupPendingBootGuard();
if (settled) {
if (stream) { try { stream.close(); } catch { /* ignore */ } }
return;
}
// Hand the up-front lease over to the real session without changing
// the lease count (transferConnectionRef). setupShellSession still
// records connRef; transfer rebinds _sshTransportLeaseId so a later
// releaseConnectionRef(session) returns the right lease.
try {
setupShellSession({
conn,
stream,
options: { ...options, _connRef: connRef },
sessionId,
event,
log,
detachX11Forwarding: null,
chainConnections: [],
isReused: true,
});
} catch (setupErr) {
// openBoundedSshShellCallback delivers this from a Promise .then
// without catching callback throws — reject via failReuse.
failReuse(setupErr);
return;
}
if (prefetchedChunks.length > 0) {
for (const chunk of prefetchedChunks) {
stream.emit("data", chunk);
}
}
const reconnectAfterLastShellClose =
consumePendingShellReconnectRisk(connRef);
const copiedSession = sessions.get(sessionId);
if (copiedSession && reconnectAfterLastShellClose) {
copiedSession.blockUntargetedCwdProbe = true;
copiedSession.parkedReconnectRisk = reconnectAfterLastShellClose;
}
if (copiedSession) {
if (typeof transferConnectionRef === "function") {
transferConnectionRef(refHolder, copiedSession);
} else {
// Legacy count model: detach holder without decrement.
refHolder.connRef = null;
}
} else {
refHolder.connRef = null;
}
void discoverCopiedShellPid(copiedSession).then((newShellPid) => {
// Bind PID only to the session this reuse opened. A higher
// bootEpoch reconnect may already own sessionId in the map.
const liveSession = sessions.get(sessionId);
if (liveSession && liveSession === copiedSession && newShellPid) {
liveSession.shellPid = newShellPid;
}
settled = true;
resolve({ sessionId });
});
};
const discoverCopiedShellPid = async (copiedSession) => {
if (options.skipShellPidDiscovery) return null;
const liveBaseline = () => [...sessions.values()]
.filter((candidate) => (
candidate?.connRef === connRef
&& candidate !== copiedSession
&& candidate.shellPid
))
.map((candidate) => String(candidate.shellPid));
const listUnassignedSiblings = () => [...sessions.values()].filter(
(candidate) => (
candidate?.connRef === connRef
&& candidate !== copiedSession
&& !candidate.shellPid
),
);
// Prefer PIDs already recorded on sibling tabs of this shared
// transport. Fall back to the pre-shell snapshot when the source
// closed before discovery runs but had a known shellPid.
let baseline = liveBaseline();
if (baseline.length === 0) {
baseline = shellPidsBeforeOpen;
}
// Also reconcile when some siblings are already tracked but the
// copy source (or another tab) still lacks shellPid — otherwise
// waitForNew sees multiple "new" PIDs and returns null.
const needsUntrackedReconcile = listUnassignedSiblings().length > 0;
const blockedEndpointSibling = !options.sourceSessionId
&& listUnassignedSiblings().some(
(candidate) => candidate.blockUntargetedCwdProbe === true,
);
// Idle-park reconnect after the last interactive shell closed:
// no sibling tabs share this transport, so post-open discovery
// cannot disambiguate anything. Skip the exec — bastions often
// tear down the interactive session when a second channel opens,
// racing start completion as
// "Terminal session closed before its output route opened" (#2923).
// Copy Tab (sourceSessionId) still needs discovery even when the
// source closes mid-open and leaves an empty baseline.
if (
baseline.length === 0
&& !options.sourceSessionId
&& (!needsUntrackedReconcile || blockedEndpointSibling)
) {
if (copiedSession && blockedEndpointSibling) {
copiedSession.blockUntargetedCwdProbe = true;
copiedSession.parkedReconnectRisk = {
oldShellPids: [],
hasUnknownOldShell: true,
};
}
return null;
}
if (baseline.length === 0 || needsUntrackedReconcile) {
const discovery = await listInteractiveShellPidsResilient(conn, {
initialDelayMs: discoveryBackoffMs,
attempts: 4,
backoffMs: discoveryBackoffMs,
});
if (!discovery.available && !discovery.rateLimited) {
// Discovery is permanently unavailable (not rate-limited).
return null;
}
if (discovery.available && discovery.pids.length > 0) {
const assignedPids = new Set(liveBaseline());
for (const pid of baseline) assignedPids.add(String(pid));
const unclaimed = discovery.pids.filter((pid) => !assignedPids.has(pid));
const unassignedSiblings = listUnassignedSiblings();
if (unclaimed.length === 1 && unassignedSiblings.length === 1) {
unassignedSiblings[0].shellPid = unclaimed[0];
baseline = liveBaseline();
} else if (unassignedSiblings.length === 0 && unclaimed.length === 1) {
// Sole unclaimed PID is ambiguous once the source tab is
// gone: the closing source process may still be listed
// while the copied shell has not appeared yet. Wait for a
// PID beyond that candidate; if none appears and the
// candidate remains the only unclaimed shell, it is the
// copy (source process already exited).
const candidatePid = String(unclaimed[0]);
const waited = await waitForNewInteractiveShellPid(conn, [candidatePid], {
initialDelayMs: discoveryBackoffMs,
backoffMs: discoveryBackoffMs,
});
if (waited) return waited;
const recheck = await listInteractiveShellPids(conn);
if (!recheck.available) return null;
const assigned = new Set(liveBaseline());
const remaining = recheck.pids
.map(String)
.filter((pid) => !assigned.has(pid));
if (remaining.length === 1) return remaining[0];
return null;
} else if (unassignedSiblings.length === 1 && unclaimed.length === 2) {
// Source never recorded shellPid (e.g. OSC 7 cwd skipped
// the probe), so the first post-open scan already lists
// both shared shells. Reintroducing a pre-open exec would
// burn bastion channel budget (#2704). Disambiguate by
// process age (etimes): login shells on one transport are
// created sequentially, so the copied shell is younger.
// Numeric PID order is not a timestamp and fails when the
// PID allocator wraps between source and copy.
const ages = discovery.ages || {};
const left = String(unclaimed[0]);
const right = String(unclaimed[1]);
const leftAge = ages[left];
const rightAge = ages[right];
if (
Number.isFinite(leftAge)
&& Number.isFinite(rightAge)
&& leftAge !== rightAge
) {
const older = leftAge > rightAge ? left : right;
const newer = older === left ? right : left;
unassignedSiblings[0].shellPid = older;
return newer;
}
// Ages tied (same etimes second) or unavailable: refuse
// numeric PID order — after wrap the lower PID can be the
// copy. Leave the pair unassigned and keep only already-
// tracked PIDs in the baseline so waitForNew can still
// claim the copy if one of the two later disappears.
baseline = [...assignedPids];
} else if (assignedPids.size > 0) {
baseline = [...assignedPids];
}
}
// Empty successful scans (shell not visible yet) must not
// abort — waitForNew retries until the new shell appears.
}
return waitForNewInteractiveShellPid(conn, baseline, {
// Brief pause after the shell channel so bastion rate limits
// have a chance to clear before the discovery exec.
initialDelayMs: discoveryBackoffMs,
backoffMs: discoveryBackoffMs,
});
};
// Decide at channel-open time, not when start() was queued.
// Copy Tab can lose its source shell while this open is still
// pinned; pendingShellReconnectRisk is recorded then (#2923).
const confirmReusedShellLiveness = reuseOpts.confirmReusedShellLiveness === true
|| shouldConfirmReusedShellLiveness({
state: connRef?.state,
pendingShellReconnectRisk: connRef?.pendingShellReconnectRisk,
remoteSshVersion: conn?._remoteVer,
});
if (!confirmReusedShellLiveness) {
finishReusedShellOpen();
return;
}
// Idle-park reconnect on an unknown / non-multiplex banner: the
// channel can open and then immediately exit 0 (齐治 TERM-SSHD,
// issue #2923). Fail reuse before setupShellSession so start()
// can discard the parked transport and dial fresh.
void waitForReusedShellLiveness(stream, {
settleMs: resolveReusedShellLivenessMs(options.sshReusedShellLivenessMs),
setTimeout,
clearTimeout,
}).then((liveness) => {
if (settled) {
if (stream) { try { stream.close(); } catch { /* ignore */ } }
return;
}
if (!liveness.alive) {
log("reused parked shell closed immediately, discarding transport", {
sessionId,
hostname: options.hostname,
reason: liveness.reason,
code: liveness.code,
transportId: connRef?.id,
});
if (connRef) {
connRef.allowIdlePark = false;
connRef.allowShellReuse = false;
if (typeof markEndpointNoIdlePark === "function") {
markEndpointNoIdlePark(connRef.endpoint || connRef.endpointKey);
}
}
try { stream.close(); } catch { /* ignore */ }
failReuse(new Error("Reused parked shell closed immediately"));
return;
}
finishReusedShellOpen(liveness.buffered);
}).catch((livenessErr) => {
failReuse(livenessErr);
});
},
{
...(Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
? { rateLimitBackoffMs }
: {}),
...(options.sourceSessionId
? { rateLimitRetryTimeoutMs: COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS }
: {}),
signal: retryAbortController.signal,
// Cancelling one pending Copy Tab must not destroy the source
// tab's shared authenticated transport.
invalidateOnAbort: false,
onAbandonedOpen: () => {
connRef.pendingAbandonedShellOpens =
(Number(connRef.pendingAbandonedShellOpens) || 0) + 1;
},
onAbandonedOpenSettled: () => {
const pending = Math.max(
0,
(Number(connRef.pendingAbandonedShellOpens) || 0) - 1,
);
if (pending === 0) delete connRef.pendingAbandonedShellOpens;
else connRef.pendingAbandonedShellOpens = pending;
},
},
);
} catch (syncErr) {
// ssh2 can throw synchronously (e.g. "Not connected") if the borrowed
// transport dropped between findReusableSession and conn.shell(). Make
// sure we drop the listener and release the up-front ref so the count
// isn't leaked, then fall back to a fresh connection.
log("reused shell threw synchronously", { sessionId, hostname: options.hostname, error: syncErr?.message });
failReuse(syncErr);
}
});
}
function reuseShellSession(event, options, sourceSession, sessionId, log, reuseOpts = {}) {
const connRef = sourceSession.connRef;
const {
ensureConcurrentJoinIdentity = false,
...openReuseOpts
} = reuseOpts;
const refHolder = {};
// Pin while queued as well as while opening: the source tab may close
// before this copy reaches the front of the per-connection queue.
acquireConnectionRef(refHolder, connRef);
const previous = connRef.shellOpenQueue || Promise.resolve();
const operation = previous
.catch(() => {})
.then(async () => {
try {
if (
ensureConcurrentJoinIdentity
&& !await ensureConcurrentJoinShellIdentity(connRef, options)
) {
const error = new Error("Concurrent terminal shell identity is unavailable");
error.code = "SSH_CONCURRENT_SHELL_IDENTITY_UNSAFE";
throw error;
}
} catch (error) {
releaseConnectionRef(refHolder);
throw error;
}
return openReusedShellSerialized(
event,
options,
sourceSession,
sessionId,
log,
connRef,
refHolder,
openReuseOpts,
);
});
const tail = operation.then(() => undefined, () => undefined);
connRef.shellOpenQueue = tail;
return operation.finally(() => {
if (connRef.shellOpenQueue === tail) {
delete connRef.shellOpenQueue;
}
});
}
async function startSSHSession(event, options) {
const sessionId = options.sessionId || randomUUID();
const sender = event.sender;
const log = createSshDiagnosticLogger(
!!options.sshDebugLogEnabled || process.env.NETCATTY_SSH_DEBUG === "1",
);
const sendConnectionReuseFallback = () => {
if (!sender.isDestroyed()) {
sender.send("netcatty:connection-reuse:fallback", {
sessionId,
sourceSessionId: options.sourceSessionId,
});
}
};
// Resolve forwarding before connection reuse so an agent-provider change
// (for example Bitwarden becoming available after unlock) cannot attach a
// new shell to a transport that still forwards the previous socket.
options = await prepareAgentForwardingOptions(options, getAvailableForwardingAgentSocket);
// Connection reuse (issue #1204): when a tab is duplicated we try to open
// a new shell channel on the source tab's already-authenticated
// connection. This skips key exchange + authentication entirely, so an
// MFA-protected host does not prompt for a second factor. Only applies to
// a live, interactive SSH shell source; anything else falls through to a
// normal fresh connection below.
//
// X11 forwarding is negotiated per shell channel using a fresh fake
// cookie wired up at connection time, so a reused channel would not carry
// 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 (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);
} catch (reuseErr) {
if (options._passphraseSignal?.aborted) throw reuseErr;
log("connection reuse failed, falling back to fresh connection", {
sessionId,
sourceSessionId: options.sourceSessionId,
error: reuseErr?.message,
});
sendConnectionReuseFallback();
// Fall through to establish a fresh connection.
}
} else {
log("connection reuse requested but source not reusable, connecting fresh", {
sessionId,
sourceSessionId: options.sourceSessionId,
});
sendConnectionReuseFallback();
}
}
// 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 (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" });
if (parked?.conn && (parked.state === "live" || parked.state === "idle")) {
try {
log("reusing parked or shared transport for new shell channel", {
sessionId,
hostname: options.hostname,
transportId: parked.id,
transportState: parked.state,
});
const confirmReusedShellLiveness = shouldConfirmReusedShellLiveness({
state: parked.state,
pendingShellReconnectRisk: parked.pendingShellReconnectRisk,
remoteSshVersion: parked.conn?._remoteVer,
});
return await reuseShellSession(
event,
options,
{
conn: parked.conn,
connRef: parked,
// openReusedShellSerialized only needs conn + connRef; stream is
// required by findReusableSession but we bypass that path here.
stream: {},
},
sessionId,
log,
{ confirmReusedShellLiveness },
);
} catch (parkErr) {
if (options._passphraseSignal?.aborted) {
throw options._passphraseSignal.reason instanceof Error
? options._passphraseSignal.reason
: parkErr;
}
log("parked transport reuse failed, falling back to fresh connection", {
sessionId,
hostname: options.hostname,
error: parkErr?.message,
});
// Fall through to a normal dial.
}
}
}
// Atomically reserve the physical dial before any asynchronous key,
// proxy, or jump-host preparation. A second terminal/SFTP/forward open
// 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 (
allowGeneralTransportReuse
&& !pendingDialCoordination
&& !options.x11Forwarding
&& typeof beginTransportDial === "function"
) {
const coordination = beginTransportDial(reuseEndpoint, { kind: "shell" });
if (coordination.role === "reuse" || coordination.role === "join") {
try {
const transport = coordination.role === "reuse"
? coordination.transport
: await waitForTransportDial(coordination);
return await reuseShellSession(
event,
options,
{ conn: transport.conn, connRef: transport, stream: {} },
sessionId,
log,
{
ensureConcurrentJoinIdentity: coordination.role === "join"
&& coordination._record?.kind === "shell",
},
);
} catch (coordinationErr) {
if (coordinationErr?.code === "SSH_CONCURRENT_SHELL_IDENTITY_UNSAFE") {
log("concurrent transport shell identity unavailable; connecting fresh", {
sessionId,
hostname: options.hostname,
});
} else {
if (options._passphraseSignal?.aborted) {
throw options._passphraseSignal.reason instanceof Error
? options._passphraseSignal.reason
: coordinationErr;
}
// A waiter must observe the leader's real failure instead of
// immediately starting a second authentication prompt. Existing
// transport reuse keeps its historical fresh-dial fallback.
if (coordination.role === "join") throw coordinationErr;
log("coordinated transport reuse failed, connecting fresh", {
sessionId,
hostname: options.hostname,
error: coordinationErr?.message,
});
}
}
} else {
pendingDialCoordination = coordination;
if (options._pendingDialState) {
options._pendingDialState.coordination = coordination;
}
}
}
if (options._passphraseSignal?.aborted) {
throw options._passphraseSignal.reason instanceof Error
? options._passphraseSignal.reason
: new Error("SSH session start was cancelled");
}
const cols = options.cols || 80;
const rows = options.rows || 24;
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
try {
const { tcpConnectTimeoutMs, authReadyTimeoutMs } = resolveSshConnectionTimeouts(options);
log("session starting", {
sessionId,
hostname: options.hostname,
port: options.port || 22,
username: options.username || "root",
hostLabel: options.hostLabel || options.label,
hasJumpHosts: (options.jumpHosts || []).length > 0,
hasProxy: !!options.proxy,
tcpConnectTimeoutMs,
authReadyTimeoutMs,
});
const conn = new SSHClient();
let chainConnections = [];
let connectionSocket = null;
// Determine if we have jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
const totalHops = jumpHosts.length + 1; // +1 for final target
// Build base connection options for final target
const keepalivePolicy = resolveConnectionKeepalivePolicy(options);
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
// `timeout` covers TCP dial silence; `readyTimeout` covers the full
// SSH handshake/auth flow so MFA still has enough time.
timeout: tcpConnectTimeoutMs,
// ssh2 starts readyTimeout before TCP connects. The auth-ready timer
// below starts explicitly from the connection event instead.
readyTimeout: 0,
// Resolved keepalive (caller decides whether host override or global
// applies). interval is in seconds; 0 means truly disabled, so
// countMax also goes to 0 to skip ssh2's dead-connection check.
keepaliveInterval: keepalivePolicy.keepaliveIntervalMs,
keepaliveCountMax: keepalivePolicy.keepaliveCountMax,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(options.legacyAlgorithms, {
skipEcdsaHostKey: options.skipEcdsaHostKey,
algorithmOverrides: options.algorithmOverrides,
}),
};
attachSshDebugLogger(connectOpts, log);
logSshAlgorithms("Target host", connectOpts.algorithms, {
hostname: options.hostname,
port: options.port || 22,
legacyAlgorithms: !!options.legacyAlgorithms,
skipEcdsaHostKey: !!options.skipEcdsaHostKey,
hasAlgorithmOverrides: !!options.algorithmOverrides,
}, log);
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId,
hostname: options.hostname,
port: options.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: options.verifyHostKeys,
bootEpoch: options.bootEpoch,
});
// Authentication for final target
const hasCertificate = options.authMethod !== "password"
&& typeof options.certificate === "string"
&& options.certificate.trim().length > 0;
const isAutomaticAuth = options.authMethod === "auto";
const isSelectedKeyAuth = options.authMethod === "key" || options.authMethod === "certificate";
const effectivePassphrase = options.passphrase;
console.log("[SSH] Auth configuration:", {
hasCertificate,
keySource: options.keySource,
hasPublicKey: !!options.publicKey,
hasPrivateKey: !!options.privateKey,
hasPassword: !!options.password,
hasEffectivePassphrase: !!effectivePassphrase,
});
log("Auth configuration", {
hasCertificate,
keySource: options.keySource,
hasPublicKey: !!options.publicKey,
hasPrivateKey: !!options.privateKey,
});
let authAgent = null;
const systemAuthAgent = shouldPrepareSystemAgentForLogin(options)
? await prepareSystemSshAgentForAuth(options, "[SSH]")
: null;
// Kick off the default-key scan now so it overlaps the identity-file /
// inline-key preparation below instead of running serially after it.
// findAllDefaultPrivateKeys swallows its own fs errors and never rejects,
// so leaving this promise briefly unawaited cannot surface an unhandled
// rejection even if the key prep throws first.
const defaultKeysPromise = findAllDefaultPrivateKeys();
const identityFile = options.authMethod !== "password" && !options.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender,
identityFilePaths: options.identityFilePaths,
hostname: options.hostname,
initialPassphrase: options.passphrase,
passphraseSignal: options._passphraseSignal,
sessionId,
bootEpoch: options.bootEpoch,
logPrefix: "[SSH]",
onPassphrasePromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, "auth-attempt", "waiting for user input...",
),
onPassphrasePromptResolved: () => sendProgress(
totalHops, totalHops, options.hostname, "auth-attempt", "user responded",
),
onLoaded: (loaded) => {
log("Loaded identity file", { keyPath: loaded.keyPath, encrypted: !!loaded.passphrase });
},
onError: (err, keyPath) => {
log("Failed to read identity file", { keyPath, error: err.message });
},
})
: null;
const inlineKey = options.authMethod !== "password" && options.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey: options.privateKey,
keyId: options.keyId,
keyName: options.keyId || options.username,
hostname: options.hostname,
initialPassphrase: effectivePassphrase,
passphraseSignal: options._passphraseSignal,
sessionId,
bootEpoch: options.bootEpoch,
logPrefix: "[SSH]",
onPassphrasePromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, "auth-attempt", "waiting for user input...",
),
onPassphrasePromptResolved: () => sendProgress(
totalHops, totalHops, options.hostname, "auth-attempt", "user responded",
),
})
: null;
const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey;
const effectiveIdentityPassphrase = inlineKey?.passphrase || identityFile?.passphrase;
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
}
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: options.keyId || options.username || "",
certificate: options.certificate,
privateKey: effectivePrivateKey,
passphrase: effectiveIdentityPassphrase,
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey) {
connectOpts.privateKey = effectivePrivateKey;
if (effectiveIdentityPassphrase) {
connectOpts.passphrase = effectiveIdentityPassphrase;
}
}
// Whitespace-only passwords are valid SSH secrets (issue #2036).
if (isPasswordProvided(options.password)) {
connectOpts.password = options.password;
}
// Default ~/.ssh keys are a fallback for hosts that have no explicit
// credentials. Password-only hosts must NOT silently fall back to local
// keys: jump-host / SFTP paths already honor that rule via
// buildAuthHandler (issue #266), and falling back only on the direct
// terminal path makes a wrong saved password look fine until ProxyJump
// or SFTP fails (issue #2079).
//
// The full list is scanned exactly once (kicked off above); its first
// entry is the preferred default key — identical to what a separate
// findDefaultPrivateKey() scan would return — so derive it here instead
// of walking ~/.ssh a second time. (Pinned by
// sshBridge.defaultKeyEquivalence.test.cjs.)
let usedDefaultKeyAsPrimary = false;
const discoveredDefaultKeys = await defaultKeysPromise;
const allDefaultKeys = isSelectedKeyAuth || options.authMethod === "password" || (systemAuthAgent && options.identitiesOnly)
? []
: discoveredDefaultKeys;
const defaultKeyInfo = allDefaultKeys[0] ?? null;
// Explicit password without a user-configured key/certificate/agent is
// password-only — same predicate buildAuthHandler uses for isPasswordOnly.
const isPasswordOnlyAuth =
options.authMethod === "password" || (
!isAutomaticAuth &&
isPasswordProvided(connectOpts.password) &&
!connectOpts.privateKey &&
!hasCertificate &&
!systemAuthAgent &&
!hasUserConfiguredKey(options)
);
if (defaultKeyInfo && !isPasswordOnlyAuth) {
log("Found default SSH key for fallback", { keyPath: defaultKeyInfo.keyPath, keyName: defaultKeyInfo.keyName });
} else if (defaultKeyInfo && isPasswordOnlyAuth) {
log("Skipping default SSH key fallback for password-only auth", {
keyPath: defaultKeyInfo.keyPath,
keyName: defaultKeyInfo.keyName,
});
}
// Use unlocked encrypted keys if provided (from retry after auth failure)
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
const unlockedEncryptedKeys = resolveUnlockedEncryptedKeysForAuth(
options,
Boolean(isSelectedKeyAuth || (systemAuthAgent && options.identitiesOnly)),
);
if (unlockedEncryptedKeys.length > 0) {
log("Using unlocked encrypted keys from retry", {
count: unlockedEncryptedKeys.length,
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
});
}
// Automatic mode intentionally mirrors OpenSSH-style discovery even
// when a saved password exists: agent and default keys are tried first,
// then the password. Password-only mode never enters this path.
if (isAutomaticAuth && !connectOpts.agent && options.useSshAgent !== false) {
const automaticAgentSocket = await getAvailableAgentSocket();
if (automaticAgentSocket) {
connectOpts.agent = automaticAgentSocket;
log("Automatic auth found SSH agent", { agentSocket: automaticAgentSocket });
}
}
// If no primary auth method configured, try ssh-agent first, then ALL default keys.
// Skip default-key primaries when the user explicitly chose a key (inline or
// identityFilePaths) even if loading that key failed (issue #1614).
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = options.useSshAgent !== false
? await getAvailableAgentSocket()
: null;
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
connectOpts.agent = sshAgentSocket;
}
// Mark that we need to try all default keys (handled in authMethods below)
if (!hasUserConfiguredKey(options) && allDefaultKeys.length > 0) {
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
connectOpts.privateKey = allDefaultKeys[0].privateKey;
usedDefaultKeyAsPrimary = true;
} else if (allDefaultKeys.length === 0) {
log("No default SSH key found in ~/.ssh directory");
}
}
log("Final auth configuration", {
hasPrivateKey: !!connectOpts.privateKey,
hasPassword: !!connectOpts.password,
hasAgent: !!connectOpts.agent,
hasDefaultKeyFallback: !!defaultKeyInfo && !isPasswordOnlyAuth,
isPasswordOnlyAuth,
});
// ssh2 uses connectOpts.agent for forwarding, but authHandler agent
// objects may select a different agent for login.
const loginAgent = connectOpts.agent;
// Agent forwarding
if (options.agentForwarding) {
await applyAgentForwarding(options, connectOpts, getAvailableForwardingAgentSocket);
if (!connectOpts.agentForward) {
log("Agent forwarding requested but no agent available, skipping");
}
}
// Build authentication handler with fallback support
// ssh2 authHandler can be a function that returns the next auth method to try
// Check if we have a cached successful auth method for this host
const cachedMethod = getCachedAuthMethod(connectOpts.username, options.hostname, options.port);
// Track which method succeeded for caching
let lastTriedMethod = null;
// Shared with keyboard-interactive auto-fill. A completed password or
// keyboard-interactive factor suppresses reuse of the saved host
// password for a later KI factor (EDR step-up). publickey partialSuccess
// still allows Password: auto-fill.
const authPhase = createAuthPhase();
if (authAgent) {
const order = ["none", "agent"];
if (options.requiresMfa && !options._skipPasswordMethod) {
order.push("keyboard-interactive");
}
if (connectOpts.password && !options._skipPasswordMethod) {
order.push("password");
}
// Default key fallback only when this is not password-only (issue #266 / #2079).
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth.
if (defaultKeyInfo && !hasUserConfiguredKey(options) && !isPasswordOnlyAuth) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
order.push("publickey");
}
if (!order.includes("keyboard-interactive")) order.push("keyboard-interactive");
// Function form so authPhase.hadPartialSuccess updates for cert/agent
// first-factor + keyboard-interactive second-factor (#2150).
connectOpts.authHandler = createOrderedStringAuthHandler(
order,
authPhase,
undefined,
authAgent !== connectOpts.agent
? { username: connectOpts.username, agent: authAgent }
: undefined,
);
connectOpts._shouldRetryKeyboardInteractiveFirst = () => Boolean(authPhase.retryKeyboardInteractiveFirst);
log("Auth order (agent mode)", { order, skipPasswordMethod: !!options._skipPasswordMethod });
} else {
// Build dynamic auth handler for fallback support
const authMethods = [];
if (isAutomaticAuth) {
if (options.requiresMfa && !connectOpts.password && !options._skipPasswordMethod) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}
if (shouldOfferAgentForLogin(options, { agent: loginAgent })) {
authMethods.push({ type: "agent", agent: loginAgent, id: "agent" });
}
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
if (options.requiresMfa && connectOpts.password && !options._skipPasswordMethod) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}
if (connectOpts.password && !options._skipPasswordMethod) {
authMethods.push({ type: "password", id: "password" });
}
} else {
// First try user-configured key if available (explicit user choice)
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
// Then try agent if configured (try agent before password since it's usually faster)
if (shouldOfferAgentForLogin(options, { agent: loginAgent })) {
authMethods.push({ type: "agent", agent: loginAgent, id: "agent" });
}
// MFA/PAM hosts can reject the SSH "password" method while accepting
// the login password through keyboard-interactive.
if (options.requiresMfa && !options._skipPasswordMethod) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}
// Then try password if available (explicit user choice).
if (connectOpts.password && !options._skipPasswordMethod) {
authMethods.push({ type: "password", id: "password" });
}
// Then try ALL default SSH keys as fallback (not just the first one!)
// Password-only hosts skip automatic default-key probing so terminal/SFTP/jump agree.
if (!isPasswordOnlyAuth && usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (!isSelectedKeyAuth && defaultKeyInfo && !hasUserConfiguredKey(options) && !usedDefaultKeyAsPrimary) {
// Single default key fallback (when user has configured other non-password auth)
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
}
}
// Unlocked default keys remain eligible only for automatic and
// legacy fallback modes. Explicit modes must not probe unrelated keys.
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
isDefault: true,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Keyboard-interactive as last resort, or already placed before password
// as last-resort fallback for multi-factor / EDR.
if (!authMethods.some((method) => method.type === "keyboard-interactive")) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}
log("Auth methods configured", {
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
cachedMethod,
usedDefaultKeyAsPrimary
});
// Reorder methods based on cached successful method
if (shouldPromoteCachedAuthMethod(options.authMethod, cachedMethod)) {
const cachedIndex = authMethods.findIndex(m => m.id === cachedMethod);
if (cachedIndex > 0) {
const [cachedAuthMethod] = authMethods.splice(cachedIndex, 1);
authMethods.unshift(cachedAuthMethod);
log("Reordered auth methods based on cache", {
methods: authMethods.map(m => m.id)
});
}
}
// Always use dynamic authHandler to ensure consistent "none" probing
// and auth method logging regardless of how many methods are configured
if (authMethods.length >= 1) {
// Track methods that have been attempted (to avoid re-trying on failure)
// This prevents reusing the same key when server requires multiple publickey auth steps
// and also prevents re-attempting failed methods
let attemptedMethodIds = new Set();
// Methods that contributed a successful factor; never retried.
const succeededMethodIds = new Set();
// Methods actually rejected by the server. Keep these blocked when
// a later factor makes previously unavailable methods eligible.
const failedMethodIds = new Set();
// Track the first successful method for caching (not the last one in multi-step flows)
let firstSuccessfulMethod = null;
// Track if we've gone through a partialSuccess flow (multi-step auth)
let hadPartialSuccess = false;
// Some EDR servers advertise keyboard-interactive next to password,
// then remove it after a rejected password request. The wrapper can
// recover by retrying once with the password method omitted so the
// login password flows through keyboard-interactive.
let passwordAttemptSawKeyboardInteractive = false;
let shouldRetryKeyboardInteractiveFirst = false;
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
log("authHandler called", { methodsLeft, partialSuccess, attemptedMethodIds: Array.from(attemptedMethodIds) });
// Log rejection of previous method
if (lastTriedMethod && !partialSuccess) {
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', `${lastTriedMethod} rejected`);
if (
lastTriedMethod === "password" &&
passwordAttemptSawKeyboardInteractive &&
Array.isArray(methodsLeft) &&
!methodsLeft.includes("keyboard-interactive")
) {
shouldRetryKeyboardInteractiveFirst = true;
log("password rejection removed keyboard-interactive; scheduling KI-first retry", {
methodsLeft,
});
}
if (lastTriedMethod !== "none") {
failedMethodIds.add(lastTriedMethod);
}
}
// On the very first call (methodsLeft === null), try "none" auth.
// Per RFC 4252, the "none" request is how the client discovers which
// methods the server supports. It also allows passwordless login on
// embedded devices. This matches the behavior of OpenSSH and Tabby.
if (methodsLeft === null && !attemptedMethodIds.has("none")) {
attemptedMethodIds.add("none");
lastTriedMethod = "none";
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'none (no credentials)');
return callback("none");
}
// methodsLeft can be null on first call (before server responds with available methods)
// Include "agent" for SSH agent-based auth (used with agentForwarding)
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
// Handle partialSuccess case (e.g., password succeeded but server requires additional auth like MFA)
// When partialSuccess is true, we should try the remaining methods the server is asking for
if (partialSuccess && methodsLeft && methodsLeft.length > 0) {
hadPartialSuccess = true;
// password method id is "password"; key ids start with publickey-
const succeededType =
lastTriedMethod === "password"
? "password"
: lastTriedMethod === "agent"
? "agent"
: lastTriedMethod === "keyboard-interactive"
? "keyboard-interactive"
: "publickey";
markAuthPhasePartialSuccess(authPhase, succeededType);
// Record the first successful method (the one that triggered partialSuccess)
if (lastTriedMethod && !firstSuccessfulMethod) {
firstSuccessfulMethod = lastTriedMethod;
log("Recorded first successful method for caching", { method: firstSuccessfulMethod });
}
// Reconsider methods that were unavailable in the previous
// factor, but preserve credentials already rejected or used.
if (lastTriedMethod) {
succeededMethodIds.add(lastTriedMethod);
log("Recorded successful auth factor (partial success)", { method: lastTriedMethod });
}
attemptedMethodIds = new Set([...failedMethodIds, ...succeededMethodIds]);
// PAM/EDR can require two consecutive keyboard-interactive
// factors (login password, then a separate secondary password).
// A partial-success response explicitly advertising KI again is
// permission to repeat that method with a fresh server prompt.
// Keep keys/passwords de-duplicated, but do not suppress this
// second interactive factor (#2150).
if (
methodsLeft.includes("keyboard-interactive") &&
canRepeatKeyboardInteractive(authPhase, failedMethodIds)
) {
attemptedMethodIds.delete("keyboard-interactive");
}
log("Partial success - server requires additional auth", { methodsLeft, succeeded: Array.from(succeededMethodIds), attemptedMethodIds: Array.from(attemptedMethodIds) });
// Next-factor selection: prefer keyboard-interactive when the
// server still allows it (automatic KI fallback after any
// partial success). Otherwise walk authMethods in order.
const partialCandidates = [];
for (const matchingMethod of authMethods) {
if (attemptedMethodIds.has(matchingMethod.id)) continue;
const serverMethod =
matchingMethod.type === "agent" || matchingMethod.type === "publickey"
? "publickey"
: matchingMethod.type;
if (!methodsLeft.includes(serverMethod) && !methodsLeft.includes(matchingMethod.type)) {
continue;
}
partialCandidates.push(matchingMethod);
}
const preferredPartial =
partialCandidates.find((method) => method.type === "keyboard-interactive")
|| partialCandidates[0];
if (preferredPartial) {
const matchingMethod = preferredPartial;
const serverMethod =
matchingMethod.type === "agent" || matchingMethod.type === "publickey"
? "publickey"
: matchingMethod.type;
log("Found matching method for partial success", { serverMethod, matchingMethod: matchingMethod.id });
attemptedMethodIds.add(matchingMethod.id);
lastTriedMethod = matchingMethod.id;
if (matchingMethod.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth (partial success)", { id: matchingMethod.id });
return callback("keyboard-interactive");
} else if (matchingMethod.type === "password") {
log("Trying password auth (partial success)", { id: matchingMethod.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (matchingMethod.type === "agent") {
const agentType = typeof matchingMethod.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth (partial success)", { id: matchingMethod.id, agentType });
return matchingMethod.agent === connectOpts.agent
? callback("agent")
: callback({
type: "agent",
username: connectOpts.username,
agent: matchingMethod.agent,
});
} else if (matchingMethod.type === "publickey") {
log("Trying publickey auth (partial success)", { id: matchingMethod.id });
return callback({
type: "publickey",
username: connectOpts.username,
key: matchingMethod.key,
passphrase: matchingMethod.passphrase,
});
}
}
// No matching method found for partial success
log("No matching method found for partial success requirements", { methodsLeft });
return callback(false);
}
for (const method of authMethods) {
// Skip methods that have already been attempted (e.g., during partial success handling)
if (attemptedMethodIds.has(method.id)) {
log("Skipping already attempted method", { method: method.id });
continue;
}
// Check if this method is still available on server
// Note: "agent" uses "publickey" as the underlying method type
const methodName = method.type === "password" ? "password" :
method.type === "publickey" ? "publickey" :
method.type === "agent" ? "publickey" : "keyboard-interactive";
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
log("Auth method not available on server, skipping", { method: method.id });
continue;
}
// Mark as attempted BEFORE returning
attemptedMethodIds.add(method.id);
lastTriedMethod = method.id;
if (method.type === "agent") {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof method.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'SSH agent');
return method.agent === connectOpts.agent
? callback("agent")
: callback({
type: "agent",
username: connectOpts.username,
agent: method.agent,
});
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', keyLabel);
return callback({
type: "publickey",
username: connectOpts.username,
key: method.key,
passphrase: method.passphrase,
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'password');
passwordAttemptSawKeyboardInteractive = availableMethods.includes("keyboard-interactive");
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'keyboard-interactive');
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
return callback("keyboard-interactive");
}
}
log("All auth methods exhausted");
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'all methods exhausted');
return callback(false);
};
// Store method reference for success callback
// For multi-step auth (partialSuccess), cache the first successful method, not the last
// This ensures next connection starts with the correct first factor
connectOpts._lastTriedMethodRef = () => {
if (hadPartialSuccess && firstSuccessfulMethod) {
log("Using first successful method for cache (multi-step auth)", { firstSuccessfulMethod });
return firstSuccessfulMethod;
}
return lastTriedMethod;
};
connectOpts._shouldRetryKeyboardInteractiveFirst = () => shouldRetryKeyboardInteractiveFirst;
}
}
// Handle chain/proxy connections
if (hasJumpHosts) {
// Pass fetched keys to chain connection to avoid re-reading files
options._defaultKeys = discoveredDefaultKeys;
options._sshDiagnosticLogger = log;
const chainResult = await connectThroughChain(
event,
options,
jumpHosts,
options.hostname,
options.port || 22,
sessionId
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
} else if (hasProxy) {
sendProgress(1, 1, options.hostname, 'connecting');
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.port || 22,
{ timeoutMs: tcpConnectTimeoutMs }
);
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else {
// Direct connection (no jump hosts, no proxy)
sendProgress(1, 1, options.hostname, 'connecting');
}
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
let settled = false;
const connectionStartedAt = Date.now();
let connectionStage = "connecting";
let authBanner = "";
let detachX11Forwarding = null;
// Reference-counted descriptor for this connection. Created when the
// shell channel opens; shared with any tabs that later reuse this
// connection (issue #1204). Tearing the transport down is funneled
// through releaseConnectionRef so the last channel — not whichever
// channel happens to close first — ends the connection + chain.
let connRef = null;
let authReadyTimer = null;
const clearAuthReadyTimer = () => {
if (authReadyTimer) {
clearTimeout(authReadyTimer);
authReadyTimer = null;
}
};
// Session-log stream token for THIS connection's owner channel,
// captured in the closure so the connection-level error/timeout/close
// handlers stop only this connection's stream. Reading it back off the
// session map would risk stopping a *newer* same-sessionId stream
// after a reconnect (the token guard from #916). Stays null until the
// owner shell opens.
let ownerLogStreamToken = null;
// End the shared transport directly when we fail *before* a session
// (and its connRef) exists; once connRef exists, teardown goes through
// releaseConnectionRef via the stream close handler instead.
const teardownTransport = () => {
if (connRef) return;
try { conn.end(); } catch { }
for (const c of chainConnections) {
try { c.end(); } catch { }
}
};
conn.once("connect", () => {
runWhenProxyConnectionReady(conn._sock, () => {
try { conn._sock?.setTimeout?.(0); } catch { }
clearAuthReadyTimer();
connectionStage = "tcp-connected";
authReadyTimer = setTimeout(() => conn.emit("timeout"), authReadyTimeoutMs);
authReadyTimer.unref?.();
log("target tcp connected", {
sessionId,
hostname: options.hostname,
elapsedMs: Date.now() - connectionStartedAt,
authReadyTimeoutMs,
});
sendProgress(totalHops, totalHops, options.hostname, 'tcp-connected');
enableSshNoDelay(conn);
});
});
if (connectOpts.sock) enableTcpNoDelay(connectOpts.sock);
conn.once("handshake", () => {
connectionStage = "handshake";
console.log(`${logPrefix} ${options.hostname} handshake complete`);
log("target handshake complete", {
sessionId,
hostname: options.hostname,
elapsedMs: Date.now() - connectionStartedAt,
});
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
});
conn.on("banner", (message) => {
authBanner = String(message || "").trim();
log("auth banner received", {
sessionId,
hostname: options.hostname,
length: authBanner.length,
});
});
conn.once("ready", () => {
clearAuthReadyTimer();
connectionStage = "ready";
console.log(`${logPrefix} ${options.hostname} ready`);
log("target ready", {
sessionId,
hostname: options.hostname,
elapsedMs: Date.now() - connectionStartedAt,
remoteSshVersion: (conn && typeof conn._remoteVer === 'string') ? conn._remoteVer : '',
});
// Cache the successful auth method
if (connectOpts._lastTriedMethodRef) {
const successMethod = connectOpts._lastTriedMethodRef();
if (successMethod) {
setCachedAuthMethod(connectOpts.username, options.hostname, options.port, successMethod);
}
}
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
sendProgress(totalHops, totalHops, options.hostname, 'shell');
let establishedOwnerSession = null;
const sendTerminalMessage = (data) => {
const current = sessions.get(sessionId);
if (establishedOwnerSession && current !== establishedOwnerSession) return;
emitTerminalSessionData(event.sender, sessionId, data, {
...(establishedOwnerSession ? { session: establishedOwnerSession } : {}),
cols: current?.cols,
rows: current?.rows,
});
};
const x11FakeCookie = options.x11Forwarding
? crypto.randomBytes(16).toString("hex")
: null;
if (options.x11Forwarding) {
detachX11Forwarding = attachX11Forwarding(conn, {
display: options.x11Display,
fakeCookie: x11FakeCookie,
sendMessage: sendTerminalMessage,
});
}
const shellOptions = {
env: {
COLORTERM: "truecolor",
...(options.env || {}),
},
};
if (options.x11Forwarding) {
shellOptions.x11 = {
protocol: "MIT-MAGIC-COOKIE-1",
cookie: x11FakeCookie,
screen: 0,
single: false,
};
}
openBoundedSshShellCallback(
conn,
{
term: "xterm-256color",
cols,
rows,
},
shellOptions,
(err, stream) => {
if (err) {
log("shell open failed", { sessionId, hostname: options.hostname, error: err.message });
if (detachX11Forwarding) detachX11Forwarding();
settled = true;
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
}
if (options.x11Forwarding && /x11/i.test(err.message || "")) {
sendTerminalMessage("\r\n[X11] Could not enable X11 forwarding. Make sure X11 forwarding is allowed on the server and xauth is installed.\r\n");
}
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
reject(err);
return;
}
sendProgress(totalHops, totalHops, options.hostname, 'connected');
// Create the shared reference-counted descriptor for this
// connection now that the owning channel is open, then wire the
// shell up through the shared helper.
let ownerSession;
try {
ownerSession = setupShellSession({
conn,
stream,
options: {
...options,
_actualAgentForwarding: Boolean(connectOpts.agentForward),
},
sessionId,
event,
log,
detachX11Forwarding,
chainConnections,
isReused: false,
});
} catch (setupErr) {
// Callback runs from openBoundedSshShellCallback's Promise
// .then without a catch — reject the owning start Promise.
if (detachX11Forwarding) {
try { detachX11Forwarding(); } catch { /* ignore */ }
}
settled = true;
reject(setupErr);
return;
}
establishedOwnerSession = ownerSession;
connRef = createConnectionRef(ownerSession, conn, chainConnections);
if (pendingDialCoordination) {
completeTransportDial(pendingDialCoordination, connRef);
}
// Capture this connection's log stream token in the closure so
// the connection-level handlers below stop the right stream even
// after a same-sessionId reconnect (#916).
ownerLogStreamToken = ownerSession._logStreamToken;
settled = true;
resolve({ sessionId });
}
);
});
conn.on("error", (err) => {
clearAuthReadyTimer();
// After the promise is settled, we can't reject again. But if the
// session was already established (resolved), we still need to notify
// the renderer about transport errors so the session shows as failed
// rather than silently closing.
// Don't send netcatty:exit here — the stream close handler will flush
// any buffered data first and then send exit with this error info.
if (settled) {
console.warn(`${logPrefix} ${options.hostname} post-settle error:`, err.message);
log("post-connect transport error", {
sessionId,
hostname: options.hostname,
error: err.message,
code: err.code,
level: err.level,
});
// Store the error so the close handler can include it in the exit event.
// ssh2 closes every channel when the transport errors, so each
// affected session (the owner and any reused siblings) gets the
// flag and reports the error via its own stream close handler.
const currentSession = sessions.get(sessionId);
const ownsCurrentSession = Boolean(connRef && currentSession?.connRef === connRef);
if (ownsCurrentSession) {
currentSession._transportError = err.message;
}
if (connRef) {
for (const sibling of sessions.values()) {
if (sibling !== currentSession && sibling.connRef === connRef) {
sibling._transportError = err.message;
}
}
}
return;
}
const contents = event.sender;
const isAuthError = isSshAuthFailure(err);
// Clear cached auth method on auth failure so next attempt tries all methods
if (isAuthError) {
if (
!options._skipPasswordMethod &&
connectOpts.password &&
connectOpts._shouldRetryKeyboardInteractiveFirst?.()
) {
err.retryKeyboardInteractiveFirst = true;
log("auth failure marked for KI-first retry", {
sessionId,
hostname: options.hostname,
});
}
clearCachedAuthMethod(connectOpts.username, options.hostname, options.port);
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
log("authentication failed", {
sessionId,
hostname: options.hostname,
error: err.message,
code: err.code,
level: err.level,
});
safeSend(contents, "netcatty:auth:failed", {
sessionId,
error: err.message,
hostname: options.hostname
});
} else {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
log("connection error", {
sessionId,
hostname: options.hostname,
error: err.message,
code: err.code,
level: err.level,
});
}
const visibleError = userVisibleSshErrorMessage(err, options);
sendProgress(totalHops, totalHops, options.hostname, 'error', visibleError);
const suppressPreShellAuthExit = Boolean(options._suppressPreShellAuthExit && isAuthError);
if (suppressPreShellAuthExit) {
log("suppressing pre-shell auth exit for wrapper-managed retry", {
sessionId,
hostname: options.hostname,
});
} else {
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, { sessionId, exitCode: 1, error: visibleError, reason: "error" });
}
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
if (detachX11Forwarding) {
detachX11Forwarding();
detachX11Forwarding = null;
}
sessions.get(sessionId)?.zmodemSentry?.cancel();
closeTerminalOutputSession?.(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
teardownTransport();
// Destroy the connection to prevent further socket errors from leaking
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
try { conn.destroy(); } catch { }
settled = true;
reject(err);
});
conn.once("timeout", () => {
clearAuthReadyTimer();
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
log("connection timeout", {
sessionId,
hostname: options.hostname,
error: err.message,
stage: connectionStage,
elapsedMs: Date.now() - connectionStartedAt,
tcpConnectTimeoutMs,
authReadyTimeoutMs,
});
const contents = event.sender;
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
sessions.get(sessionId)?.zmodemSentry?.cancel();
closeTerminalOutputSession?.(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
teardownTransport();
try { conn.destroy(); } catch { }
settled = true;
reject(err);
});
conn.once("close", () => {
clearAuthReadyTimer();
const contents = event.sender;
const currentSession = sessions.get(sessionId);
const ownsCurrentSession = Boolean(connRef && currentSession?.connRef === connRef);
log("connection closed", {
sessionId,
hostname: options.hostname,
settled,
staleForCurrentSession: Boolean(currentSession && !ownsCurrentSession),
transportError: ownsCurrentSession ? currentSession?._transportError : undefined,
});
if (!settled) {
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
}
// This handler owns teardown for the *owner* session only. ssh2
// fires conn "close" before the owner's stream "close" on a
// transport drop, so we clean the owner up here; the owner's stream
// close then no-ops because the session is already gone. Reused
// sibling channels each clean themselves up via their own stream
// "close" (ssh2 closes every channel when the transport drops).
if (ownsCurrentSession) {
const session = currentSession;
const transportError = session?._transportError;
if (transportError) {
// A transport error was recorded — report it as an error exit
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
exitCode: 1,
error: transportError,
reason: "error",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
} else {
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
exitCode: 0,
reason: "closed",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
}
// Use this connection's captured token so a late close from an
// old transport can't stop a newer same-sessionId stream (#916).
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
session?.zmodemSentry?.cancel();
// Release the owner's hold on the shared connection. The transport
// is already closing, but this decrements the reference count and,
// when it is the last holder, ends the jump-host chain. Reused
// siblings (if any) keep the count above zero until their own
// stream close handlers run.
releaseConnectionRef(session);
closeTerminalOutputSession?.(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
} else {
// Owner already cleaned up (e.g. its stream closed first). Ensure
// this connection's log stream is stopped defensively, scoped by
// the captured token so a reconnect's fresh stream is left alone.
if (ownerLogStreamToken) {
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
}
}
if (!settled) {
settled = true;
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
}
});
// Handle keyboard-interactive authentication (2FA/MFA). Uses the shared
// factory so PAM-wrapped single-password prompts get auto-filled from
// the saved host password (#969) — same path the chain/SFTP/port-
// forwarding bridges go through.
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId,
hostId: options.hostId,
hostname: options.hostname,
password: options.password,
logPrefix,
scope: "terminal",
bootEpoch: options.bootEpoch,
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
onAutoFill: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'using saved password',
),
onPromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...',
),
onUserResponded: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded',
),
}));
// Enable keyboard-interactive authentication in authHandler
// Note: If authHandler is a function (for fallback support), keyboard-interactive
// is already included in the auth methods list
if (Array.isArray(connectOpts.authHandler)) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else if (typeof connectOpts.authHandler !== "function") {
// Create authHandler with keyboard-interactive support
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
const authMethods = [];
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
if (loginAgent) authMethods.push("agent");
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password && !options._skipPasswordMethod) {
authMethods.push("password");
}
authMethods.push("keyboard-interactive");
const dedupedAuthMethods = Array.from(new Set(authMethods));
connectOpts.authHandler = createOrderedStringAuthHandler(
dedupedAuthMethods,
createAuthPhase(),
undefined,
loginAgent !== connectOpts.agent
? { username: connectOpts.username, agent: loginAgent }
: undefined,
);
log("Using simple array authHandler", {
authMethods: dedupedAuthMethods,
usedDefaultKeyAsPrimary,
});
}
// If authHandler is a function, it already handles keyboard-interactive
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
log("connect options prepared", {
sessionId,
hostname: options.hostname,
port: options.port || 22,
hasSocket: !!connectOpts.sock,
hasProxy,
jumpHostCount: jumpHosts.length,
timeout: connectOpts.timeout,
readyTimeout: connectOpts.readyTimeout,
tryKeyboard: connectOpts.tryKeyboard,
hasPassword: !!connectOpts.password,
hasPrivateKey: !!connectOpts.privateKey,
hasAgent: !!connectOpts.agent,
authHandlerType: Array.isArray(connectOpts.authHandler) ? "array" : typeof connectOpts.authHandler,
authMethods: Array.isArray(connectOpts.authHandler)
? connectOpts.authHandler
: undefined,
});
conn.connect(connectOpts);
}).catch((err) => {
if (pendingDialCoordination && !options._deferPendingDialFailure) {
failTransportDial(pendingDialCoordination, err);
}
throw err;
});
} catch (err) {
if (pendingDialCoordination && !options._deferPendingDialFailure) {
failTransportDial(pendingDialCoordination, err);
}
console.error("[Chain] SSH chain connection error:", err.message);
const isAuthError = isSshAuthFailure(err);
const suppressPreShellAuthExit = Boolean(options._suppressPreShellAuthExit && isAuthError);
if (!suppressPreShellAuthExit) {
const contents = event.sender;
safeSendSessionExit(
{ safeSend, electronModule, sessions },
contents,
sessionId,
{ sessionId, exitCode: 1, error: userVisibleSshErrorMessage(err, options) },
);
}
throw err;
}
}
return { startSSHSession };
}
}
module.exports = {
SSH_AUTH_READY_TIMEOUT_MS,
SSH_TCP_CONNECT_TIMEOUT_MS,
createStartSessionApi,
resolveSshConnectionTimeouts,
shouldOfferAgentForLogin,
shouldPrepareSystemAgentForLogin,
resolveUnlockedEncryptedKeysForAuth,
shouldPromoteCachedAuthMethod,
applyAgentForwarding,
prepareAgentForwardingOptions,
};