Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fd7a42a8 | ||
|
|
981c5de90d | ||
|
|
0097d65a6e | ||
|
|
a451fd8811 | ||
|
|
49cef792a8 | ||
|
|
62511ceb21 | ||
|
|
00cbb05d71 | ||
|
|
3497614165 | ||
|
|
b652b836a7 |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -59,12 +59,12 @@ jobs:
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
# macOS code signing & notarization (ignored on other platforms)
|
||||
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# macOS code signing & notarization (only for macOS builds)
|
||||
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
|
||||
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
|
||||
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Upload artifacts
|
||||
|
||||
@@ -728,6 +728,7 @@ const en: Messages = {
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
'sftp.upload.completedToPath': 'Uploaded to {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
|
||||
@@ -1053,6 +1053,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上传至 {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
@@ -68,6 +68,18 @@ export const useSftpPaneActions = ({
|
||||
isSessionError,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
// Track the latest navigation request ID per tab, so we can distinguish
|
||||
// whether a superseded request was superseded by the same tab or a different tab.
|
||||
const tabNavSeqRef = useRef(new Map<string, number>());
|
||||
|
||||
// Track the last confirmed (successfully loaded) state per tab, so that
|
||||
// restore-on-error/supersede always reverts to a known-good state rather
|
||||
// than an intermediate optimistic state from another in-flight navigation.
|
||||
// Includes connectionId so stale entries from a previous host are ignored.
|
||||
const lastConfirmedRef = useRef(
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -92,8 +104,9 @@ export const useSftpPaneActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
@@ -104,6 +117,13 @@ export const useSftpPaneActions = ({
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -118,7 +138,36 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
|
||||
// Re-seed confirmed state whenever the pane is settled (not loading), or
|
||||
// when the connection has changed. This captures post-mutation state from
|
||||
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
|
||||
// doesn't resurrect deleted items.
|
||||
const existing = lastConfirmedRef.current.get(activeTabId);
|
||||
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
// that gets superseded by another tab navigating on the same side.
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: new Set(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
let files: SftpFileEntry[];
|
||||
@@ -164,13 +213,42 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
// Another navigation on this side superseded this request.
|
||||
// Only restore if no newer navigation has occurred on this specific tab
|
||||
// AND the tab still belongs to the same connection (connect/disconnect
|
||||
// bump navSeqRef but not tabNavSeqRef).
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
// Tab was reconnected or disconnected; don't restore stale state.
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -181,13 +259,38 @@ export const useSftpPaneActions = ({
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
}));
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TransferTask {
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
@@ -166,6 +167,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
{task.targetPath && (
|
||||
<span className="text-muted-foreground ml-1">→ {task.targetPath}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
|
||||
@@ -27,6 +27,7 @@ interface TransferTask {
|
||||
fileCount?: number;
|
||||
completedCount?: number;
|
||||
direction: "upload" | "download";
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
// Keep UploadTask as alias for backwards compatibility
|
||||
@@ -246,6 +247,7 @@ export const useSftpModalTransfers = ({
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
direction: "upload",
|
||||
targetPath: currentPath,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
@@ -343,7 +345,7 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
}, [t, currentPath]);
|
||||
|
||||
// Helper function to perform upload with compression setting from user preference
|
||||
const performUpload = useCallback(async (
|
||||
|
||||
@@ -411,7 +411,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getAvailableAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
@@ -427,7 +428,7 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
@@ -498,6 +499,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
@@ -521,6 +523,11 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
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);
|
||||
reject(err);
|
||||
});
|
||||
@@ -828,6 +835,10 @@ async function openSftp(event, options) {
|
||||
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}`);
|
||||
@@ -841,7 +852,8 @@ async function openSftp(event, options) {
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22,
|
||||
connId
|
||||
connId,
|
||||
agentSocket
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -895,6 +907,7 @@ async function openSftp(event, options) {
|
||||
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,
|
||||
@@ -903,6 +916,7 @@ async function openSftp(event, options) {
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -922,44 +936,104 @@ async function openSftp(event, options) {
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
try {
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
const sshClient = client.client;
|
||||
// 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) => {
|
||||
// Set up error handler for initial connection
|
||||
const onConnectError = (err) => reject(err);
|
||||
sshClient.once('error', onConnectError);
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn, val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
fn(val);
|
||||
};
|
||||
|
||||
sshClient.once('ready', async () => {
|
||||
sshClient.removeListener('error', onConnectError);
|
||||
try {
|
||||
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
|
||||
const sudoPass = options.password || "";
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
|
||||
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);
|
||||
};
|
||||
|
||||
// Inject into sftp-client
|
||||
client.sftp = sftpWrapper;
|
||||
const onEnd = () => {
|
||||
settle(reject, new Error('Connection closed before SFTP session was ready'));
|
||||
};
|
||||
|
||||
// Important: attach cleanup listener expected by sftp-client
|
||||
client.sftp.on('close', () => client.end());
|
||||
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.on('error', onError);
|
||||
sshClient.on('end', onEnd);
|
||||
sshClient.on('close', onClose);
|
||||
|
||||
sshClient.once('ready', () => {
|
||||
cleanup();
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
sshClient.end();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await client.connect(connectOpts);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
@@ -123,11 +124,33 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* Check if Windows SSH Agent service is running
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function checkWindowsSshAgentRunning() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
exec("sc query ssh-agent", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.includes("RUNNING"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform (synchronous, best-effort)
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, always return the pipe path; the caller should use
|
||||
// getAvailableAgentSocket() for a reliable async check.
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
const agentSocket = process.env.SSH_AUTH_SOCK;
|
||||
@@ -143,6 +166,18 @@ function getSshAgentSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path with async validation (checks Windows service status)
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getAvailableAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
const running = await checkWindowsSshAgentRunning();
|
||||
return running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
}
|
||||
return getSshAgentSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
@@ -156,7 +191,7 @@ function getSshAgentSocket() {
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
@@ -168,7 +203,10 @@ function buildAuthHandler(options) {
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
// Allow callers to pass in a pre-validated agent socket (e.g. from async
|
||||
// getAvailableAgentSocket). Fall back to synchronous getSshAgentSocket()
|
||||
// which on Windows always returns the pipe path without checking the service.
|
||||
const sshAgentSocket = sshAgentSocketOverride !== undefined ? sshAgentSocketOverride : getSshAgentSocket();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
@@ -522,6 +560,7 @@ module.exports = {
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
getAvailableAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
Reference in New Issue
Block a user