Files
Netcatty/electron/bridges/sftpBridge.cjs
Eric Chan c771979178 Add Skills + CLI mode for external agents (#599)
* Add Skills + CLI external agent workflow

* feat: add Skills + CLI transport for ACP agents

* chore: remove branch-local compatibility shims
2026-04-10 18:41:53 +08:00

2446 lines
84 KiB
JavaScript

/**
* SFTP Bridge - Handles SFTP connections and file operations
* Extracted from main.cjs for single responsibility
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { pipeline } = require("node:stream/promises");
const { TextDecoder } = require("node:util");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
const iconv = require("iconv-lite");
let SFTPWrapper;
try {
// Try to load SFTPWrapper from ssh2 internals for sudo support
const sftpModule = require("ssh2/lib/protocol/SFTP");
SFTPWrapper = sftpModule.SFTP || sftpModule;
} catch (e) {
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
}
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
isKeyEncrypted,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getAvailableAgentSocket,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
let electronModule = null;
let sessions = null;
// Storage for jump host connections that need to be cleaned up
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
// Storage for active SFTP uploads that can be cancelled
const activeSftpUploads = new Map(); // transferId -> { cancelled: boolean, stream: Readable }
// Track requested/resolved filename encoding per SFTP session
const sftpEncodingState = new Map(); // stateKey -> { requested: 'auto'|'utf-8'|'gb18030', resolved: 'utf-8'|'gb18030' }
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const cloneEncodingState = (value) => (
value && typeof value === "object"
? { requested: value.requested || "auto", resolved: value.resolved || "utf-8" }
: null
);
function copySftpEncodingState(sourceKey, targetKey) {
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
const state = cloneEncodingState(sftpEncodingState.get(sourceKey));
if (state) {
sftpEncodingState.set(targetKey, state);
} else {
sftpEncodingState.delete(targetKey);
}
}
function clearSftpEncodingState(stateKey) {
if (!stateKey) return;
sftpEncodingState.delete(stateKey);
}
function clearSftpEncodingStateByPrefix(prefix) {
if (!prefix) return;
for (const key of sftpEncodingState.keys()) {
if (key.startsWith(prefix)) {
sftpEncodingState.delete(key);
}
}
}
const normalizeEncoding = (encoding) => {
if (!encoding) return "auto";
const normalized = String(encoding).toLowerCase();
if (normalized === "utf8") return "utf-8";
return normalized;
};
const isValidUtf8 = (buffer) => {
try {
utf8Decoder.decode(buffer);
return true;
} catch {
return false;
}
};
const detectEncodingFromList = (items) => {
// Return null if we can't definitively detect encoding (empty list or all valid UTF-8)
// This allows the caller to preserve the previous encoding instead of defaulting to UTF-8
if (!items || items.length === 0) {
return null;
}
for (const item of items) {
const raw = item?.filenameRaw || (item?.filename ? Buffer.from(item.filename, "utf8") : null);
if (raw && !isValidUtf8(raw)) {
return "gb18030";
}
}
// All filenames are valid UTF-8, but we can't prove they're not GB18030-encoded ASCII
// Return null to preserve previous encoding rather than forcing UTF-8
return null;
};
const resolveEncodingForRequest = (sftpId, requestedEncoding) => {
const requested = normalizeEncoding(requestedEncoding);
if (requested && requested !== "auto") {
sftpEncodingState.set(sftpId, { requested, resolved: requested });
return requested;
}
const existing = sftpEncodingState.get(sftpId);
const resolved = existing?.resolved || "utf-8";
sftpEncodingState.set(sftpId, { requested: "auto", resolved });
return resolved;
};
const updateResolvedEncoding = (sftpId, requestedEncoding, resolvedEncoding) => {
const requested = normalizeEncoding(requestedEncoding);
const resolved = normalizeEncoding(resolvedEncoding);
const finalResolved = resolved === "auto" ? "utf-8" : resolved;
sftpEncodingState.set(sftpId, {
requested: requested || "auto",
resolved: finalResolved,
});
return finalResolved;
};
const isAsciiString = (value) =>
typeof value === "string" && /^[\x00-\x7F]*$/.test(value);
const encodePath = (input, encoding) => {
if (input === undefined || input === null) return input;
if (Buffer.isBuffer(input)) return input;
if (encoding === "utf-8") return input;
// Avoid Buffer paths when ASCII-only; keeps compatibility with unpatched ssh2
if (isAsciiString(input)) return input;
return iconv.encode(input, encoding);
};
const decodeName = (raw, encoding) => {
if (!raw) return "";
if (Buffer.isBuffer(raw)) {
return encoding === "utf-8" ? raw.toString("utf8") : iconv.decode(raw, encoding);
}
return raw;
};
const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
if (!sftpId) return inputPath;
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
return encodePath(inputPath, encoding);
};
const hasSftpChannelApi = (value) =>
!!value &&
typeof value.readdir === "function" &&
typeof value.stat === "function" &&
typeof value.mkdir === "function" &&
typeof value.unlink === "function";
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
function createAbortError(signal, fallbackMessage = "The operation was aborted.") {
const reason = signal?.reason;
if (reason instanceof Error) {
return reason;
}
if (typeof reason === "string" && reason) {
return new Error(reason);
}
return new Error(fallbackMessage);
}
const tryOpenSftpChannel = (client, options = {}) =>
new Promise((resolve, reject) => {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") {
resolve(null);
return;
}
const signal = options?.signal || null;
const timeoutMs = Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0
? options.timeoutMs
: DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS;
if (signal?.aborted) {
reject(createAbortError(signal, "SFTP channel open was aborted"));
return;
}
let settled = false;
let timer = null;
const cleanup = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
};
const closeOrphanedChannel = (sftp) => {
try { sftp?.end?.(); } catch {}
try { sftp?.close?.(); } catch {}
};
const finishReject = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const finishResolve = (sftp) => {
if (settled) {
closeOrphanedChannel(sftp);
return;
}
settled = true;
cleanup();
resolve(sftp || null);
};
const onAbort = () => {
finishReject(createAbortError(signal, "SFTP channel open was aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
if (timeoutMs) {
timer = setTimeout(() => {
finishReject(new Error(`SFTP channel open timed out after ${timeoutMs}ms`));
}, timeoutMs);
}
try {
sshClient.sftp((err, sftp) => {
if (err) {
finishReject(err);
return;
}
finishResolve(sftp);
});
} catch (err) {
finishReject(err);
}
});
const getSftpChannel = async (client, options = {}) => {
if (!client) return null;
if (hasSftpChannelApi(client.sftp)) {
return client.sftp;
}
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
// Reopening with sshClient.sftp() would silently downgrade permissions.
if (client.__netcattySudoMode) {
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
return null;
}
// Do not treat ssh2's "client.sftp" method as a channel object.
// Re-open a fresh channel when the cached channel is stale.
if (!client.client || typeof client.client.sftp !== "function") {
return null;
}
// Deduplicate per-client: avoid concurrent channel re-open attempts
if (client._reopeningPromise) {
try {
return await client._reopeningPromise;
} catch {
return null;
}
}
client._reopeningPromise = (async () => {
try {
const reopened = await tryOpenSftpChannel(client, options);
if (hasSftpChannelApi(reopened)) {
client.sftp = reopened;
return reopened;
}
} catch (err) {
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
}
return null;
})();
try {
return await client._reopeningPromise;
} finally {
client._reopeningPromise = null;
}
};
const requireSftpChannel = async (client, options = {}) => {
const sftp = await getSftpChannel(client, options);
if (!sftp) {
throw new Error("SFTP session lost. Please reconnect.");
}
return sftp;
};
const realpathAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.realpath(targetPath, (err, absPath) => (err ? reject(err) : resolve(absPath)));
});
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.stat(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
});
const readdirAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.readdir(targetPath, (err, items) => (err ? reject(err) : resolve(items || [])));
});
const mkdirAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.mkdir(targetPath, (err) => (err ? reject(err) : resolve()));
});
const rmdirAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.rmdir(targetPath, (err) => (err ? reject(err) : resolve()));
});
const unlinkAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.unlink(targetPath, (err) => (err ? reject(err) : resolve()));
});
const openFileAsync = (sftp, targetPath, flags = "w") =>
new Promise((resolve, reject) => {
sftp.open(targetPath, flags, (err, handle) => (err ? reject(err) : resolve(handle)));
});
const writeFileChunkAsync = (sftp, handle, buffer, offset, length, position) =>
new Promise((resolve, reject) => {
sftp.write(handle, buffer, offset, length, position, (err) => (err ? reject(err) : resolve()));
});
const closeFileAsync = (sftp, handle) =>
new Promise((resolve, reject) => {
sftp.close(handle, (err) => (err ? reject(err) : resolve()));
});
const normalizeRemotePathString = async (client, inputPath) => {
if (typeof inputPath !== "string") return inputPath;
if (inputPath.startsWith("..")) {
const root = await client.realPath("..");
return `${root}/${inputPath.slice(3)}`;
}
if (inputPath.startsWith(".")) {
const root = await client.realPath(".");
return `${root}/${inputPath.slice(2)}`;
}
return inputPath;
};
const isWindowsRemotePath = (dirPath) => /^[A-Za-z]:[\\/]/.test(dirPath) || /^[A-Za-z]:$/.test(dirPath);
const normalizeRemoteDirPath = (dirPath) => {
if (isWindowsRemotePath(dirPath)) {
const normalized = dirPath.replace(/\//g, "\\").replace(/\\+/g, "\\");
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
}
return path.posix.normalize(dirPath);
};
const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
if (!dirPath || dirPath === ".") return;
const normalized = normalizeRemoteDirPath(dirPath);
if (!normalized || normalized === ".") return;
// Optimization: Check if the full path already exists to avoid O(N) round trips
// This is the common case (e.g. uploading multiple files to the same directory)
const encodedFull = encodePath(normalized, encoding);
try {
const stats = await statAsync(sftp, encodedFull);
if (stats.isDirectory()) {
return;
}
} catch (err) {
// If path doesn't exist or other error, proceed to recursive check
}
const isWindowsPath = isWindowsRemotePath(normalized);
const isAbsolute = normalized.startsWith("/");
const parts = isWindowsPath
? normalized.slice(2).replace(/^[\\]+/, "").split(/[\\]+/).filter(Boolean)
: normalized.split("/").filter(Boolean);
let current = isWindowsPath
? `${normalized.slice(0, 2)}\\`
: (isAbsolute ? "/" : "");
for (const part of parts) {
if (isWindowsPath) {
const base = current.replace(/[\\]+$/, "");
current = `${base}\\${part}`;
} else {
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
}
const encodedCurrent = encodePath(current, encoding);
try {
const stats = await statAsync(sftp, encodedCurrent);
if (!stats.isDirectory()) {
throw new Error(`Remote path is not a directory: ${current}`);
}
} catch (err) {
if (err && (err.code === 2 || err.code === 4)) {
await mkdirAsync(sftp, encodedCurrent);
continue;
}
throw err;
}
}
};
const removeRemotePathInternal = async (sftp, targetPath, encoding, signal = null) => {
throwIfAborted(signal);
const encodedTarget = encodePath(targetPath, encoding);
let stats;
try {
stats = await statAsync(sftp, encodedTarget);
} catch (err) {
if (err && err.code === 2) return;
throw err;
}
throwIfAborted(signal);
if (stats.isDirectory()) {
throwIfAborted(signal);
const items = await readdirAsync(sftp, encodedTarget);
throwIfAborted(signal);
for (const item of items) {
throwIfAborted(signal);
const rawName =
item?.filenameRaw ||
(item?.filename ? Buffer.from(item.filename, "utf8") : null);
const name = decodeName(rawName, encoding);
if (!name || name === "." || name === "..") continue;
const childPath = path.posix.join(targetPath, name);
await removeRemotePathInternal(sftp, childPath, encoding, signal);
throwIfAborted(signal);
}
throwIfAborted(signal);
await rmdirAsync(sftp, encodedTarget);
} else {
throwIfAborted(signal);
await unlinkAsync(sftp, encodedTarget);
}
throwIfAborted(signal);
};
const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) => {
const client = sftpClients.get(sftpId);
if (!client) throw new Error("SFTP session not found");
if (!dirPath || dirPath === ".") return true;
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
const sftp = await requireSftpChannel(client);
// Always walk the path segment-by-segment. This lets sftp.stat() follow
// symlinked directory segments before deciding whether the next mkdir is
// valid, which avoids recursive mkdir failures on paths like /link/subdir.
const normalizedPath = await normalizeRemotePathString(client, dirPath);
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
return true;
};
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*/
function buildSftpAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Initialize the SFTP bridge with dependencies
*/
function init(deps) {
sftpClients = deps.sftpClients;
electronModule = deps.electronModule;
sessions = deps.sessions;
}
function ensureRemoteSftpSupport(sessionId) {
const session = sessions?.get(sessionId);
if (!session) {
throw new Error(`Session "${sessionId}" not found`);
}
const sshClient = session.conn || session.sshClient;
if (!sshClient || typeof sshClient.sftp !== "function") {
throw new Error("SFTP is only supported for SSH sessions with an active SSH connection.");
}
return { session, sshClient };
}
function buildStagedRemotePath(remotePath) {
const isWindowsPath = isWindowsRemotePath(remotePath);
const lastSeparatorIndex = Math.max(remotePath.lastIndexOf("/"), remotePath.lastIndexOf("\\"));
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
const safeBaseName = baseName || "upload";
const stagedName = `.netcatty-upload-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.part`;
return dir ? `${dir}${stagedName}` : stagedName;
}
function buildBackupRemotePath(remotePath) {
const lastSeparatorIndex = Math.max(remotePath.lastIndexOf("/"), remotePath.lastIndexOf("\\"));
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
const safeBaseName = baseName || "upload";
const backupName = `.netcatty-backup-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.bak`;
return dir ? `${dir}${backupName}` : backupName;
}
const posixRenameAsync = (sftp, fromPath, toPath) =>
new Promise((resolve, reject) => {
if (typeof sftp?.ext_openssh_rename !== "function") {
reject(new Error("POSIX rename is not supported by this SFTP channel."));
return;
}
sftp.ext_openssh_rename(fromPath, toPath, (err) => (err ? reject(err) : resolve()));
});
async function renameRemotePath(client, fromPath, toPath, backupPath = null) {
const sftp = await requireSftpChannel(client);
if (typeof sftp?.ext_openssh_rename === "function") {
try {
await posixRenameAsync(sftp, fromPath, toPath);
return;
} catch {
// Fall back to plain rename when the OpenSSH extension is unavailable or rejected.
}
}
try {
await client.rename(fromPath, toPath);
return;
} catch (renameErr) {
if (!backupPath) throw renameErr;
const destinationStat = await client.stat(toPath)
.then((stat) => stat || null)
.catch(() => false);
if (!destinationStat || destinationStat.isDirectory) {
throw renameErr;
}
let movedExistingTarget = false;
try {
await client.rename(toPath, backupPath);
movedExistingTarget = true;
await client.rename(fromPath, toPath);
} catch (fallbackErr) {
if (movedExistingTarget) {
try {
await client.rename(backupPath, toPath);
} catch {
// Ignore restore failures and surface the original fallback error.
}
}
throw fallbackErr;
}
if (movedExistingTarget) {
try {
await client.delete(backupPath);
} catch {
// Ignore backup cleanup failures after the final file is in place.
}
}
}
}
function collectReadable(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
stream.once("error", reject);
stream.once("end", () => resolve(Buffer.concat(chunks)));
});
}
function writeToWritable(stream, content) {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
stream.removeListener("error", onError);
stream.removeListener("finish", onSuccess);
stream.removeListener("close", onSuccess);
};
const onError = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onSuccess = () => {
if (settled) return;
settled = true;
cleanup();
resolve();
};
stream.once("error", onError);
stream.once("finish", onSuccess);
stream.once("close", onSuccess);
stream.end(content);
});
}
function throwIfAborted(signal) {
if (!signal?.aborted) return;
const reason = signal.reason;
if (reason instanceof Error) {
throw reason;
}
if (typeof reason === "string" && reason) {
throw new Error(reason);
}
throw new Error("The operation was aborted.");
}
async function pipeStreams(source, destination, signal = null) {
if (signal) {
return await pipeline(source, destination, { signal });
}
return await pipeline(source, destination);
}
function statResultFromAttrs(attrs) {
const mode = attrs?.mode || 0;
const fileTypeMask = mode & 0o170000;
return {
size: attrs?.size || 0,
modifyTime: (attrs?.mtime || 0) * 1000,
mode,
isDirectory: typeof attrs?.isDirectory === "function"
? attrs.isDirectory()
: fileTypeMask === 0o040000,
isSymbolicLink: typeof attrs?.isSymbolicLink === "function"
? attrs.isSymbolicLink()
: fileTypeMask === 0o120000,
};
}
function createSessionBackedSftpClient(sessionId, sshClient) {
const client = {
client: sshClient,
sftp: null,
__netcattySessionBacked: true,
_reopeningPromise: null,
async get(remotePath) {
const sftp = await requireSftpChannel(client);
const stream = sftp.createReadStream(remotePath);
return await collectReadable(stream);
},
async put(content, remotePath, options = {}) {
const sftp = await requireSftpChannel(client);
const signal = options?.signal || null;
throwIfAborted(signal);
if (content && typeof content.pipe === "function") {
const stream = sftp.createWriteStream(remotePath);
await pipeStreams(content, stream, signal);
return true;
}
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
const handle = await openFileAsync(sftp, remotePath, "w");
try {
let offset = 0;
while (offset < buffer.length) {
throwIfAborted(signal);
const length = Math.min(256 * 1024, buffer.length - offset);
await writeFileChunkAsync(sftp, handle, buffer, offset, length, offset);
offset += length;
}
} finally {
await closeFileAsync(sftp, handle);
}
return true;
},
async stat(remotePath) {
const sftp = await requireSftpChannel(client);
const attrs = await statAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async realPath(remotePath) {
const sftp = await requireSftpChannel(client);
return await realpathAsync(sftp, remotePath);
},
async rename(oldPath, newPath) {
const sftp = await requireSftpChannel(client);
await new Promise((resolve, reject) => {
sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve()));
});
},
async delete(remotePath, options = {}) {
const signal = options?.signal || null;
throwIfAborted(signal);
const sftp = await requireSftpChannel(client, { signal });
throwIfAborted(signal);
await unlinkAsync(sftp, remotePath);
throwIfAborted(signal);
},
async rmdir(remotePath, recursive = false, options = {}) {
const signal = options?.signal || null;
throwIfAborted(signal);
const sftp = await requireSftpChannel(client, { signal });
if (recursive) {
const normalized = await normalizeRemotePathString(client, remotePath);
throwIfAborted(signal);
await removeRemotePathInternal(sftp, normalized, "utf-8", signal);
return;
}
throwIfAborted(signal);
await rmdirAsync(sftp, remotePath);
throwIfAborted(signal);
},
async chmod(remotePath, mode) {
const sftp = await requireSftpChannel(client);
await new Promise((resolve, reject) => {
if (typeof sftp.chmod === "function") {
sftp.chmod(remotePath, mode, (err) => (err ? reject(err) : resolve()));
return;
}
sftp.setstat(remotePath, { mode }, (err) => (err ? reject(err) : resolve()));
});
},
async end() {
try {
if (client.sftp && typeof client.sftp.end === "function") {
client.sftp.end();
} else if (client.sftp && typeof client.sftp.close === "function") {
client.sftp.close();
}
} catch {
// Ignore channel close failures for session-backed clients.
} finally {
client.sftp = null;
}
},
};
return client;
}
async function openSftpForSession(_event, payload) {
const { sessionId } = payload || {};
if (!sessionId) throw new Error("sessionId is required");
throwIfAborted(payload?.abortSignal);
const { sshClient } = ensureRemoteSftpSupport(sessionId);
const sftpId = `${sessionId}-sftp-${Math.random().toString(16).slice(2, 10)}`;
const client = createSessionBackedSftpClient(sessionId, sshClient);
try {
await requireSftpChannel(client, {
signal: payload?.abortSignal,
timeoutMs: payload?.timeoutMs,
});
throwIfAborted(payload?.abortSignal);
copySftpEncodingState(payload?.encodingStateKey, sftpId);
sftpClients.set(sftpId, client);
return { ok: true, sftpId };
} catch (err) {
try {
await client.end();
} catch {
// Ignore cleanup failures while discarding a one-off session-backed handle.
}
throw err;
}
}
async function downloadSftpToLocal(_event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const sftp = await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.remotePath, encoding);
const stagedFilePath = tempDirBridge.getTempFilePath(path.basename(payload.localPath || payload.remotePath || "download"));
throwIfAborted(payload.abortSignal);
const readStream = sftp.createReadStream(encodedPath);
const writeStream = fs.createWriteStream(stagedFilePath);
try {
await pipeStreams(readStream, writeStream, payload.abortSignal);
throwIfAborted(payload.abortSignal);
try {
await fs.promises.rename(stagedFilePath, payload.localPath);
} catch (err) {
if (err?.code !== "EXDEV" && err?.code !== "EEXIST" && err?.code !== "EPERM") {
throw err;
}
await fs.promises.copyFile(stagedFilePath, payload.localPath);
await fs.promises.unlink(stagedFilePath);
}
} catch (err) {
try {
await fs.promises.unlink(stagedFilePath);
} catch {
// Ignore temp-file cleanup failures after a cancelled or failed download.
}
throw err;
}
return { success: true, localPath: payload.localPath };
}
async function uploadLocalToSftp(_event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const stagedRemotePath = buildStagedRemotePath(payload.remotePath);
const backupRemotePath = buildBackupRemotePath(payload.remotePath);
const encodedPath = encodePath(payload.remotePath, encoding);
const encodedStagedPath = encodePath(stagedRemotePath, encoding);
const encodedBackupPath = encodePath(backupRemotePath, encoding);
throwIfAborted(payload.abortSignal);
const content = fs.createReadStream(payload.localPath);
try {
await client.put(content, encodedStagedPath, { signal: payload.abortSignal });
throwIfAborted(payload.abortSignal);
await renameRemotePath(client, encodedStagedPath, encodedPath, encodedBackupPath);
} catch (err) {
try {
await client.delete(encodedStagedPath);
} catch {
// Ignore best-effort cleanup failures for partially uploaded temp files.
}
throw err;
}
return { success: true, remotePath: payload.remotePath };
}
/**
* Send SFTP connection progress to the renderer for user-visible logging
*/
function sendSftpProgress(sender, sessionId, label, status, detail) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send("netcatty:sftp:connection-progress", { sessionId, label, status, detail });
} catch {
// Ignore destroyed webContents
}
}
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
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();
// Increase max listeners to prevent Node.js warning
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
keepaliveInterval: 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
let authAgent = null;
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: jump.keyId || jump.username || "",
certificate: jump.certificate,
privateKey: jump.privateKey,
passphrase: jump.passphrase,
},
});
connOpts.agent = authAgent;
} else if (jump.privateKey) {
connOpts.privateKey = jump.privateKey;
if (jump.passphrase) {
connOpts.passphrase = jump.passphrase;
} else if (isKeyEncrypted(jump.privateKey)) {
// 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}`);
}
}
}
}
// Read identity files from local paths (e.g. from SSH config IdentityFile)
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
for (const keyPath of jump.identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
console.log(`[SFTP Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
path.basename(resolvedPath),
hopLabel
);
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
delete connOpts.privateKey;
continue;
}
}
console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
break;
} catch (err) {
console.warn(`[SFTP Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
}
}
}
if (jump.password) connOpts.password = jump.password;
// Get default keys (either from options if pre-fetched, or fetch them now)
const defaultKeys = 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({
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: agentSocket,
onAuthAttempt: (method) => {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
if (effectiveHopProxy) {
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22);
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) => {
conn.once('handshake', () => {
sendSftpProgress(sender, connId, hopLabel, 'authenticating');
});
conn.once('ready', () => {
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;
}
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', () => {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
const sftpChainKiHandler = createKeyboardInteractiveHandler({
sender,
sessionId: connId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
});
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);
// Determine next target
let nextHost, nextPort;
if (isLast) {
// Last jump host, forward to final target
nextHost = targetHost;
nextPort = targetPort;
} else {
// Forward to next jump host
const nextJump = jumpHosts[i + 1];
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// 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) => {
conn.forwardOut('127.0.0.1', 0, nextHost, nextPort, (err, stream) => {
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
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 {
await new Promise((resolve, reject) => {
client.exec(`test -x ${p}`, (err, stream) => {
if (err) return reject(err);
stream.on('exit', (code) => {
if (code === 0) resolve();
else reject(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
client.exec(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;
const timeoutMs = 20000;
const timeoutId = setTimeout(() => {
if (sftpInitialized || settled) return;
settled = true;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
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);
const finalize = (err, result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
if (err) 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
setTimeout(() => {
initSftp();
}, 1000);
} else if (stdoutBuffer.length > 256) {
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
}
};
const onStderr = (data) => {
const chunk = data.toString();
// Only log that we received stderr data, not the content (may contain sensitive prompts)
stderrBuffer += chunk;
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);
}
});
});
});
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
*/
async function openSftp(event, options) {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
// 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 = !!options.proxy;
let chainConnections = [];
let connectionSocket = null;
// 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();
// 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,
agentSocket
);
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
);
}
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
// Keep SFTP sessions alive while the panel is idle. Without SSH-level
// keepalive packets the connection sits with zero data flow while the
// user is just browsing files, and NAT/firewall state tables drop the
// idle TCP connection after ~30-60s (the exact symptom of #669).
// Honor an explicitly configured positive keepaliveInterval (seconds);
// otherwise default to 10s, matching the SFTP jump host path below.
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// 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 authAgent = null;
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: options.keyId || options.username || "",
certificate: options.certificate,
privateKey: options.privateKey,
passphrase: options.passphrase,
},
});
connectOpts.agent = authAgent;
} else if (options.privateKey) {
connectOpts.privateKey = options.privateKey;
if (options.passphrase) {
connectOpts.passphrase = options.passphrase;
} else if (isKeyEncrypted(options.privateKey)) {
// 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;
}
}
}
}
// Read identity files from local paths (e.g. from SSH config IdentityFile)
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
for (const keyPath of options.identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connectOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
console.log(`[SFTP] Identity file ${resolvedPath} is encrypted, requesting passphrase`);
const result = await passphraseHandler.requestPassphrase(
event.sender,
resolvedPath,
path.basename(resolvedPath),
options.hostname
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
delete connectOpts.privateKey;
continue;
}
}
console.log(`[SFTP] Loaded identity file ${resolvedPath}`);
break;
} catch (err) {
console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message);
}
}
}
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({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SFTP]",
defaultKeys,
sshAgentSocketOverride: agentSocket,
onAuthAttempt: (method) => {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connectOpts, authConfig);
// Create keyboard-interactive handler using shared helper
const kiHandler = createKeyboardInteractiveHandler({
sender: event.sender,
sessionId: connId,
hostname: options.hostname,
password: options.password,
logPrefix: "[SFTP]",
});
// Add keyboard-interactive listener BEFORE connecting
// Wrap to emit progress events for the SFTP connection log
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);
});
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
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;
const settle = (fn, val) => {
if (settled) return;
settled = true;
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);
// 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);
sshClient.once('handshake', () => {
sendSftpProgress(event.sender, connId, options.hostname, 'authenticating');
});
sshClient.once('ready', () => {
cleanup();
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
(async () => {
try {
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
client.sftp = sftpWrapper;
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
// Fallback: if sftp-server binary is missing (exit code 127),
// try standard SFTP subsystem instead of failing completely.
// This handles systems like ESXi that don't have sftp-server
// but support the SFTP subsystem natively.
if (e.message && e.message.includes('exit code 127')) {
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
options.sudo = false; // Mark as non-sudo for downstream logic
sshClient.sftp((sftpErr, sftp) => {
if (sftpErr) {
sshClient.end();
return reject(sftpErr);
}
client.sftp = sftp;
resolve();
});
} else {
sshClient.end();
reject(e);
}
}
})();
} else {
// Open standard SFTP subsystem channel
sshClient.sftp((err, sftp) => {
if (err) return reject(err);
client.sftp = sftp;
resolve();
});
}
});
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;
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed
if (chainConnections.length > 0) {
jumpConnectionsMap.set(connId, {
connections: chainConnections,
socket: connectionSocket
});
}
console.log(`[SFTP] Connection established: ${connId}`);
return { sftpId: connId };
} catch (err) {
// Cleanup jump connections on error
for (const conn of chainConnections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on connect failure:', cleanupErr.message); }
}
throw err;
}
}
/**
* List files in a directory
* Properly handles symlinks by resolving their target type
*/
async function listSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const requestedEncoding = normalizeEncoding(payload.encoding);
const basePath = payload.path || ".";
const pathEncoding = resolveEncodingForRequest(payload.sftpId, requestedEncoding);
const encodedPath = encodePath(basePath, pathEncoding);
const sftp = await requireSftpChannel(client);
let list;
try {
list = await new Promise((resolve, reject) => {
sftp.readdir(encodedPath, (err, items) => {
if (err) return reject(err);
resolve(items || []);
});
});
} catch (err) {
// Retry with string path when ASCII-only and a Buffer path caused issues
if (Buffer.isBuffer(encodedPath) && isAsciiString(basePath)) {
console.warn("[SFTP] Retrying readdir with string path after Buffer failure", {
basePath,
error: err?.message || String(err),
});
list = await new Promise((resolve, reject) => {
sftp.readdir(basePath, (retryErr, items) => {
if (retryErr) return reject(retryErr);
resolve(items || []);
});
});
} else {
throw err;
}
}
// When auto mode, try to detect encoding from list
// If detection returns null (empty list or can't prove non-UTF-8), preserve the previous encoding
let detectedEncoding;
if (requestedEncoding === "auto") {
const detected = detectEncodingFromList(list);
if (detected) {
// Definitive detection (e.g., found GB18030 bytes)
detectedEncoding = detected;
} else {
// Can't detect - preserve existing session encoding
const existing = sftpEncodingState.get(payload.sftpId);
detectedEncoding = existing?.resolved || "utf-8";
}
} else {
detectedEncoding = requestedEncoding;
}
const resolvedEncoding = updateResolvedEncoding(payload.sftpId, requestedEncoding, detectedEncoding);
// Process items and resolve symlinks
const results = await Promise.all(list.map(async (item) => {
const filenameRaw = item.filenameRaw || (item.filename ? Buffer.from(item.filename, "utf8") : null);
const longnameRaw = item.longnameRaw || (item.longname ? Buffer.from(item.longname, "utf8") : null);
const name = decodeName(filenameRaw, resolvedEncoding) || item.filename || "";
const longname = decodeName(longnameRaw, resolvedEncoding) || item.longname || "";
let type;
let linkTarget = null;
if (item.attrs?.isDirectory?.()) {
type = "directory";
} else if (item.attrs?.isSymbolicLink?.()) {
// This is a symlink - try to resolve its target type
type = "symlink";
try {
// Use path.posix.join to properly construct the path and avoid double slashes
const fullPath = path.posix.join(basePath === "." ? "/" : basePath, name);
const encodedFullPath = encodePath(fullPath, resolvedEncoding);
const stat = await client.stat(encodedFullPath);
// stat follows symlinks, so we get the target's type
if (stat.isDirectory) {
linkTarget = "directory";
} else {
linkTarget = "file";
}
} catch (err) {
// If we can't stat the symlink target (broken link), keep it as symlink
console.warn(`Could not resolve symlink target for ${item.name}:`, err.message);
}
} else {
type = "file";
}
const modeToPermissions = (mode) => {
if (typeof mode !== "number") return undefined;
const toTriplet = (bits) =>
`${bits & 4 ? "r" : "-"}${bits & 2 ? "w" : "-"}${bits & 1 ? "x" : "-"}`;
return `${toTriplet((mode >> 6) & 7)}${toTriplet((mode >> 3) & 7)}${toTriplet(mode & 7)}`;
};
// Extract permissions from longname or attrs.mode
let permissions = undefined;
if (longname) {
// Fallback: parse from longname (e.g., "-rwxr-xr-x 1 root root ...")
const match = longname.match(/^[dlsbc-]([rwxsStT-]{9})/);
if (match) {
permissions = match[1];
}
}
if (!permissions && item.attrs?.mode) {
permissions = modeToPermissions(item.attrs.mode);
}
const modifyTime = item.attrs?.mtime ? item.attrs.mtime * 1000 : Date.now();
return {
name,
type,
linkTarget,
size: `${item.attrs?.size || 0} bytes`,
lastModified: new Date(modifyTime).toISOString(),
permissions,
};
}));
return results;
}
/**
* Read file content
*/
async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const buffer = await client.get(encodedPath);
return buffer.toString();
}
/**
* Read file as binary (returns ArrayBuffer for binary files like images)
*/
async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const buffer = await client.get(encodedPath);
// Convert Node.js Buffer to ArrayBuffer
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Write file content.
*
* If the target file already exists, its mode is preserved — ssh2-sftp-client's
* `put()` otherwise overwrites existing files with the server's default mode
* (typically 0o666 after umask), which would silently change permissions on
* files edited through the built-in text editor.
*/
async function writeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
let existingMode = null;
try {
const stat = await client.stat(encodedPath);
if (typeof stat.mode === "number") {
// Mask with 0o7777 so special bits (setuid/setgid/sticky) are preserved too.
existingMode = stat.mode & 0o7777;
}
} catch (_err) {
// File does not exist — treat as a new file and let the server apply defaults.
}
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
if (existingMode !== null) {
try {
await client.chmod(encodedPath, existingMode);
} catch (err) {
console.warn(
`[sftp] Failed to restore permissions on ${payload.path}:`,
err && err.message ? err.message : err,
);
}
}
return true;
}
/**
* Write binary data
*/
async function writeSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.put(Buffer.from(payload.content), encodedPath);
return true;
}
/**
* Write binary data with progress callback
* Supports cancellation via activeSftpUploads map
* Optimized for performance with throttled progress updates
*/
async function writeSftpBinaryWithProgress(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const { sftpId, path: remotePath, content, transferId } = payload;
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(remotePath, encoding);
// Extract callback functions from payload
const onProgress = payload.onProgress;
const onComplete = payload.onComplete;
const onError = payload.onError;
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
const totalBytes = buffer.length;
let transferredBytes = 0;
let lastProgressTime = Date.now();
let lastTransferredBytes = 0;
let lastProgressSentTime = 0;
// Throttle settings: send progress at most every 100ms or every 1MB
const PROGRESS_THROTTLE_MS = 100;
const PROGRESS_THROTTLE_BYTES = 1024 * 1024; // 1MB
let lastProgressSentBytes = 0;
const { Readable } = require("stream");
const readableStream = new Readable({
read() {
// Check for cancellation
const uploadState = activeSftpUploads.get(transferId);
if (uploadState?.cancelled) {
this.destroy(new Error("Upload cancelled"));
return;
}
// Use larger chunk size for better performance (256KB instead of 64KB)
const chunkSize = 262144;
if (transferredBytes < totalBytes) {
const end = Math.min(transferredBytes + chunkSize, totalBytes);
// Use subarray instead of slice to avoid copying
const chunk = buffer.subarray(transferredBytes, end);
transferredBytes = end;
const now = Date.now();
const elapsed = (now - lastProgressTime) / 1000;
let speed = 0;
if (elapsed >= 0.1) {
speed = (transferredBytes - lastTransferredBytes) / elapsed;
lastProgressTime = now;
lastTransferredBytes = transferredBytes;
}
// Throttle IPC progress events: only send if enough time or bytes have passed
const timeSinceLastProgress = now - lastProgressSentTime;
const bytesSinceLastProgress = transferredBytes - lastProgressSentBytes;
const isComplete = transferredBytes >= totalBytes;
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
// Call the progress callback if provided, otherwise send IPC event
if (typeof onProgress === 'function') {
try {
onProgress(transferredBytes, totalBytes, speed);
} catch (err) {
console.warn('[SFTP] Progress callback error:', err);
}
} else {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
transferred: transferredBytes,
totalBytes,
speed,
});
}
lastProgressSentTime = now;
lastProgressSentBytes = transferredBytes;
}
this.push(chunk);
} else {
this.push(null);
}
}
});
// Register this upload for potential cancellation
activeSftpUploads.set(transferId, { cancelled: false, stream: readableStream });
try {
await client.put(readableStream, encodedPath);
// Call the complete callback if provided, otherwise send IPC event
if (typeof onComplete === 'function') {
try {
onComplete();
} catch (err) {
console.warn('[SFTP] Complete callback error:', err);
}
} else {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:complete", { transferId });
}
return { success: true, transferId };
} catch (err) {
// Check if this upload was cancelled - the error might not be exactly "Upload cancelled"
// when stream is destroyed, SFTP server may return different errors like "Write stream error"
const uploadState = activeSftpUploads.get(transferId);
if (uploadState?.cancelled || err.message === "Upload cancelled") {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:cancelled", { transferId });
return { success: false, transferId, cancelled: true };
}
// Call the error callback if provided, otherwise send IPC event
if (typeof onError === 'function') {
try {
onError(err.message);
} catch (callbackErr) {
console.warn('[SFTP] Error callback error:', callbackErr);
}
} else {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:error", { transferId, error: err.message });
}
throw err;
} finally {
// Cleanup
activeSftpUploads.delete(transferId);
}
}
/**
* Cancel an in-progress SFTP upload
* Note: We only set the cancelled flag and destroy the stream here.
* The cleanup (deleting from activeSftpUploads) is handled by writeSftpBinaryWithProgress's finally block
* to avoid race conditions.
*/
async function cancelSftpUpload(event, payload) {
const { transferId } = payload;
const uploadState = activeSftpUploads.get(transferId);
if (uploadState) {
uploadState.cancelled = true;
try {
uploadState.stream?.destroy();
} catch (err) {
// Log but continue - stream may already be destroyed
console.warn("[SFTP] Error destroying upload stream:", err.message);
}
// Don't delete here - let the finally block in writeSftpBinaryWithProgress handle cleanup
// This avoids race conditions where the upload might still be in progress
}
return { success: true };
}
/**
* Close an SFTP connection
* Also cleans up any jump host connections and file watchers if present
*/
async function closeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) return;
// Stop file watchers and clean up temp files for this SFTP session
try {
fileWatcherBridge.stopWatchersForSession(payload.sftpId, true);
} catch (err) {
console.warn("[SFTP] Error stopping file watchers:", err.message);
}
try {
await client.end();
} catch (err) {
console.warn("SFTP close failed", err);
}
copySftpEncodingState(payload?.sftpId, payload?.encodingStateKey);
sftpClients.delete(payload.sftpId);
clearSftpEncodingState(payload.sftpId);
// Clean up jump connections if any
const jumpData = jumpConnectionsMap.get(payload.sftpId);
if (jumpData) {
for (const conn of jumpData.connections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on close:', cleanupErr.message); }
}
jumpConnectionsMap.delete(payload.sftpId);
console.log(`[SFTP] Cleaned up ${jumpData.connections.length} jump connection(s) for ${payload.sftpId}`);
}
}
/**
* Create a directory
*/
async function mkdirSftp(event, payload) {
await ensureRemoteDirForSession(payload.sftpId, payload.path, payload.encoding);
return true;
}
/**
* Execute a command via SSH using the underlying ssh2 client
* Returns { stdout, stderr, code }
*/
function execSshCommand(sshClient, command) {
return new Promise((resolve, reject) => {
sshClient.exec(command, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
stream.on('close', (code) => {
resolve({ stdout, stderr, code });
});
stream.on('data', (data) => {
stdout += data.toString();
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
});
});
}
/**
* Delete a file or directory
* For directories, uses SSH exec with 'rm -rf' for much faster deletion
*/
async function deleteSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const signal = payload?.abortSignal || null;
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const shouldUseFastDirectoryDelete = (
encoding === "utf-8" &&
!client.__netcattySessionBacked &&
!signal &&
!(Number.isFinite(payload?.timeoutMs) && payload.timeoutMs > 0)
);
if (encoding === "utf-8") {
throwIfAborted(signal);
const sftp = await requireSftpChannel(client, { signal, timeoutMs: payload?.timeoutMs });
const encodedPath = encodePath(payload.path, encoding);
const stat = statResultFromAttrs(await statAsync(sftp, encodedPath));
throwIfAborted(signal);
if (stat.isDirectory) {
if (shouldUseFastDirectoryDelete) {
// Keep the SSH rm -rf fast path only for ordinary UI SFTP sessions.
// Session-backed / stop-sensitive flows must stay on the abort-aware
// recursive SFTP path so ACP Stop and command timeouts can interrupt
// large directory deletes promptly.
const sshClient = client.client;
if (sshClient && typeof sshClient.exec === 'function') {
try {
// Escape path for shell - wrap in single quotes and escape any single quotes in the path
const escapedPath = payload.path.replace(/'/g, "'\\''");
const command = `rm -rf '${escapedPath}'`;
console.log(`[SFTP] Using SSH exec for fast directory deletion: ${command}`);
const result = await execSshCommand(sshClient, command);
if (result.code !== 0) {
console.warn(`[SFTP] rm -rf returned code ${result.code}: ${result.stderr}`);
// Fall back to SFTP rmdir if rm -rf fails (e.g., permission denied)
await client.rmdir(encodedPath, true);
}
return true;
} catch (execErr) {
console.warn('[SFTP] SSH exec failed, falling back to SFTP rmdir:', execErr.message);
// Fall back to slow SFTP rmdir
await client.rmdir(encodedPath, true);
return true;
}
}
}
if (client.__netcattySessionBacked) {
await client.rmdir(encodedPath, true, { signal });
} else {
const normalizedPath = await normalizeRemotePathString(client, payload.path);
throwIfAborted(signal);
await removeRemotePathInternal(sftp, normalizedPath, encoding, signal);
throwIfAborted(signal);
}
} else {
if (client.__netcattySessionBacked) {
await client.delete(encodedPath, { signal });
} else {
throwIfAborted(signal);
await unlinkAsync(sftp, encodedPath);
throwIfAborted(signal);
}
}
return true;
}
throwIfAborted(signal);
const sftp = await requireSftpChannel(client, { signal, timeoutMs: payload?.timeoutMs });
const normalizedPath = await normalizeRemotePathString(client, payload.path);
throwIfAborted(signal);
await removeRemotePathInternal(sftp, normalizedPath, encoding, signal);
return true;
}
/**
* Rename a file or directory
*/
async function renameSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedOldPath = encodePath(payload.oldPath, encoding);
const encodedNewPath = encodePath(payload.newPath, encoding);
await client.rename(encodedOldPath, encodedNewPath);
return true;
}
/**
* Get file statistics
*/
async function statSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const stat = await client.stat(encodedPath);
return {
name: path.basename(payload.path),
type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file",
size: stat.size,
lastModified: stat.modifyTime,
permissions: stat.mode ? (stat.mode & 0o777).toString(8) : undefined,
};
}
/**
* Change file permissions
*/
async function chmodSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.chmod(encodedPath, parseInt(payload.mode, 8));
return true;
}
/**
* Resolve the remote user's home directory.
* Strategy: exec `echo ~` via SSH, fallback to SFTP realpath('.').
*/
async function getSftpHomeDir(_event, payload) {
const { sftpId } = payload;
const client = sftpClients.get(sftpId);
if (!client) return { success: false, error: "SFTP session not found" };
const signal = payload?.abortSignal || null;
throwIfAborted(signal);
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
// hosts with blocking shell init scripts or forced commands)
const sshClient = client.client;
if (sshClient && typeof sshClient.exec === "function") {
let execStream = null;
try {
const result = await new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const cleanup = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
};
const closeExecStream = () => {
try { execStream?.close?.(); } catch {}
try { execStream?.destroy?.(); } catch {}
};
const finishResolve = (value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const finishReject = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onAbort = () => {
closeExecStream();
finishReject(createAbortError(signal, "SFTP home probe was aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
timer = setTimeout(() => {
closeExecStream();
finishReject(new Error("SFTP home probe timed out after 5000ms"));
}, 5000);
sshClient.exec("echo ~", (err, stream) => {
if (err) {
finishReject(err);
return;
}
if (settled) {
try { stream?.close?.(); } catch {}
try { stream?.destroy?.(); } catch {}
return;
}
execStream = stream;
let stdout = "";
stream.once("error", finishReject);
stream.on("close", (code) => finishResolve({ stdout, code }));
stream.on("data", (data) => { stdout += data.toString(); });
stream.stderr.on("data", () => {});
});
});
throwIfAborted(signal);
const home = result.stdout?.trim();
if (home && home.startsWith("/")) {
return { success: true, homeDir: home };
}
} catch (err) {
// Timeout or error — kill the exec channel if still open
try { execStream?.close?.(); } catch {}
try { execStream?.destroy?.(); } catch {}
if (signal?.aborted) {
throw err;
}
// Fall through to SFTP realpath
}
}
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
// because some SFTP servers start in '/' rather than the user's home
try {
const sftp = await requireSftpChannel(client, {
signal,
timeoutMs: payload?.timeoutMs,
});
throwIfAborted(signal);
const absPath = await realpathAsync(sftp, ".");
throwIfAborted(signal);
if (absPath && absPath !== "/") {
return { success: true, homeDir: absPath };
}
} catch (err) {
if (signal?.aborted) {
throw err;
}
// ignore
}
return { success: false, error: "Could not determine home directory" };
}
/**
* Register IPC handlers for SFTP operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:open", openSftp);
ipcMain.handle("netcatty:sftp:list", listSftp);
ipcMain.handle("netcatty:sftp:read", readSftp);
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
ipcMain.handle("netcatty:sftp:write", writeSftp);
ipcMain.handle("netcatty:sftp:writeBinary", writeSftpBinary);
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
ipcMain.handle("netcatty:sftp:cancelUpload", cancelSftpUpload);
ipcMain.handle("netcatty:sftp:close", closeSftp);
ipcMain.handle("netcatty:sftp:mkdir", mkdirSftp);
ipcMain.handle("netcatty:sftp:delete", deleteSftp);
ipcMain.handle("netcatty:sftp:rename", renameSftp);
ipcMain.handle("netcatty:sftp:stat", statSftp);
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
ipcMain.handle("netcatty:sftp:homeDir", getSftpHomeDir);
}
/**
* Get the SFTP clients map (for external access)
*/
function getSftpClients() {
return sftpClients;
}
module.exports = {
init,
registerHandlers,
getSftpClients,
requireSftpChannel,
encodePathForSession,
ensureRemoteDirForSession,
clearSftpEncodingState,
clearSftpEncodingStateByPrefix,
openSftpForSession,
openSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinary,
writeSftpBinaryWithProgress,
cancelSftpUpload,
downloadSftpToLocal,
uploadLocalToSftp,
closeSftp,
mkdirSftp,
deleteSftp,
renameSftp,
statSftp,
chmodSftp,
getSftpHomeDir,
resolveEncodingForRequest,
};