Files
NetMesh/electron/bridges/sftpBridge/openConnection.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1386 lines
57 KiB
JavaScript

/* eslint-disable no-undef */
const { resolveSshConnectionTimeouts } = require("../sshBridge/startSession.cjs");
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
const { normalizeFileProtocol } = require("./scpShell.cjs");
const { getScpBackendForClient } = require("./scpBackend.cjs");
const {
executeBoundedSshCommand,
openBoundedSshExecStreamCallback,
terminateSshExecStream,
} = require("../boundedSshExec.cjs");
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
/** Bound shell/scp probes so a hung remote exec cannot leave the panel connecting forever. */
const SCP_PROBE_TIMEOUT_MS = 15_000;
/**
* AbortSignal that fires on parent abort or after timeoutMs.
* Caller must dispose() to clear the timer.
*/
function createBoundedProbeSignal(parentSignal = null, timeoutMs = SCP_PROBE_TIMEOUT_MS) {
const ac = new AbortController();
let timedOut = false;
let timer = null;
const onParentAbort = () => {
try { ac.abort(); } catch { /* ignore */ }
};
if (parentSignal?.aborted) {
ac.abort();
} else {
if (parentSignal && typeof parentSignal.addEventListener === "function") {
parentSignal.addEventListener("abort", onParentAbort, { once: true });
}
timer = setTimeout(() => {
timedOut = true;
try { ac.abort(); } catch { /* ignore */ }
}, Math.max(1, Number(timeoutMs) || SCP_PROBE_TIMEOUT_MS));
if (typeof timer.unref === "function") timer.unref();
}
return {
signal: ac.signal,
get timedOut() {
return timedOut;
},
dispose: () => {
if (timer) clearTimeout(timer);
if (parentSignal && typeof parentSignal.removeEventListener === "function") {
try { parentSignal.removeEventListener("abort", onParentAbort); } catch { /* ignore */ }
}
},
};
}
/** Return true when ssh2 reports an authentication-stage failure. */
function isSftpAuthError(err) {
const level = String(err?.level || "").toLowerCase();
const message = String(err?.message || "").toLowerCase();
return level.includes("authentication") || message.includes("authentication");
}
/** Decide whether the SFTP target should retry with keyboard-interactive before password. */
function shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err) {
return Boolean(
options?.password &&
!options?._skipPasswordMethod &&
isSftpAuthError(err) &&
authConfig?.authPhase?.retryKeyboardInteractiveFirst,
);
}
function shouldRegisterFreshSftpTransport(options) {
return options?.reuseTransport !== false && !options?.sudo;
}
/** Sudo is part of the requested connection contract; never downgrade it. */
async function openRequiredSudoSftp({
connectSudoSftp,
sshClient,
password,
closeFreshClient,
}) {
try {
return await connectSudoSftp(sshClient, password);
} catch (error) {
closeFreshClient();
throw error;
}
}
function createOpenConnectionApi(ctx) {
with (ctx) {
/**
* Mark a session client as SCP-mode and optionally probe shell access.
*/
async function activateScpMode(client, { probe = true, signal = null, timeoutMs = SCP_PROBE_TIMEOUT_MS } = {}) {
client.__netcattyFileProtocol = "scp";
client.sftp = null;
// Ensure closeSftp can tear down owned SSH sockets even when sftp is null.
// Never end a shared terminal session socket.
if (typeof client.end === "function" && !client.__netcattyScpEndWrapped) {
const prevEnd = client.end.bind(client);
client.end = async (...args) => {
const ownsSocket = !client.__netcattySessionBacked
&& !client.__netcattySourceSessionId
&& !client.__netcattyTransportManaged;
if (ownsSocket) {
try { client.client?.end?.(); } catch { /* ignore */ }
try { client.client?.destroy?.(); } catch { /* ignore */ }
}
return prevEnd(...args);
};
client.__netcattyScpEndWrapped = true;
}
if (probe) {
const bounded = createBoundedProbeSignal(signal, timeoutMs);
try {
const backend = getScpBackendForClient(client);
// Require a working shell AND scp binary — transfers need `scp -t/-f`.
await backend.homeDir({ signal: bounded.signal }).catch(async () => {
if (bounded.signal.aborted) throw new Error("SCP mode shell probe aborted");
const adapters = require("./scpBackend.cjs").createSshExecAdapters(client.client);
const pwd = await adapters.exec("pwd", { signal: bounded.signal });
if (pwd.code !== 0 || !(pwd.stdout || "").trim()) {
throw new Error("SCP mode shell probe failed");
}
});
const adapters = require("./scpBackend.cjs").createSshExecAdapters(client.client);
const scpProbe = await adapters.exec(
"command -v scp >/dev/null 2>&1 || which scp >/dev/null 2>&1",
{ signal: bounded.signal },
);
if (scpProbe.code !== 0) {
throw new Error("SCP binary not available on remote host (command -v scp failed)");
}
} catch (err) {
if (bounded.timedOut && !signal?.aborted) {
throw new Error(`SCP mode probe timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
bounded.dispose();
}
}
return client;
}
const hasUsableProxy = (proxy) => {
if (!proxy) return false;
if (proxy.type === "command") return !!proxy.command?.trim();
return !!(proxy.host && proxy.port);
};
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
let activeConn = null;
const defaultAgentSocket = await getAvailableAgentSocket();
const cleanupSocket = (socket) => {
if (!socket) return;
try { socket.end?.(); } catch { /* ignore */ }
try { socket.destroy?.(); } catch { /* ignore */ }
};
try {
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
sendSftpProgress(sender, connId, hopLabel, 'connecting');
const conn = new SSHClient();
activeConn = conn;
// Increase max listeners to prevent Node.js warning
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Per-hop keepalive. The renderer's resolver returns either a positive
// number (use it) or 0 (the host explicitly opted out, e.g. a router
// whose SSH stack doesn't reply to keepalive@openssh.com). Only when
// BOTH the per-hop and the target-call fields are undefined do we
// fall back to 10s/3 — that path exists for older serializers that
// pre-date per-host plumbing, preserving the #669 idle-NAT protection
// for callers that haven't yet been updated.
const hopInterval = jump.keepaliveInterval != null
? jump.keepaliveInterval
: options.keepaliveInterval;
const hopCountMax = jump.keepaliveCountMax != null
? jump.keepaliveCountMax
: options.keepaliveCountMax;
const hopIntervalMs = hopInterval == null
? 10000
: (hopInterval > 0 ? hopInterval * 1000 : 0);
const hopCountMaxEffective = hopInterval == null
? 3
: (hopInterval > 0 ? (hopCountMax ?? 3) : 0);
const hopConnectionTimeouts = resolveSshConnectionTimeouts(jump);
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
timeout: hopConnectionTimeouts.tcpConnectTimeoutMs,
readyTimeout: 0,
keepaliveInterval: hopIntervalMs,
keepaliveCountMax: hopCountMaxEffective,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
// Per-hop algorithm settings, mirroring sshBridge.cjs:
// - `legacyAlgorithms` falls back to the target's setting
// (append-only — safe to widen the hop's offer for chain
// convenience).
// - `skipEcdsaHostKey` and `algorithmOverrides` are strictly
// per-host. They *narrow* the offered list (drop ecdsa-* or
// replace a category), so propagating the leaf's setting
// would break an ECDSA-required or Ed25519-only bastion.
algorithms: buildSftpAlgorithms(
jump.legacyAlgorithms ?? options.legacyAlgorithms,
{
skipEcdsaHostKey: jump.skipEcdsaHostKey,
algorithmOverrides: jump.algorithmOverrides,
},
),
};
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId: connId,
hostname: jump.hostname,
port: jump.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
});
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
const systemAuthAgent = hasCertificate
? null
: await prepareSystemSshAgentForAuth(jump, `[SFTP Chain] Hop ${i + 1}:`);
const identityFile = !jump.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender,
identityFilePaths: jump.identityFilePaths,
hostname: hopLabel,
initialPassphrase: jump.passphrase,
logPrefix: `[SFTP Chain] Hop ${i + 1}:`,
onLoaded: (loaded) => {
console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${loaded.keyPath}`);
},
onError: (err, keyPath) => {
console.warn(`[SFTP Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
},
})
: null;
const inlineKey = jump.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey: jump.privateKey,
keyId: jump.keyId,
keyName: jump.keyId || jump.label || jump.username,
hostname: hopLabel,
initialPassphrase: jump.passphrase,
logPrefix: `[SFTP Chain] Hop ${i + 1}:`,
})
: null;
const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey;
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
let authAgent = null;
if (systemAuthAgent) {
connOpts.agent = systemAuthAgent;
}
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: jump.keyId || jump.username || "",
certificate: jump.certificate,
privateKey: effectivePrivateKey,
passphrase: effectivePassphrase,
},
});
connOpts.agent = authAgent;
} else if (effectivePrivateKey) {
connOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connOpts.passphrase = effectivePassphrase;
} else if (isKeyEncrypted(effectivePrivateKey)) {
// Key is encrypted but no passphrase provided — prompt the user
console.log(`[SFTP Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
const keyLabel = jump.label || hopLabel;
const result = await passphraseHandler.requestPassphrase(
sender,
`SSH key for ${keyLabel}`,
keyLabel,
hopLabel
);
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
delete connOpts.privateKey;
if (result?.cancelled) {
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
}
}
}
}
if (jump.password) connOpts.password = jump.password;
// Get default keys (either from options if pre-fetched, or fetch them now)
const defaultKeys = systemAuthAgent && jump.identitiesOnly
? []
: options._defaultKeys || await findAllDefaultPrivateKeysFromHelper();
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
authMethod: jump.authMethod,
requiresMfa: !!jump.requiresMfa,
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: systemAuthAgent && jump.identitiesOnly
? []
: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: defaultAgentSocket,
allowAgentFallback: jump.useSshAgent !== false,
onAuthAttempt: (method) => {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
const hopAuthPhase = authConfig.authPhase || { hadPartialSuccess: false };
// If first hop and proxy is configured, connect through proxy
const hasUsableJumpProxy = hasUsableProxy(jump.proxy);
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
if (effectiveHopProxy) {
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22, {
timeoutMs: hopConnectionTimeouts.tcpConnectTimeoutMs,
});
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
} else if (!isFirst && currentSocket) {
// Tunnel through previous hop
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
let settled = false;
let authReadyTimer = null;
const clearAuthReadyTimer = () => {
if (!authReadyTimer) return;
clearTimeout(authReadyTimer);
authReadyTimer = null;
};
conn.once('connect', () => {
runWhenProxyConnectionReady(conn._sock, () => {
try { conn._sock?.setTimeout?.(0); } catch { /* ignore */ }
clearAuthReadyTimer();
authReadyTimer = setTimeout(
() => conn.emit('timeout'),
hopConnectionTimeouts.authReadyTimeoutMs,
);
authReadyTimer.unref?.();
});
});
conn.once('handshake', () => {
sendSftpProgress(sender, connId, hopLabel, 'authenticating');
});
conn.once('ready', () => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
sendSftpProgress(sender, connId, hopLabel, 'connected');
resolve();
});
conn.on('error', (err) => {
// Filter out non-fatal agent auth errors (same as in openSftp)
if (err.level === 'agent') {
console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
if (settled) return;
settled = true;
clearAuthReadyTimer();
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
sendSftpProgress(sender, connId, hopLabel, 'error', err.message);
reject(err);
});
conn.once('timeout', () => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
conn.once('close', () => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
reject(new Error(`Connection closed before authentication completed for ${hopLabel}`));
});
let authBanner = "";
conn.on('banner', (message) => {
authBanner = String(message || "").trim();
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
const sftpChainKiHandler = createKeyboardInteractiveHandler({
sender,
sessionId: connId,
hostId: jump.hostId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
scope: "external",
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase),
});
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
if (prompts && prompts.length > 0) {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'waiting for user input...');
}
const wrappedFinish = (...args) => {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'user responded');
finish(...args);
};
sftpChainKiHandler(name, instructions, lang, prompts, wrappedFinish);
});
conn.connect(connOpts);
});
connections.push(conn);
activeConn = null;
// Determine next target
let nextHost, nextPort, nextConnectionTimeouts;
if (isLast) {
// Last jump host, forward to final target
nextHost = targetHost;
nextPort = targetPort;
nextConnectionTimeouts = resolveSshConnectionTimeouts(options);
} else {
// Forward to next jump host
const nextJump = jumpHosts[i + 1];
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
nextConnectionTimeouts = resolveSshConnectionTimeouts(nextJump);
}
// Create forward stream to next hop
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`);
currentSocket = await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(`Connection timeout to ${nextHost}:${nextPort}`));
}, nextConnectionTimeouts.tcpConnectTimeoutMs);
timeout.unref?.();
openBoundedForwardOutCallback(conn, '127.0.0.1', 0, nextHost, nextPort, (err, stream) => {
if (settled) {
try { stream?.end?.(); } catch { /* ignore */ }
return;
}
settled = true;
clearTimeout(timeout);
if (err) {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut failed:`, err.message);
reject(err);
return;
}
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut success`);
resolve(stream);
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
connections
};
} catch (err) {
// Cleanup on error
if (activeConn && !connections.includes(activeConn)) {
try { activeConn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); }
}
cleanupSocket(currentSocket);
for (const conn of connections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); }
}
throw err;
}
}
/**
* Establish an SFTP connection using sudo
* @param {SSHClient} client - Connected SSH client
* @param {string} password - User password for sudo
*/
async function connectSudoSftp(client, password) {
if (!SFTPWrapper) {
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
}
// Known sftp-server paths to try
const sftpPaths = [
"/usr/lib/openssh/sftp-server",
"/usr/libexec/openssh/sftp-server",
"/usr/lib/ssh/sftp-server",
"/usr/libexec/sftp-server",
"/usr/local/libexec/sftp-server",
"/usr/local/lib/sftp-server"
];
console.log("[SFTP] Probing sftp-server path for sudo mode...");
let serverPath = null;
// Try to find the path
for (const p of sftpPaths) {
try {
const result = await executeBoundedSshCommand(client, `test -x ${p}`, {
openingTimeoutMs: SCP_PROBE_TIMEOUT_MS,
runTimeoutMs: SCP_PROBE_TIMEOUT_MS,
maxOutputBytes: 16 * 1024,
});
if (result.code !== 0) throw new Error('Not found');
serverPath = p;
break;
} catch (e) {
// Continue probing
}
}
if (!serverPath) {
// Fallback: try to find it in path or assume standard location
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
serverPath = "/usr/lib/openssh/sftp-server";
} else {
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
}
return new Promise((resolve, reject) => {
// Use sudo -S to read password from stdin
// Use -p '' to set a specific prompt we can detect
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
const prompt = "SUDOPASSWORD:";
const readyMarker = "SFTPREADY";
const readyMarkerBuffer = Buffer.from(readyMarker);
// Add -e to sftp-server to log to stderr for debugging
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
console.log(`[SFTP] Executing sudo command: ${cmd}`);
// Disable pty to ensure clean binary stream for SFTP
openBoundedSshExecStreamCallback(client, cmd, { pty: false }, (err, stream) => {
if (err) return reject(err);
// Add stream lifecycle logging
stream.on('close', () => console.log("[SFTP] Stream closed"));
stream.on('end', () => console.log("[SFTP] Stream ended"));
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
let sftpInitialized = false;
let sftp = null;
let settled = false;
let stdoutBuffer = Buffer.alloc(0);
let stderrBuffer = "";
let pendingAfterMarker = null;
let sftpCreated = false;
let initDelayId = null;
const timeoutMs = 20000;
const timeoutId = setTimeout(() => {
if (sftpInitialized || settled) return;
settled = true;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
if (initDelayId) clearTimeout(initDelayId);
terminateSshExecStream(stream);
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
reject(error);
}, timeoutMs);
timeoutId.unref?.();
const finalize = (err, result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
if (initDelayId) clearTimeout(initDelayId);
initDelayId = null;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
if (err) {
terminateSshExecStream(stream);
reject(err);
}
else resolve(result);
};
const createSftp = () => {
if (sftpCreated) return;
sftpCreated = true;
try {
const chanInfo = {
type: 'sftp',
incoming: stream.incoming,
outgoing: stream.outgoing
};
sftp = new SFTPWrapper(client, chanInfo, {
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
});
// Route any remaining channel data directly into the SFTP parser
if (client._chanMgr && typeof stream.incoming?.id === "number") {
client._chanMgr.update(stream.incoming.id, sftp);
}
sftp.on('ready', () => {
sftpInitialized = true;
console.log("[SFTP] Protocol ready");
finalize(null, sftp);
});
sftp.on('error', (err) => {
console.error("[SFTP] Protocol error:", err.message);
if (!sftpInitialized) {
finalize(err);
}
});
stream.on('end', () => {
try { sftp.push(null); } catch { }
});
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const initSftp = () => {
if (sftpInitialized) return;
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
if (!sftpCreated) createSftp();
try {
// Start the handshake
console.log("[SFTP] Sending INIT packet...");
sftp._init();
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
try {
sftp.push(pendingAfterMarker);
} catch (pushErr) {
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
}
pendingAfterMarker = null;
}
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const onStdout = (data) => {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
if (markerIndex !== -1) {
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
if (afterMarkerIndex < stdoutBuffer.length) {
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
}
// Found marker, stop listening to stdout here so SFTPWrapper can take over
stream.removeListener('data', onStdout);
stdoutBuffer = Buffer.alloc(0);
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
// Increased timeout to 1000ms to be safe
initDelayId = setTimeout(() => {
initDelayId = null;
initSftp();
}, 1000);
initDelayId.unref?.();
} else if (stdoutBuffer.length > 256) {
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
}
};
const onStderr = (data) => {
const raw = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
const chunk = raw.subarray(Math.max(0, raw.length - 1024)).toString();
// Only log that we received stderr data, not the content (may contain sensitive prompts)
stderrBuffer = (stderrBuffer + chunk).slice(-1024);
if (stderrBuffer.includes(prompt)) {
console.log("[SFTP] Sudo requested password, sending...");
// Send password
if (password) {
stream.write(password + '\n');
} else {
console.warn('[SFTP] sudo requested password but none provided');
stream.write('\n');
}
stderrBuffer = "";
} else if (stderrBuffer.length > 256) stderrBuffer = stderrBuffer.slice(-256);
};
stream.on('data', onStdout);
stream.stderr.on('data', onStderr);
// Error handling
stream.on('exit', (code) => {
console.log(`[SFTP] Stream exited with code ${code}`);
if (!sftpInitialized && code !== 0) {
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
if (code === 1) {
errorMsg += " The password may be incorrect or sudo privileges are denied.";
} else if (code === 127) {
errorMsg += " sftp-server was not found on the remote system.";
}
const error = new Error(errorMsg);
finalize(error);
}
});
stream.on('error', (error) => {
if (!sftpInitialized) finalize(error);
});
stream.on('close', () => {
if (!sftpInitialized) finalize(new Error("SFTP sudo stream closed before protocol initialization"));
});
stream.on('end', () => {
if (!sftpInitialized) finalize(new Error("SFTP sudo stream ended before protocol initialization"));
});
}, { openingTimeoutMs: 20_000 });
});
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
*/
async function openSftp(event, options) {
const connId = options.sessionId || randomUUID();
const reuseEndpoint = buildConnectionReuseEndpoint(options, {
sftpSudo: Boolean(options.sudo),
});
let pendingDialCoordination = options._pendingDialCoordination || null;
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
const refHolder = {
id: connId,
__sshLeaseKind: "sftp",
webContentsId: event.sender?.id,
};
acquireConnectionRef?.(refHolder, transport);
const reusedClient = createSessionBackedSftpClient(connId, transport.conn, {
refHolder,
sourceSessionId: options.sourceSessionId || null,
});
reusedClient.__netcattyEndpointKey = transport.endpointKey || buildEndpointKey(reuseEndpoint);
reusedClient.__netcattyTransportManaged = true;
const fileProtocol = normalizeFileProtocol(options.fileProtocol);
try {
sendSftpProgress(event.sender, connId, options.hostname, "connecting", detail);
if (fileProtocol === "scp") {
await activateScpMode(reusedClient);
reusedClient.__netcattySudoMode = false;
sftpClients.set(connId, reusedClient);
sendSftpProgress(event.sender, connId, options.hostname, "connected", `${detail} (SCP mode)`);
return { sftpId: connId, fileProtocol: "scp" };
}
try {
await requireSftpChannel(reusedClient);
reusedClient.__netcattyFileProtocol = "sftp";
reusedClient.__netcattySudoMode = false;
sftpClients.set(connId, reusedClient);
sendSftpProgress(event.sender, connId, options.hostname, "connected", detail);
return { sftpId: connId, fileProtocol: "sftp" };
} catch (sftpErr) {
if (fileProtocol === "sftp") throw sftpErr;
await activateScpMode(reusedClient);
reusedClient.__netcattySudoMode = false;
sftpClients.set(connId, reusedClient);
sendSftpProgress(event.sender, connId, options.hostname, "connected", `${detail} (SCP mode)`);
return { sftpId: connId, fileProtocol: "scp" };
}
} catch (err) {
try { await reusedClient.end(); } catch { /* ignore */ }
throw err;
}
};
// Parked / shared transport without a live terminal session: open an SFTP
// channel on the registry transport (no second MFA).
// Dedicated bulk transfer / restart-resume passes reuseTransport:false so
// a transfer never silently attaches to a terminal/parked conn.
if (
!pendingDialCoordination
&& options.reuseTransport !== false
&& !options.sudo
&& typeof findTransportByEndpoint === "function"
&& typeof createSessionBackedSftpClient === "function"
) {
const transport = findTransportByEndpoint(reuseEndpoint);
if (transport?.conn) {
try {
return await openOnSharedTransport(transport, "reused parked transport");
} catch (parkErr) {
console.warn(
`[SFTP] Parked transport reuse failed for ${connId}; connecting fresh:`,
parkErr?.message || String(parkErr),
);
}
}
}
if (
!pendingDialCoordination
&& options.reuseTransport !== false
&& !options.sudo
&& typeof beginTransportDial === "function"
) {
const coordination = beginTransportDial(reuseEndpoint, { kind: "channel" });
if (coordination.role === "reuse" || coordination.role === "join") {
try {
const transport = coordination.role === "reuse"
? coordination.transport
: await waitForTransportDial(coordination);
return await openOnSharedTransport(transport, "reused coordinated transport");
} catch (coordinationErr) {
if (coordination.role === "join") throw coordinationErr;
console.warn(
`[SFTP] Coordinated transport reuse failed for ${connId}; connecting fresh:`,
coordinationErr?.message || String(coordinationErr),
);
}
} else {
pendingDialCoordination = coordination;
}
}
try {
return await openFreshSftp();
} catch (err) {
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err);
throw err;
}
async function openFreshSftp() {
const client = new SftpClient();
client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint);
let freshClientClosed = false;
const closeFreshClient = () => {
if (freshClientClosed) return;
freshClientClosed = true;
// A failure after SSH authentication but before registration in
// sftpClients/the transport pool otherwise leaves an unreachable live
// socket. This is deliberately low-level: there may be no SFTP channel
// for ssh2-sftp-client.end() to close yet.
try { client.client?.end?.(); } catch { /* ignore */ }
try { client.client?.destroy?.(); } catch { /* ignore */ }
};
// Get default keys early to use for both chain and target
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
// Check if we need to connect through jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = hasUsableProxy(options.proxy);
const targetConnectionTimeouts = resolveSshConnectionTimeouts(options);
let chainConnections = [];
let connectionSocket = null;
const cleanupPendingConnection = () => {
for (const conn of chainConnections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on connect failure:', cleanupErr.message); }
}
if (connectionSocket) {
try { connectionSocket.end?.(); } catch { /* ignore */ }
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
}
};
// Pre-fetch agent socket (async check for Windows SSH Agent service)
// This is used by both jump host chain auth and final host auth
const agentSocket = await getAvailableAgentSocket(options.identityAgent);
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
// Pass default keys to chain connection
options._defaultKeys = defaultKeys;
const chainResult = await connectThroughChainForSftp(
event,
options,
jumpHosts,
options.hostname,
options.port || 22,
connId
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
} else if (hasProxy) {
console.log(`[SFTP] Opening connection through proxy to ${options.hostname}:${options.port || 22}`);
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.port || 22,
{ timeoutMs: targetConnectionTimeouts.tcpConnectTimeoutMs },
);
}
const keepalivePolicy = resolveConnectionKeepalivePolicy(options);
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
timeout: targetConnectionTimeouts.tcpConnectTimeoutMs,
readyTimeout: 0,
// Keepalive policy:
// - positive value: honor it (in seconds, convert to ms)
// - explicit 0: truly disabled (host opted out via per-host override —
// critical for routers/switches that don't reply to keepalive
// @openssh.com and would otherwise be killed by ssh2 after countMax
// unanswered probes)
// - undefined: legacy caller path, fall back to 10s/3 so an idle SFTP
// browse over a NAT doesn't drop (the original #669 protection)
keepaliveInterval: keepalivePolicy.keepaliveIntervalMs,
keepaliveCountMax: keepalivePolicy.keepaliveCountMax,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms, {
skipEcdsaHostKey: options.skipEcdsaHostKey,
algorithmOverrides: options.algorithmOverrides,
}),
};
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender: event.sender,
sessionId: connId,
hostId: options.hostId,
hostname: options.hostname,
port: options.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: options.verifyHostKeys,
});
// Use the tunneled socket if we have one
if (connectionSocket) {
connectOpts.sock = connectionSocket;
// When using sock, we should not set host/port as the connection is already established
delete connectOpts.host;
delete connectOpts.port;
}
const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0;
let identityFile = null;
let inlineKey = null;
let systemAuthAgent = null;
try {
systemAuthAgent = hasCertificate
? null
: await prepareSystemSshAgentForAuth(options, "[SFTP]");
identityFile = !options.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender: event.sender,
identityFilePaths: options.identityFilePaths,
hostname: options.hostname,
initialPassphrase: options.passphrase,
logPrefix: "[SFTP]",
onLoaded: (loaded) => {
console.log(`[SFTP] Loaded identity file ${loaded.keyPath}`);
},
onError: (err, keyPath) => {
console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message);
},
})
: null;
inlineKey = options.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender: event.sender,
privateKey: options.privateKey,
keyId: options.keyId,
keyName: options.keyId || options.username,
hostname: options.hostname,
initialPassphrase: options.passphrase,
logPrefix: "[SFTP]",
})
: null;
} catch (err) {
cleanupPendingConnection();
throw err;
}
const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey;
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
let authAgent = null;
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: effectivePassphrase,
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
} else if (isKeyEncrypted(effectivePrivateKey)) {
// Key is encrypted but no passphrase provided — prompt the user
console.log(`[SFTP] Key is encrypted, requesting passphrase for ${options.hostname}`);
const result = await passphraseHandler.requestPassphrase(
event.sender,
`SSH key for ${options.hostname}`,
options.hostname,
options.hostname
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
delete connectOpts.privateKey;
if (result?.cancelled) {
// Clean up any chain/proxy connections and proxy socket opened earlier
for (const c of chainConnections) {
try { c.end(); } catch {}
}
if (connectionSocket) {
try { connectionSocket.destroy(); } catch {}
}
// Use "authentication" in the message so the SFTP frontend's
// isAuthError() check recognizes this and falls back to password.
const err = new Error(`Authentication cancelled — passphrase not provided for ${options.hostname}`);
err.level = 'client-authentication';
throw err;
}
}
}
}
if (options.password) connectOpts.password = options.password;
// Build auth handler using shared helper
// Use pre-fetched agentSocket (validated async, including Windows service check)
const authConfig = buildAuthHandler({
authMethod: options.authMethod,
requiresMfa: !!options.requiresMfa,
privateKey: connectOpts.privateKey,
password: connectOpts.password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SFTP]",
skipPasswordMethod: options._skipPasswordMethod === true,
defaultKeys: systemAuthAgent && options.identitiesOnly ? [] : defaultKeys,
sshAgentSocketOverride: agentSocket,
allowAgentFallback: options.useSshAgent !== false,
onAuthAttempt: (method) => {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connectOpts, authConfig);
const sftpAuthPhase = authConfig.authPhase || { hadPartialSuccess: false };
let authBanner = "";
// Create keyboard-interactive handler using shared helper
const kiHandler = createKeyboardInteractiveHandler({
sender: event.sender,
sessionId: connId,
hostId: options.hostId,
hostname: options.hostname,
password: options.password,
logPrefix: "[SFTP]",
scope: "external",
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(sftpAuthPhase),
});
// Add keyboard-interactive listener BEFORE connecting
// Wrap to emit progress events for the SFTP connection log
client.on("banner", (message) => {
authBanner = String(message || "").trim();
});
client.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
if (prompts && prompts.length > 0) {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'waiting for user input...');
}
const wrappedFinish = (...args) => {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'user responded');
finish(...args);
};
kiHandler(name, instructions, lang, prompts, wrappedFinish);
});
try {
// IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the
// underlying ssh2 Client directly. This is because ssh2-sftp-client adds
// temporary error listeners that reject the entire connect promise on ANY
// error, including non-fatal auth errors (e.g. 'Failed to connect to agent'
// when ssh2 tries agent auth and falls through to the next method).
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
await new Promise((resolve, reject) => {
let settled = false;
let authReadyTimer = null;
const clearAuthReadyTimer = () => {
if (!authReadyTimer) return;
clearTimeout(authReadyTimer);
authReadyTimer = null;
};
const settle = (fn, val) => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
cleanup();
fn(val);
};
const onError = (err) => {
// Filter out non-fatal authentication errors.
// ssh2 sets err.level = 'agent' when agent auth fails — it then
// internally calls tryNextAuth() to proceed with the next method.
// We must NOT reject here, or the fallback won't execute.
if (err.level === 'agent') {
console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message);
return;
}
settle(reject, err);
};
const onEnd = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
const onClose = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
const cleanup = () => {
sshClient.removeListener('error', onError);
sshClient.removeListener('end', onEnd);
sshClient.removeListener('close', onClose);
sshClient.removeListener('timeout', onTimeout);
// Keep a catch-all error listener so post-ready errors (e.g. connection
// drops during an active SFTP session) don't become uncaught exceptions.
sshClient.on('error', (err) => {
console.error(`[SFTP] Post-ready SSH error for ${connId}:`, err.message);
});
};
sshClient.on('error', onError);
sshClient.on('end', onEnd);
sshClient.on('close', onClose);
const onTimeout = () => {
settle(reject, new Error(`Connection timeout to ${options.hostname}`));
try { sshClient.end?.(); } catch { /* ignore */ }
try { sshClient.destroy?.(); } catch { /* ignore */ }
};
sshClient.on('timeout', onTimeout);
sshClient.once('connect', () => {
runWhenProxyConnectionReady(sshClient._sock, () => {
try { sshClient._sock?.setTimeout?.(0); } catch { /* ignore */ }
clearAuthReadyTimer();
authReadyTimer = setTimeout(
() => sshClient.emit('timeout'),
targetConnectionTimeouts.authReadyTimeoutMs,
);
authReadyTimer.unref?.();
});
});
sshClient.once('handshake', () => {
sendSftpProgress(event.sender, connId, options.hostname, 'authenticating');
});
sshClient.once('ready', () => {
clearAuthReadyTimer();
cleanup();
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
const fileProtocol = normalizeFileProtocol(options.fileProtocol);
const finishSftp = (sftp) => {
client.sftp = sftp;
client.__netcattyFileProtocol = "sftp";
resolve();
};
const finishScp = async (reason) => {
try {
if (reason) {
console.warn(`[SFTP] ${reason}; activating SCP mode for ${connId}`);
}
await activateScpMode(client);
resolve();
} catch (scpErr) {
closeFreshClient();
reject(scpErr);
}
};
if (fileProtocol === "scp") {
if (options.sudo) {
// Forced SCP cannot provide sudo elevation; reject contradictory host data.
closeFreshClient();
reject(new Error(
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
));
return;
}
console.log(`[SFTP] Forced SCP mode for connection: ${connId}`);
void finishScp(null);
return;
}
// When the host asks for sudo SFTP, never silently fall through to
// unprivileged SCP on Auto — that would look "connected" without elevation.
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
(async () => {
try {
const sudoPass = options.password || "";
const sftpWrapper = await openRequiredSudoSftp({
connectSudoSftp,
sshClient,
password: sudoPass,
closeFreshClient,
});
sftpWrapper.on('close', () => client.end());
finishSftp(sftpWrapper);
} catch (e) {
// Elevation is part of the requested connection contract. A
// standard SFTP fallback would look successful while silently
// losing access to the requested destination.
reject(e);
}
})();
} else {
// Open standard SFTP subsystem channel
openBoundedSftpChannel(sshClient).then((sftp) => {
finishSftp(sftp);
}).catch((err) => {
if (fileProtocol === "auto") {
void finishScp(`SFTP subsystem unavailable (${err.message})`);
return;
}
reject(err);
});
}
});
sendSftpProgress(event.sender, connId, options.hostname, 'connecting');
try {
sshClient.connect(connectOpts);
} catch (e) {
settle(reject, e);
}
});
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
if (client.client && typeof client.client.setMaxListeners === 'function') {
client.client.setMaxListeners(0); // 0 means unlimited
}
// Used by transferBridge to decide whether isolated fast-transfer channels are safe.
client.__netcattySudoMode = !!options.sudo && client.__netcattyFileProtocol !== "scp";
if (!client.__netcattyFileProtocol) {
client.__netcattyFileProtocol = "sftp";
}
// Register dedicated dials in the shared SSH transport registry so
// later terminal/SFTP/transfer/port-forward work can borrow without MFA.
const sshConn = client.client;
if (
sshConn
&& shouldRegisterFreshSftpTransport(options)
&& typeof createTransport === "function"
&& typeof borrowTransport === "function"
) {
try {
const refHolder = {
id: connId,
__sshLeaseKind: "sftp",
webContentsId: event.sender?.id,
};
const transport = createTransport({
conn: sshConn,
chainConnections,
endpoint: reuseEndpoint,
});
borrowTransport(transport, {
kind: "sftp",
holder: refHolder,
leaseId: `sftp:${connId}`,
meta: { source: "openSftp-dedicated" },
});
client.__netcattyRefHolder = refHolder;
client.__netcattyTransportManaged = true;
if (pendingDialCoordination) {
completeTransportDial(pendingDialCoordination, transport);
}
// Chain lifetime is owned by the transport; do not double-end on close.
chainConnections = [];
} catch (regErr) {
if (pendingDialCoordination) {
failTransportDial(pendingDialCoordination, regErr);
}
console.warn(
`[SFTP] Failed to register dedicated transport for ${connId}:`,
regErr?.message || String(regErr),
);
}
}
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed (legacy path
// only — transport-managed connections park chain on the registry).
if (chainConnections.length > 0 && !client.__netcattyTransportManaged) {
jumpConnectionsMap.set(connId, {
connections: chainConnections,
socket: connectionSocket
});
}
console.log(`[SFTP] Connection established: ${connId} (protocol=${client.__netcattyFileProtocol})`);
return { sftpId: connId, fileProtocol: client.__netcattyFileProtocol };
} catch (err) {
// Cleanup jump connections on error
cleanupPendingConnection();
closeFreshClient();
if (shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err)) {
console.log("[SFTP] Password auth removed keyboard-interactive; retrying with keyboard-interactive first...");
return openSftp(event, {
...options,
_skipPasswordMethod: true,
_pendingDialCoordination: pendingDialCoordination,
});
}
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err);
throw err;
}
}
}
return { connectThroughChainForSftp, connectSudoSftp, openSftp };
}
}
module.exports = {
createOpenConnectionApi,
createBoundedProbeSignal,
shouldRegisterFreshSftpTransport,
SCP_PROBE_TIMEOUT_MS,
};