diff --git a/src/main/http/ssh.ts b/src/main/http/ssh.ts index 8e3a304c..6afda1bb 100644 --- a/src/main/http/ssh.ts +++ b/src/main/http/ssh.ts @@ -108,7 +108,6 @@ export function registerSshRoutes( port: config.port, username: config.username, authMethod: config.authMethod, - privateKeyPath: config.privateKeyPath, }, }); return { success: true }; diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index aa71e865..94f27205 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -375,7 +375,9 @@ function isValidSshProfile(profile: unknown): boolean { if (typeof profile.host !== 'string') return false; if (typeof profile.port !== 'number') return false; if (typeof profile.username !== 'string') return false; - const validMethods = ['password', 'privateKey', 'agent', 'auto']; + // Accept current values plus legacy ('auto', 'agent', 'privateKey') so older + // configs round-trip; legacy values are normalized to 'sshConfig' on read. + const validMethods = ['password', 'sshConfig', 'auto', 'agent', 'privateKey']; if (!validMethods.includes(profile.authMethod as string)) return false; return true; } diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ae0933d2..2841c669 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -189,7 +189,6 @@ export function registerSshHandlers(ipcMain: IpcMain): void { port: config.port, username: config.username, authMethod: config.authMethod, - privateKeyPath: config.privateKeyPath, }, }); return { success: true }; diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 91e57254..d5ec6f83 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -201,7 +201,7 @@ export interface SshPersistConfig { host: string; port: number; username: string; - authMethod: 'password' | 'privateKey' | 'agent' | 'auto'; + authMethod: 'password' | 'sshConfig'; privateKeyPath?: string; } | null; autoReconnect: boolean; diff --git a/src/main/services/infrastructure/SshConfigParser.ts b/src/main/services/infrastructure/SshConfigParser.ts index 7e2c5b0a..8f70459f 100644 --- a/src/main/services/infrastructure/SshConfigParser.ts +++ b/src/main/services/infrastructure/SshConfigParser.ts @@ -1,11 +1,16 @@ /** * SshConfigParser - Parses ~/.ssh/config to resolve host aliases. * - * Responsibilities: - * - Parse SSH config with Include directive support - * - Return all defined Host aliases (excluding wildcards) - * - Resolve alias to HostName, Port, User, IdentityFile - * - Gracefully handle missing/unreadable files + * Two responsibilities: + * - `getHosts()`: enumerate every Host alias for the dropdown autocomplete. + * Uses a line-based scanner because the `ssh-config` library was silently + * dropping hosts in some configurations (mixed indentation, comment-heavy + * sections, Include directives). For the dropdown we just need the names — + * not full config resolution — so a forgiving parser is the right tool. + * - `resolveHost()`: full per-alias resolution. Uses the `ssh-config` library + * which understands `Match`, multi-alias Host lines, and config inheritance. + * + * Both methods follow `Include` directives, expanding paths and globs. */ import { createLogger } from '@shared/utils/logger'; @@ -27,31 +32,17 @@ export class SshConfigParser { /** * Returns all defined Host aliases (excluding `*` wildcards and patterns). + * + * Uses a forgiving line-based scan instead of the `ssh-config` library so + * that an unparseable block doesn't take out the rest of the config — + * users have lots of weird stuff in their ssh_config (gcloud-generated + * sections, OrbStack Includes, comment blocks). */ async getHosts(): Promise { try { - const config = await this.parseConfig(); - if (!config) return []; - - const entries: SshConfigHostEntry[] = []; - - for (const section of config) { - if (section.type !== SSHConfig.DIRECTIVE) continue; - if (section.param !== 'Host') continue; - - const hostValue = section.value; - if (typeof hostValue !== 'string') continue; - - // Skip wildcard-only entries and patterns with * or ? - const aliases = hostValue.split(/\s+/).filter((h) => !h.includes('*') && !h.includes('?')); - - for (const alias of aliases) { - const resolved = this.resolveFromConfig(config, alias); - entries.push(resolved); - } - } - - return entries; + const content = await this.readExpandedConfig(); + if (content === null) return []; + return parseHostListing(content); } catch (err) { logger.error('Failed to get SSH config hosts:', err); return []; @@ -70,8 +61,12 @@ export class SshConfigParser { const resolved = this.resolveFromConfig(config, alias); // If nothing was resolved beyond the alias itself, check if host was actually defined - if (!resolved.hostName && !resolved.user && !resolved.port && !resolved.identityFiles?.length) { - // Check if there's an explicit Host entry for this alias + if ( + !resolved.hostName && + !resolved.user && + !resolved.port && + !resolved.identityFiles?.length + ) { const hasEntry = config.some( (section) => section.type === SSHConfig.DIRECTIVE && @@ -102,9 +97,12 @@ export class SshConfigParser { const user = Array.isArray(rawUser) ? rawUser[0] : (rawUser ?? undefined); const portStr = computed.Port; const port = portStr ? parseInt(String(portStr), 10) : undefined; - // Resolve identity file paths (expand ~ to home directory) const rawIdentityFile = computed.IdentityFile; - const rawFiles = Array.isArray(rawIdentityFile) ? rawIdentityFile : rawIdentityFile != null ? [rawIdentityFile] : []; + const rawFiles = Array.isArray(rawIdentityFile) + ? rawIdentityFile + : rawIdentityFile != null + ? [rawIdentityFile] + : []; const identityFiles = rawFiles .filter((f): f is string => typeof f === 'string') .map((f) => f.replace(/^~(?=$|\/|\\)/, os.homedir())); @@ -119,18 +117,25 @@ export class SshConfigParser { } private async parseConfig(): Promise { + const content = await this.readExpandedConfig(); + if (content === null) return null; try { - let content = await fs.promises.readFile(this.configPath, 'utf8'); - - // Process Include directives by expanding them inline - content = await this.expandIncludes(content); - return SSHConfig.parse(content); + } catch (err) { + logger.error('Failed to parse SSH config:', err); + return null; + } + } + + private async readExpandedConfig(): Promise { + try { + const content = await fs.promises.readFile(this.configPath, 'utf8'); + return await this.expandIncludes(content); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { logger.info('No SSH config file found at', this.configPath); } else { - logger.error('Failed to parse SSH config:', err); + logger.error('Failed to read SSH config:', err); } return null; } @@ -156,7 +161,6 @@ export class SshConfigParser { const expandedPattern = pattern.replace(/^~/, os.homedir()); try { - // Handle glob-like patterns by checking if the path contains wildcards if (expandedPattern.includes('*') || expandedPattern.includes('?')) { const dir = path.dirname(expandedPattern); const globPart = path.basename(expandedPattern); @@ -194,3 +198,119 @@ export class SshConfigParser { } } } + +// ============================================================================= +// Line-based host listing +// ============================================================================= + +/** + * Walks the (already include-expanded) config text and returns one entry per + * Host alias. Lenient: tolerates mixed indentation, `key=value` and `key value` + * forms, and comments. Unrecognized directives are silently skipped. + */ +function parseHostListing(content: string): SshConfigHostEntry[] { + const entries: SshConfigHostEntry[] = []; + // Multiple aliases on the same `Host a b c` line share the same body, so we + // accumulate to a list of "current entries" that all receive the next props. + let current: SshConfigHostEntry[] = []; + + const flush = (): void => { + for (const entry of current) { + // Don't echo identity defaults that aren't explicitly set in the block. + if (entry.identityFiles?.length === 0) { + delete entry.identityFiles; + } + entries.push(entry); + } + current = []; + }; + + for (const rawLine of content.split('\n')) { + const line = stripComment(rawLine).trim(); + if (!line) continue; + + const kv = parseKeyValue(line); + if (!kv) continue; + + const key = kv.key.toLowerCase(); + const value = kv.value; + + if (key === 'host') { + flush(); + const aliases = value.split(/\s+/).filter((a) => a && !a.includes('*') && !a.includes('?')); + current = aliases.map((alias) => ({ alias })); + continue; + } + + if (current.length === 0) continue; + + for (const entry of current) { + applyDirective(entry, key, value); + } + } + + flush(); + return entries; +} + +/* eslint-disable no-param-reassign -- + `entry` is the in-progress builder for a Host block. Imperative mutation + is the natural shape for a line-by-line parser; the alternative (returning + a new object every line) would just churn allocations for no readability + gain. */ +function applyDirective(entry: SshConfigHostEntry, key: string, value: string): void { + switch (key) { + case 'hostname': + if (value !== entry.alias) entry.hostName = value; + break; + case 'user': + entry.user = value; + break; + case 'port': { + const port = parseInt(value, 10); + if (!Number.isNaN(port) && port !== 22) entry.port = port; + break; + } + case 'identityfile': { + const expanded = value.replace(/^~(?=$|\/|\\)/, os.homedir()); + if (!entry.identityFiles) entry.identityFiles = []; + entry.identityFiles.push(expanded); + break; + } + default: + // Unrecognized directives don't appear in the dropdown; ignore. + break; + } +} +/* eslint-enable no-param-reassign -- end builder mutation block */ + +function stripComment(line: string): string { + const idx = line.indexOf('#'); + return idx === -1 ? line : line.slice(0, idx); +} + +function parseKeyValue(line: string): { key: string; value: string } | null { + // Both `Key Value ...` and `Key=Value ...` are valid in OpenSSH config. + // Avoid regex to keep this linear-time on long lines. + const eqIdx = line.indexOf('='); + const wsIdx = findWhitespace(line); + if (eqIdx !== -1 && (wsIdx === -1 || eqIdx < wsIdx)) { + const key = line.slice(0, eqIdx).trim(); + const value = line.slice(eqIdx + 1).trim(); + if (!key) return null; + return { key, value }; + } + if (wsIdx === -1) return null; + const key = line.slice(0, wsIdx); + const value = line.slice(wsIdx + 1).trim(); + if (!key || !value) return null; + return { key, value }; +} + +function findWhitespace(s: string): number { + for (let i = 0; i < s.length; i += 1) { + const c = s.charCodeAt(i); + if (c === 0x20 || c === 0x09) return i; + } + return -1; +} diff --git a/src/main/services/infrastructure/SshConnectionManager.ts b/src/main/services/infrastructure/SshConnectionManager.ts index 6e2151d2..e66cf3a3 100644 --- a/src/main/services/infrastructure/SshConnectionManager.ts +++ b/src/main/services/infrastructure/SshConnectionManager.ts @@ -1,28 +1,38 @@ /** * SshConnectionManager - Manages SSH connection lifecycle. * - * Responsibilities: - * - Connect/disconnect SSH sessions - * - Manage SFTP channel - * - Provide FileSystemProvider (local or SSH) to services - * - Emit connection state events for UI updates - * - Handle reconnection on errors + * Auth strategy for `sshConfig` mode: + * 1. Resolve host via OpenSSH `ssh -G` (honors Host blocks, Match, Include, + * IdentityAgent, IdentityFile, default keys). + * 2. Build a list of auth candidates: agent → IdentityFile entries → default + * keys. + * 3. Try each candidate in its own ssh2 connection. Auth failure on one + * candidate moves to the next (matching how OpenSSH behaves). Network + * errors abort the chain immediately. + * + * Two safety nets: + * - `tryKeyboard: false` on every attempt so the server can't trap us in + * keyboard-interactive prompts that the GUI can't answer. + * - A single 20s outer timeout wraps the entire chain (including SFTP open), + * so the spinner never hangs forever. */ import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; import { EventEmitter } from 'events'; import * as fs from 'fs'; +import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; -import { Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'; +import { Client, type SFTPWrapper } from 'ssh2'; import { LocalFileSystemProvider } from './LocalFileSystemProvider'; import { SshConfigParser } from './SshConfigParser'; import { SshFileSystemProvider } from './SshFileSystemProvider'; +import { type ResolvedSshHost, SshHostResolver } from './SshHostResolver'; import type { FileSystemProvider } from './FileSystemProvider'; -import type { SshConfigHostEntry } from '@shared/types'; +import type { SshAuthMethod, SshConfigHostEntry } from '@shared/types'; const logger = createLogger('Infrastructure:SshConnectionManager'); @@ -32,7 +42,7 @@ const logger = createLogger('Infrastructure:SshConnectionManager'); export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto'; +export type { SshAuthMethod }; export interface SshConnectionConfig { host: string; @@ -50,6 +60,38 @@ export interface SshConnectionStatus { remoteProjectsPath: string | null; } +interface AuthAttempt { + source: string; + outcome: 'used' | 'skipped' | 'failed'; + reason?: string; +} + +type AuthCandidate = + | { kind: 'agent'; socket: string; label: string } + | { kind: 'privateKey'; data: string; label: string } + | { kind: 'password'; password: string; label: string }; + +interface ResolvedTarget { + host: string; + port: number; + username: string; + resolved: ResolvedSshHost | null; + matchedAlias?: string; +} + +const CONNECT_TIMEOUT_MS = 25_000; +// ssh2 readyTimeout covers TCP + handshake + auth. Leave headroom under our +// outer timeout so the outer fires last (cleaner error + diagnostics). +const SSH2_READY_TIMEOUT_MS = 22_000; +// Quick reachability probe before handing off to ssh2. Long enough for slow +// VPN tunnels but short enough that failure surfaces in seconds, not 25. +const TCP_PROBE_TIMEOUT_MS = 5_000; +// SFTP subsystem open. Should complete in <1s on a healthy server; anything +// past 8s means the server isn't going to answer (sshd_config missing +// `Subsystem sftp`, restricted shell, etc.) — fail loud rather than burn the +// whole connect budget. +const SFTP_OPEN_TIMEOUT_MS = 8_000; + // ============================================================================= // Connection Manager // ============================================================================= @@ -59,6 +101,7 @@ export class SshConnectionManager extends EventEmitter { private provider: FileSystemProvider; private localProvider: LocalFileSystemProvider; private configParser: SshConfigParser; + private hostResolver: SshHostResolver; private state: SshConnectionState = 'disconnected'; private connectedHost: string | null = null; private lastError: string | null = null; @@ -69,18 +112,13 @@ export class SshConnectionManager extends EventEmitter { this.localProvider = new LocalFileSystemProvider(); this.provider = this.localProvider; this.configParser = new SshConfigParser(); + this.hostResolver = new SshHostResolver(); } - /** - * Returns the current FileSystemProvider (local or SSH). - */ getProvider(): FileSystemProvider { return this.provider; } - /** - * Returns the current connection status. - */ getStatus(): SshConnectionStatus { return { state: this.state, @@ -90,40 +128,23 @@ export class SshConnectionManager extends EventEmitter { }; } - /** - * Returns the remote projects directory path. - * Used by services to know where to scan on the remote machine. - */ getRemoteProjectsPath(): string | null { return this.remoteProjectsPath; } - /** - * Returns whether we're in SSH mode. - */ isRemote(): boolean { return this.state === 'connected' && this.provider.type === 'ssh'; } - /** - * Returns all SSH config host entries from ~/.ssh/config. - */ async getConfigHosts(): Promise { return this.configParser.getHosts(); } - /** - * Resolves a host alias from ~/.ssh/config. - */ async resolveHostConfig(alias: string): Promise { return this.configParser.resolveHost(alias); } - /** - * Connect to a remote SSH host. - */ async connect(config: SshConnectionConfig): Promise { - // Disconnect existing connection first if (this.client) { this.disconnect(); } @@ -131,46 +152,23 @@ export class SshConnectionManager extends EventEmitter { this.setState('connecting'); this.connectedHost = config.host; + let chainResult: { client: Client; sftp: SFTPWrapper } | null = null; try { - const client = new Client(); - this.client = client; - - const connectConfig = await this.buildConnectConfig(config); + chainResult = await this.connectChain(config); - await new Promise((resolve, reject) => { - client.on('ready', () => resolve()); - client.on('error', (err) => reject(err)); - client.connect(connectConfig); - }); - - // Open SFTP channel - const sftpChannel = await new Promise((resolve, reject) => { - client.sftp((err, channel) => { - if (err) { - reject(err); - return; - } - resolve(channel); - }); - }); - - // Create SSH provider - this.provider = new SshFileSystemProvider(sftpChannel); - - // Resolve remote ~/.claude/projects/ path + const { client, sftp } = chainResult; + this.client = client; + this.provider = new SshFileSystemProvider(sftp); this.remoteProjectsPath = await this.resolveRemoteProjectsPath(config.username); - // Set up disconnect handler client.on('end', () => { logger.info('SSH connection ended'); this.handleDisconnect(); }); - client.on('close', () => { logger.info('SSH connection closed'); this.handleDisconnect(); }); - client.on('error', (err) => { logger.error('SSH connection error:', err); this.lastError = err.message; @@ -184,49 +182,30 @@ export class SshConnectionManager extends EventEmitter { logger.error(`SSH connection failed: ${message}`); this.lastError = message; this.setState('error'); + // If the chain returned a client but we failed afterwards, dispose it. + if (chainResult) { + try { + chainResult.client.end(); + } catch { + /* ignore */ + } + } this.cleanup(); - throw err; + throw err instanceof Error ? err : new Error(message); } } - /** - * Test a connection without switching to SSH mode. - */ async testConnection(config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> { - const testClient = new Client(); - try { - const connectConfig = await this.buildConnectConfig(config); - - await new Promise((resolve, reject) => { - testClient.on('ready', () => resolve()); - testClient.on('error', (err) => reject(err)); - testClient.connect(connectConfig); - }); - - // Try to open SFTP to verify full access - await new Promise((resolve, reject) => { - testClient.sftp((err) => { - if (err) { - reject(err); - return; - } - resolve(); - }); - }); - - testClient.end(); + const { client } = await this.connectChain(config); + client.end(); return { success: true }; } catch (err) { - testClient.end(); const message = err instanceof Error ? err.message : String(err); return { success: false, error: message }; } } - /** - * Disconnect and switch back to local mode. - */ disconnect(): void { this.cleanup(); this.provider = this.localProvider; @@ -237,9 +216,6 @@ export class SshConnectionManager extends EventEmitter { logger.info('Switched to local mode'); } - /** - * Dispose of all resources. - */ dispose(): void { this.cleanup(); this.localProvider.dispose(); @@ -247,106 +223,375 @@ export class SshConnectionManager extends EventEmitter { } // =========================================================================== - // Private Methods + // Private: connection chain // =========================================================================== - private async buildConnectConfig(config: SshConnectionConfig): Promise { - // Resolve SSH config for the given host (alias or hostname) - const sshConfig = await this.configParser.resolveHost(config.host); + private async connectChain( + config: SshConnectionConfig + ): Promise<{ client: Client; sftp: SFTPWrapper }> { + const timings = new Timings(); + const attempts: AuthAttempt[] = []; + let inFlightClient: Client | null = null; + + const work = async (): Promise<{ client: Client; sftp: SFTPWrapper }> => { + timings.start('resolve'); + const target = await this.resolveTarget(config); + timings.end('resolve'); + + if (target.matchedAlias) { + attempts.push({ + source: `matched ssh_config alias "${target.matchedAlias}"`, + outcome: 'used', + }); + } - const connectConfig: ConnectConfig = { - host: sshConfig?.hostName ?? config.host, - port: config.port !== 22 ? config.port : (sshConfig?.port ?? config.port), - username: config.username || sshConfig?.user || os.userInfo().username, - readyTimeout: 10000, - }; + timings.start('buildCandidates'); + const candidates = await this.buildAuthCandidates(config, target.resolved, attempts); + timings.end('buildCandidates'); + + if (candidates.length === 0) { + throw this.enrichAuthError( + new Error('No usable authentication method found.'), + attempts, + timings + ); + } + + // TCP reachability probe — fails fast and surfaces "Electron can't see + // this host" before we burn 22s on ssh2's readyTimeout. The most common + // cause when terminal `ssh` works but this app doesn't is a per-app VPN + // (Cisco AnyConnect, GlobalProtect, Cloudflare WARP, some MDM clients) + // that routes whitelisted bundle IDs only. + timings.start(`tcp probe ${target.host}:${target.port}`); + const probe = await probeTcp(target.host, target.port, TCP_PROBE_TIMEOUT_MS); + timings.end(`tcp probe ${target.host}:${target.port}`); + attempts.push({ + source: `TCP probe ${target.host}:${target.port}`, + outcome: probe.ok ? 'used' : 'failed', + reason: probe.reason, + }); - switch (config.authMethod) { - case 'password': - connectConfig.password = config.password; - break; + if (!probe.ok) { + throw this.enrichAuthError( + new Error( + `Cannot reach ${target.host}:${target.port} from this app (${probe.reason ?? 'unknown'}).\n` + + `If \`ssh ${config.host}\` works in your terminal, the host is reachable from your ` + + 'shell but not from this Electron process. The most common cause is a per-app VPN ' + + '(some corporate VPN clients route only whitelisted apps through the tunnel) — ' + + "add this app to your VPN client's allowed-apps list, or switch the VPN to full-tunnel mode." + ), + attempts, + timings + ); + } - case 'privateKey': { - const rawKeyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa'); - const keyPath = rawKeyPath.replace(/^~(?=$|\/|\\)/, os.homedir()); + // Single TCP/SSH session — ssh2's authHandler walks our candidate list, + // trying each method as the previous fails. Same behavior as OpenSSH; + // avoids the cumulative TCP+handshake cost of one connection per key. + const client = new Client(); + inFlightClient = client; + const queue = [...candidates]; + let lastTried: AuthCandidate | null = null; + + timings.start(`tcp+handshake ${target.host}:${target.port}`); + try { + await new Promise((resolve, reject) => { + const onReady = (): void => { + if (lastTried) { + attempts.push({ source: lastTried.label, outcome: 'used' }); + } + client.removeListener('error', onError); + resolve(); + }; + const onError = (err: Error): void => { + client.removeListener('ready', onReady); + reject(err); + }; + client.once('ready', onReady); + client.once('error', onError); + + client.connect({ + host: target.host, + port: target.port, + username: target.username, + readyTimeout: SSH2_READY_TIMEOUT_MS, + tryKeyboard: false, + authHandler: (_methodsLeft, partialSuccess, callback) => { + if (lastTried && !partialSuccess) { + attempts.push({ + source: lastTried.label, + outcome: 'failed', + reason: 'rejected by server', + }); + } + const next = queue.shift(); + if (!next) { + // ssh2 accepts `false` at runtime to abort auth, but the typed + // signature doesn't expose it. Double-cast through `unknown`. + (callback as unknown as (v: false) => void)(false); + return; + } + lastTried = next; + switch (next.kind) { + case 'agent': + callback({ type: 'agent', username: target.username, agent: next.socket }); + return; + case 'privateKey': + callback({ type: 'publickey', username: target.username, key: next.data }); + return; + case 'password': + callback({ + type: 'password', + username: target.username, + password: next.password, + }); + return; + } + }, + }); + }); + timings.end(`tcp+handshake ${target.host}:${target.port}`); + + timings.start('open sftp'); + const sftp = await this.openSftp(client); + timings.end('open sftp'); + inFlightClient = null; + return { client, sftp }; + } catch (err) { try { - const keyData = await fs.promises.readFile(keyPath, 'utf8'); - connectConfig.privateKey = keyData; - } catch (err) { - throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`); + client.end(); + } catch { + /* ignore */ } - break; + inFlightClient = null; + const error = err instanceof Error ? err : new Error(String(err)); + throw this.enrichAuthError(error, attempts, timings); } + }; - case 'agent': { - const agentSocket = await this.discoverAgentSocket(); - if (!agentSocket) { - throw new Error( - 'SSH agent socket not found. Ensure ssh-agent is running or SSH_AUTH_SOCK is set.' - ); + // Outer hard timeout. Lives inside connectChain so it can pull the + // attempts/timings already collected and append them to the error — + // otherwise a timeout surfaces a bare "host unreachable" with no + // diagnostic about which step actually stalled. + let timer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + if (inFlightClient) { + try { + inFlightClient.end(); + } catch { + /* ignore */ + } + } + reject( + this.enrichAuthError( + new Error( + `SSH connection timed out after ${Math.round(CONNECT_TIMEOUT_MS / 1000)}s. ` + + 'The host may be unreachable, or the server is not responding.' + ), + attempts, + timings + ) + ); + }, CONNECT_TIMEOUT_MS); + }); + + try { + return await Promise.race([work(), timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } + } + + /** + * Resolves the target host. Runs `ssh -G ` first; if that returned + * nothing useful AND the input matches a `HostName` from `~/.ssh/config`, + * re-resolves using the matching alias so we inherit its directives. + */ + private async resolveTarget(config: SshConnectionConfig): Promise { + let resolved = await this.hostResolver.resolve(config.host); + let matchedAlias: string | undefined; + + const hasInheritedConfig = + resolved !== null && + ((resolved.identityFiles.length > 0 && !looksLikeOnlyDefaultKeys(resolved.identityFiles)) || + Boolean(resolved.identityAgent) || + Boolean(resolved.user) || + Boolean(resolved.hostname && resolved.hostname !== config.host)); + + if (!hasInheritedConfig) { + const alias = await this.findAliasByHostname(config.host); + if (alias) { + const fromAlias = await this.hostResolver.resolve(alias); + if (fromAlias) { + resolved = fromAlias; + matchedAlias = alias; } - connectConfig.agent = agentSocket; - break; } + } - case 'auto': { - // Auto: try identity file from config -> agent -> default keys - const resolved = await this.resolveAutoAuth(sshConfig); - if (resolved.privateKey) { - connectConfig.privateKey = resolved.privateKey; - } else if (resolved.agent) { - connectConfig.agent = resolved.agent; + return { + host: resolved?.hostname ?? config.host, + port: resolved?.port ?? config.port, + username: config.username || resolved?.user || os.userInfo().username, + resolved, + matchedAlias, + }; + } + + private async findAliasByHostname(hostname: string): Promise { + try { + const hosts = await this.configParser.getHosts(); + for (const h of hosts) { + if (h.hostName && h.hostName === hostname) return h.alias; + } + } catch { + /* ignore — best-effort */ + } + return null; + } + + private openSftp(client: Client): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new Error( + 'SFTP subsystem did not open within ' + + `${SFTP_OPEN_TIMEOUT_MS / 1000}s. ` + + 'Authentication succeeded but the server is not exposing SFTP. ' + + "Most common cause: the server's sshd_config is missing a " + + '`Subsystem sftp …` line, or the account is in a restricted shell ' + + '/ ChrootDirectory that blocks the SFTP subsystem. ' + + 'Verify with `sftp ' + + (this.connectedHost ?? '') + + '` from your terminal — if that also hangs, fix the server config.' + ) + ); + }, SFTP_OPEN_TIMEOUT_MS); + + client.sftp((err, channel) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) { + reject(err); + return; } - break; + resolve(channel); + }); + }); + } + + // =========================================================================== + // Private: candidate building + // =========================================================================== + + private async buildAuthCandidates( + config: SshConnectionConfig, + resolved: ResolvedSshHost | null, + attempts: AuthAttempt[] + ): Promise { + if (config.authMethod === 'password') { + return [{ kind: 'password', password: config.password ?? '', label: 'password' }]; + } + + const candidates: AuthCandidate[] = []; + const usedKeyPaths = new Set(); + + // Agents — every accessible agent gets its own candidate. Many users have + // both a system ssh-agent AND a 1Password agent, with the right key on + // only one. Walking each in turn (within the same TCP/SSH session via + // authHandler) is what `ssh` would do. + for (const agentCandidate of await this.discoverAgentCandidates(resolved, attempts)) { + candidates.push(agentCandidate); + } + + // IdentityFile entries from ssh -G + for (const keyPath of resolved?.identityFiles ?? []) { + const loaded = await tryLoadKey(keyPath); + if (loaded.kind === 'ok') { + candidates.push({ kind: 'privateKey', data: loaded.data, label: keyPath }); + usedKeyPaths.add(keyPath); + } else { + attempts.push({ source: keyPath, outcome: 'skipped', reason: loaded.reason }); } } - return connectConfig; + // Default keys (only if not already covered by IdentityFile) + const defaultKeys = [ + path.join(os.homedir(), '.ssh', 'id_ed25519'), + path.join(os.homedir(), '.ssh', 'id_rsa'), + path.join(os.homedir(), '.ssh', 'id_ecdsa'), + ]; + for (const keyPath of defaultKeys) { + if (usedKeyPaths.has(keyPath)) continue; + const loaded = await tryLoadKey(keyPath); + if (loaded.kind === 'ok') { + candidates.push({ kind: 'privateKey', data: loaded.data, label: keyPath }); + } + // Skip noisy diagnostics for missing default keys. + } + + return candidates; } /** - * Discovers the SSH agent socket path. - * Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell. + * Enumerates every accessible SSH agent socket. + * + * Order: IdentityAgent (from `ssh -G`) first, then SSH_AUTH_SOCK env, then + * launchctl-published SSH_AUTH_SOCK (macOS GUI apps don't inherit shell + * env), then 1Password's well-known sockets, then a few platform fallbacks. + * De-duplicated by canonical path. */ - private async discoverAgentSocket(): Promise { - // 1. Check SSH_AUTH_SOCK env var - if (process.env.SSH_AUTH_SOCK) { - try { - await fs.promises.access(process.env.SSH_AUTH_SOCK); - return process.env.SSH_AUTH_SOCK; - } catch { - // Socket path set but not accessible + private async discoverAgentCandidates( + resolved: ResolvedSshHost | null, + attempts: AuthAttempt[] + ): Promise { + const seen = new Set(); + const out: AuthCandidate[] = []; + + const tryAdd = async (socket: string, label: string): Promise => { + if (seen.has(socket)) return; + if (await pathExists(socket)) { + seen.add(socket); + out.push({ kind: 'agent', socket, label }); } - } + }; - // 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env) - if (process.platform === 'darwin') { - try { - const sock = await new Promise((resolve) => { - execFile('/bin/launchctl', ['getenv', 'SSH_AUTH_SOCK'], (err, stdout) => { - if (err || !stdout.trim()) { - resolve(null); - return; - } - resolve(stdout.trim()); - }); + if (resolved?.identityAgent) { + const socket = resolved.identityAgent; + if (await pathExists(socket)) { + seen.add(socket); + out.push({ kind: 'agent', socket, label: `IdentityAgent ${socket}` }); + } else { + attempts.push({ + source: `IdentityAgent ${socket}`, + outcome: 'skipped', + reason: 'socket not accessible', }); - if (sock) { - try { - await fs.promises.access(sock); - return sock; - } catch { - // Not accessible - } - } - } catch { - // launchctl not available } } - // 3. Try known socket paths - const knownPaths = [ - // 1Password SSH agent + if (process.env.SSH_AUTH_SOCK) { + await tryAdd(process.env.SSH_AUTH_SOCK, `SSH_AUTH_SOCK ${process.env.SSH_AUTH_SOCK}`); + } + + // 1Password is checked BEFORE launchctl because launchctl typically + // resolves to the empty system ssh-agent for users who actually keep + // their keys in 1Password — querying the system agent first wastes a + // round-trip on a guaranteed miss. + const onePasswordPaths = [ + path.join( + os.homedir(), + 'Library', + 'Group Containers', + '2BUA8C4S2C.com.1password', + 't', + 'agent.sock' + ), path.join( os.homedir(), 'Library', @@ -355,79 +600,70 @@ export class SshConnectionManager extends EventEmitter { 'agent.sock' ), path.join(os.homedir(), '.1password', 'agent.sock'), - // Common user agent socket - path.join(os.homedir(), '.ssh', 'agent.sock'), ]; + for (const p of onePasswordPaths) { + await tryAdd(p, `1Password agent ${p}`); + } + + if (process.platform === 'darwin') { + const launchSock = await getLaunchctlSshAuthSock(); + if (launchSock) { + await tryAdd(launchSock, `launchctl agent ${launchSock}`); + } + } + + await tryAdd(path.join(os.homedir(), '.ssh', 'agent.sock'), 'user agent ~/.ssh/agent.sock'); - // Linux: add system paths if (process.platform === 'linux') { const uid = process.getuid?.(); if (uid !== undefined) { - knownPaths.push(`/run/user/${uid}/ssh-agent.socket`); - knownPaths.push(`/run/user/${uid}/keyring/ssh`); + await tryAdd( + `/run/user/${uid}/ssh-agent.socket`, + `systemd agent /run/user/${uid}/ssh-agent.socket` + ); + await tryAdd(`/run/user/${uid}/keyring/ssh`, `gnome-keyring /run/user/${uid}/keyring/ssh`); } } - for (const socketPath of knownPaths) { - try { - await fs.promises.access(socketPath); - return socketPath; - } catch { - // Not accessible - } + if (out.length === 0) { + attempts.push({ + source: 'ssh-agent', + outcome: 'skipped', + reason: 'no SSH_AUTH_SOCK and no known agent socket found', + }); } - return null; + return out; } - /** - * Resolves authentication automatically by trying: - * 1. IdentityFile from SSH config - * 2. SSH agent - * 3. Default key files (id_ed25519, id_rsa) - */ - private async resolveAutoAuth( - sshConfig: SshConfigHostEntry | null - ): Promise<{ privateKey?: string; agent?: string }> { - // Try identity files from SSH config - if (sshConfig?.identityFiles && sshConfig.identityFiles.length > 0) { - for (const keyPath of sshConfig.identityFiles) { - try { - const keyData = await fs.promises.readFile(keyPath, 'utf8'); - return { privateKey: keyData }; - } catch { - // Try next - } - } - } + // =========================================================================== + // Private: error handling, timeout, remote-path resolution + // =========================================================================== - // Try SSH agent - const agentSocket = await this.discoverAgentSocket(); - if (agentSocket) { - return { agent: agentSocket }; - } + private enrichAuthError(err: Error, attempts: AuthAttempt[], timings?: Timings): Error { + const sections: string[] = [err.message]; - // Try default key files - const defaultKeys = [ - path.join(os.homedir(), '.ssh', 'id_ed25519'), - path.join(os.homedir(), '.ssh', 'id_rsa'), - path.join(os.homedir(), '.ssh', 'id_ecdsa'), - ]; + if (attempts.length > 0) { + const lines = attempts.map((a) => { + const detail = a.reason ? ` (${a.reason})` : ''; + return ` • ${a.source} — ${a.outcome}${detail}`; + }); + sections.push(`Auth chain:\n${lines.join('\n')}`); + } - for (const keyPath of defaultKeys) { - try { - const keyData = await fs.promises.readFile(keyPath, 'utf8'); - return { privateKey: keyData }; - } catch { - // Try next - } + const timingReport = timings?.format(); + if (timingReport) { + sections.push(`Timing:\n${timingReport}`); } - return {}; + if (sections.length === 1) return err; + + const enriched = new Error(sections.join('\n\n')); + enriched.stack = err.stack; + return enriched; } private async resolveRemoteProjectsPath(username: string): Promise { - // Prefer remote $HOME when available, then fall back to common paths. const remoteHome = await this.resolveRemoteHomeDirectory(); const candidates = [ ...(remoteHome ? [path.posix.join(remoteHome, '.claude', 'projects')] : []), @@ -442,23 +678,15 @@ export class SshConnectionManager extends EventEmitter { } } - // Fallback to inferred home-based path when we could resolve $HOME. if (remoteHome) { return path.posix.join(remoteHome, '.claude', 'projects'); } - // Final fallback: Linux convention. return `/home/${username}/.claude/projects`; } - /** - * Resolve remote user's home directory by querying `$HOME` over SSH. - */ private async resolveRemoteHomeDirectory(): Promise { - if (!this.client) { - return null; - } - + if (!this.client) return null; try { const home = await this.execRemoteCommand('printf %s "$HOME"'); const normalized = home.trim(); @@ -468,9 +696,6 @@ export class SshConnectionManager extends EventEmitter { } } - /** - * Execute a command on the connected SSH host and return stdout. - */ private async execRemoteCommand(command: string): Promise { const client = this.client; if (!client) { @@ -490,11 +715,9 @@ export class SshConnectionManager extends EventEmitter { stream.on('data', (chunk: Buffer | string) => { stdout += chunk.toString(); }); - stream.stderr.on('data', (chunk: Buffer | string) => { stderr += chunk.toString(); }); - stream.on('close', (code: number | null) => { if (code === 0) { resolve(stdout); @@ -509,7 +732,6 @@ export class SshConnectionManager extends EventEmitter { private handleDisconnect(): void { if (this.state === 'disconnected') return; - this.provider = this.localProvider; this.remoteProjectsPath = null; this.setState('disconnected'); @@ -523,7 +745,7 @@ export class SshConnectionManager extends EventEmitter { try { this.client.end(); } catch { - // Ignore cleanup errors + /* ignore cleanup errors */ } this.client = null; } @@ -534,3 +756,156 @@ export class SshConnectionManager extends EventEmitter { this.emit('state-change', this.getStatus()); } } + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Records wall-clock duration of named steps, formatted as a diagnostic block. */ +class Timings { + private readonly steps: { name: string; ms: number }[] = []; + private readonly active = new Map(); + + start(name: string): void { + this.active.set(name, Date.now()); + } + + end(name: string): void { + const t = this.active.get(name); + if (t === undefined) return; + this.active.delete(name); + this.steps.push({ name, ms: Date.now() - t }); + } + + format(): string | null { + // Include any in-flight step (the one that timed out). + const all = [...this.steps]; + const now = Date.now(); + for (const [name, start] of this.active) { + all.push({ name: `${name} (in-flight)`, ms: now - start }); + } + if (all.length === 0) return null; + return all.map((s) => ` • ${s.name}: ${s.ms}ms`).join('\n'); + } +} + +function getLaunchctlSshAuthSock(): Promise { + return new Promise((resolve) => { + execFile('/bin/launchctl', ['getenv', 'SSH_AUTH_SOCK'], (err, stdout) => { + if (err || !stdout.trim()) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + }); +} + +/** + * Raw TCP reachability probe. Distinguishes "Electron process can't reach + * this host" (per-app VPN, broken routing) from "host rejects auth" — the + * latter would otherwise present as a long ssh2 timeout with no clue why. + */ +function probeTcp( + host: string, + port: number, + timeoutMs: number +): Promise<{ ok: boolean; reason?: string }> { + return new Promise((resolve) => { + const sock = net.createConnection({ host, port }); + const timer = setTimeout(() => { + sock.destroy(); + resolve({ ok: false, reason: `timed out after ${timeoutMs}ms` }); + }, timeoutMs); + sock.once('connect', () => { + clearTimeout(timer); + sock.end(); + resolve({ ok: true }); + }); + sock.once('error', (err) => { + clearTimeout(timer); + resolve({ ok: false, reason: err.message }); + }); + }); +} + +async function pathExists(p: string): Promise { + try { + await fs.promises.access(p); + return true; + } catch { + return false; + } +} + +type LoadResult = { kind: 'ok'; data: string } | { kind: 'skip'; reason: string }; + +async function tryLoadKey(keyPath: string): Promise { + let data: string; + try { + data = await fs.promises.readFile(keyPath, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { kind: 'skip', reason: 'not found' }; + return { kind: 'skip', reason: `unreadable (${code ?? 'unknown'})` }; + } + + if (isEncryptedPrivateKey(data)) { + return { + kind: 'skip', + reason: 'encrypted; passphrases not supported, use ssh-agent', + }; + } + + return { kind: 'ok', data }; +} + +/** + * `ssh -G ` always emits the three default IdentityFile paths + * (~/.ssh/id_rsa, id_dsa, id_ecdsa, id_ed25519, …). When that's all we got, + * the user's input didn't match a Host block — we should look for an alias + * whose HostName matches the input before giving up. + */ +function looksLikeOnlyDefaultKeys(identityFiles: string[]): boolean { + const home = os.homedir(); + const defaults = new Set( + ['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519', 'id_xmss', 'id_ecdsa_sk', 'id_ed25519_sk'].map( + (name) => path.join(home, '.ssh', name) + ) + ); + return identityFiles.every((f) => defaults.has(f)); +} + +/** + * Detects passphrase-protected private keys. ssh2 cannot prompt for the + * passphrase from a GUI process, so we must skip these and surface a clear + * diagnostic — otherwise the connect hangs or fails opaquely. + * + * Covers PEM (`Proc-Type: 4,ENCRYPTED`), PKCS#8 (`BEGIN ENCRYPTED PRIVATE + * KEY`), and OpenSSH format (cipher field non-`none` in the binary header). + */ +function isEncryptedPrivateKey(content: string): boolean { + if (content.includes('-----BEGIN ENCRYPTED PRIVATE KEY-----')) return true; + if (content.includes('Proc-Type: 4,ENCRYPTED')) return true; + + const begin = '-----BEGIN OPENSSH PRIVATE KEY-----'; + const end = '-----END OPENSSH PRIVATE KEY-----'; + const beginIdx = content.indexOf(begin); + if (beginIdx === -1) return false; + const endIdx = content.indexOf(end, beginIdx + begin.length); + if (endIdx === -1) return false; + + const body = content.slice(beginIdx + begin.length, endIdx).replace(/\s/g, ''); + let buf: Buffer; + try { + buf = Buffer.from(body, 'base64'); + } catch { + return false; + } + const magic = 'openssh-key-v1\0'; + if (buf.length < magic.length + 4) return false; + if (buf.subarray(0, magic.length).toString() !== magic) return false; + const cipherLen = buf.readUInt32BE(magic.length); + const cipher = buf.subarray(magic.length + 4, magic.length + 4 + cipherLen).toString(); + return cipher !== 'none'; +} diff --git a/src/main/services/infrastructure/SshHostResolver.ts b/src/main/services/infrastructure/SshHostResolver.ts new file mode 100644 index 00000000..5378961d --- /dev/null +++ b/src/main/services/infrastructure/SshHostResolver.ts @@ -0,0 +1,112 @@ +/** + * SshHostResolver - Defers host resolution to the system OpenSSH client. + * + * Why: `ssh -G ` runs the full OpenSSH config pipeline (Host blocks, + * Match directives, Include files, IdentityAgent, default identity files). + * Reusing OpenSSH's resolution avoids the "works in terminal, fails in app" + * class of bugs where our in-process parser drifts from the real client. + * + * The result is then fed to `ssh2` for the actual transport. + */ + +import { createLogger } from '@shared/utils/logger'; +import { execFile } from 'child_process'; +import * as os from 'os'; + +const logger = createLogger('Infrastructure:SshHostResolver'); + +export interface ResolvedSshHost { + hostname?: string; + port?: number; + user?: string; + identityFiles: string[]; + identityAgent?: string; +} + +const SSH_G_TIMEOUT_MS = 5000; + +export class SshHostResolver { + /** + * Resolves a host alias or hostname via `ssh -G`. Returns `null` if the + * `ssh` binary is missing or the call fails — callers should fall back to + * sensible defaults. + */ + async resolve(host: string): Promise { + try { + const stdout = await this.runSshG(host); + return this.parse(stdout); + } catch (err) { + logger.warn(`ssh -G failed for "${host}": ${(err as Error).message}`); + return null; + } + } + + private runSshG(host: string): Promise { + /* eslint-disable sonarjs/no-os-command-from-path -- + We resolve `ssh` via PATH because users routinely override it (Homebrew, + 1Password CLI shims, FIDO-aware builds) and pinning to /usr/bin/ssh would + miss those. The only argument we pass is the user-supplied host; execFile + (vs exec) avoids shell interpolation of it. */ + return new Promise((resolve, reject) => { + execFile( + 'ssh', + ['-G', host], + { timeout: SSH_G_TIMEOUT_MS, windowsHide: true }, + (err, stdout) => { + if (err) { + // ExecException extends Error at runtime, but TS narrows it to a + // structural shape, so wrap defensively to satisfy lint + types. + reject(err instanceof Error ? err : new Error(err.message ?? 'ssh -G failed')); + return; + } + resolve(stdout); + } + ); + }); + /* eslint-enable sonarjs/no-os-command-from-path -- ssh resolution scope ends here */ + } + + private parse(output: string): ResolvedSshHost { + const result: ResolvedSshHost = { + identityFiles: [], + }; + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const spaceIdx = trimmed.indexOf(' '); + if (spaceIdx === -1) continue; + const key = trimmed.slice(0, spaceIdx).toLowerCase(); + const value = trimmed.slice(spaceIdx + 1).trim(); + if (!value) continue; + + switch (key) { + case 'hostname': + result.hostname = value; + break; + case 'port': { + const port = parseInt(value, 10); + if (!Number.isNaN(port)) result.port = port; + break; + } + case 'user': + result.user = value; + break; + case 'identityfile': + result.identityFiles.push(expandTilde(value)); + break; + case 'identityagent': + if (value.toLowerCase() !== 'none') { + result.identityAgent = expandTilde(value); + } + break; + } + } + + return result; + } +} + +function expandTilde(p: string): string { + return p.replace(/^~(?=$|\/|\\)/, os.homedir()); +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 52a8840b..ca7449c8 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -28,5 +28,6 @@ export * from './ServiceContextRegistry'; export * from './SshConfigParser'; export * from './SshConnectionManager'; export * from './SshFileSystemProvider'; +export * from './SshHostResolver'; export * from './TriggerManager'; export * from './UpdaterService'; diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index 8da99046..cf9b8e29 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { normalizeSshAuthMethod } from '@shared/types'; import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react'; import { SettingRow } from '../components/SettingRow'; @@ -27,9 +28,7 @@ import type { } from '@shared/types'; const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [ - { value: 'auto', label: 'Auto (from SSH Config)' }, - { value: 'agent', label: 'SSH Agent' }, - { value: 'privateKey', label: 'Private Key' }, + { value: 'sshConfig', label: 'SSH Config (recommended)' }, { value: 'password', label: 'Password' }, ]; @@ -49,9 +48,8 @@ export const ConnectionSection = (): React.JSX.Element => { const [host, setHost] = useState(''); const [port, setPort] = useState('22'); const [username, setUsername] = useState(''); - const [authMethod, setAuthMethod] = useState('auto'); + const [authMethod, setAuthMethod] = useState('sshConfig'); const [password, setPassword] = useState(''); - const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa'); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); @@ -102,10 +100,7 @@ export const ConnectionSection = (): React.JSX.Element => { setHost(lastSshConfig.host); setPort(String(lastSshConfig.port)); setUsername(lastSshConfig.username); - setAuthMethod(lastSshConfig.authMethod); - if (lastSshConfig.privateKeyPath) { - setPrivateKeyPath(lastSshConfig.privateKeyPath); - } + setAuthMethod(normalizeSshAuthMethod(lastSshConfig.authMethod)); } // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time prefill when async data arrives }, [lastSshConfig]); @@ -128,8 +123,13 @@ export const ConnectionSection = (): React.JSX.Element => { // Filter config hosts based on input const filteredHosts = useMemo(() => { - if (!host.trim()) return sshConfigHosts; - const lower = host.toLowerCase(); + const lower = host.trim().toLowerCase(); + if (!lower) return sshConfigHosts; + // If the input exactly matches a known alias, the user either pre-filled + // it or just selected from the dropdown — show the full list so they can + // see other options at a glance instead of being narrowed to a single row. + const exactMatch = sshConfigHosts.some((e) => e.alias.toLowerCase() === lower); + if (exactMatch) return sshConfigHosts; return sshConfigHosts.filter( (entry) => entry.alias.toLowerCase().includes(lower) || entry.hostName?.toLowerCase().includes(lower) @@ -142,7 +142,7 @@ export const ConnectionSection = (): React.JSX.Element => { setHost(entry.alias); if (entry.port) setPort(String(entry.port)); if (entry.user) setUsername(entry.user); - setAuthMethod('auto'); + setAuthMethod('sshConfig'); setShowDropdown(false); setTestResult(null); clearProfileSelection(); @@ -152,8 +152,7 @@ export const ConnectionSection = (): React.JSX.Element => { setHost(profile.host); setPort(String(profile.port)); setUsername(profile.username); - setAuthMethod(profile.authMethod); - if (profile.privateKeyPath) setPrivateKeyPath(profile.privateKeyPath); + setAuthMethod(normalizeSshAuthMethod(profile.authMethod)); setPassword(''); setTestResult(null); setSelectedProfileId(profile.id); @@ -165,7 +164,6 @@ export const ConnectionSection = (): React.JSX.Element => { username, authMethod, password: authMethod === 'password' ? password : undefined, - privateKeyPath: authMethod === 'privateKey' ? privateKeyPath : undefined, }); const handleTest = async (): Promise => { @@ -235,7 +233,9 @@ export const ConnectionSection = (): React.JSX.Element => { {connectionError && (
-

{connectionError}

+
+            {connectionError}
+          
)} @@ -414,25 +414,13 @@ export const ConnectionSection = (): React.JSX.Element => { /> - {authMethod === 'privateKey' && ( -
- - setPrivateKeyPath(e.target.value)} - placeholder="~/.ssh/id_rsa" - className={inputClass} - style={inputStyle} - /> -
+ {authMethod === 'sshConfig' && ( +

+ Uses your ~/.ssh/config exactly like ssh {host || ''}{' '} + from a terminal (IdentityFile, IdentityAgent, agent forwarding all honored). Add an{' '} + IdentityFile to the host's config block, or run ssh-add + , if connection fails. +

)} {authMethod === 'password' && ( diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx index 2093f315..f00a5357 100644 --- a/src/renderer/components/settings/sections/WorkspaceSection.tsx +++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx @@ -16,6 +16,7 @@ import { api } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { useStore } from '@renderer/store'; import { generateUUID } from '@renderer/utils/stringUtils'; +import { normalizeSshAuthMethod } from '@shared/types'; import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; @@ -31,9 +32,7 @@ const inputStyle = { }; const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [ - { value: 'auto', label: 'Auto (from SSH Config)' }, - { value: 'agent', label: 'SSH Agent' }, - { value: 'privateKey', label: 'Private Key' }, + { value: 'sshConfig', label: 'SSH Config (recommended)' }, { value: 'password', label: 'Password' }, ]; @@ -42,8 +41,7 @@ const defaultForm = { host: '', port: '22', username: '', - authMethod: 'auto' as SshAuthMethod, - privateKeyPath: '', + authMethod: 'sshConfig' as SshAuthMethod, }; export const WorkspaceSection = (): React.JSX.Element => { @@ -58,7 +56,6 @@ export const WorkspaceSection = (): React.JSX.Element => { const [formPort, setFormPort] = useState(defaultForm.port); const [formUsername, setFormUsername] = useState(defaultForm.username); const [formAuthMethod, setFormAuthMethod] = useState(defaultForm.authMethod); - const [formPrivateKeyPath, setFormPrivateKeyPath] = useState(defaultForm.privateKeyPath); const resetForm = useCallback(() => { setFormName(defaultForm.name); @@ -66,7 +63,6 @@ export const WorkspaceSection = (): React.JSX.Element => { setFormPort(defaultForm.port); setFormUsername(defaultForm.username); setFormAuthMethod(defaultForm.authMethod); - setFormPrivateKeyPath(defaultForm.privateKeyPath); }, []); const loadProfiles = useCallback(async () => { @@ -95,8 +91,7 @@ export const WorkspaceSection = (): React.JSX.Element => { setFormHost(profile.host); setFormPort(String(profile.port)); setFormUsername(profile.username); - setFormAuthMethod(profile.authMethod); - setFormPrivateKeyPath(profile.privateKeyPath ?? ''); + setFormAuthMethod(normalizeSshAuthMethod(profile.authMethod)); } } }, [editingId, profiles]); @@ -109,7 +104,6 @@ export const WorkspaceSection = (): React.JSX.Element => { port: parseInt(formPort, 10) || 22, username: formUsername.trim(), authMethod: formAuthMethod, - privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined, }; await api.config.update('ssh', { profiles: [...profiles, newProfile] }); @@ -129,7 +123,7 @@ export const WorkspaceSection = (): React.JSX.Element => { port: parseInt(formPort, 10) || 22, username: formUsername.trim(), authMethod: formAuthMethod, - privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined, + privateKeyPath: undefined, } : p ); @@ -261,25 +255,11 @@ export const WorkspaceSection = (): React.JSX.Element => { /> - {formAuthMethod === 'privateKey' && ( -
- - setFormPrivateKeyPath(e.target.value)} - placeholder="~/.ssh/id_rsa" - className={inputClass} - style={inputStyle} - /> -
+ {formAuthMethod === 'sshConfig' && ( +

+ Authentication is delegated to your ~/.ssh/config (IdentityFile, + IdentityAgent, agent forwarding all honored). +

)} {formAuthMethod === 'password' && ( diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts index a16e3f6c..37a35980 100644 --- a/src/renderer/store/slices/connectionSlice.ts +++ b/src/renderer/store/slices/connectionSlice.ts @@ -114,7 +114,6 @@ export const createConnectionSlice: StateCreator` from a terminal would. */ -export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto'; +export function normalizeSshAuthMethod(raw: unknown): SshAuthMethod { + return raw === 'password' ? 'password' : 'sshConfig'; +} /** * SSH config host entry resolved from ~/.ssh/config. @@ -236,6 +254,11 @@ export interface SshConfigHostEntry { /** * SSH connection configuration sent from renderer. + * + * Identity files / agent sockets are not part of this payload — `sshConfig` + * mode resolves them via the system `ssh` binary. The `privateKeyPath` field + * is retained as an optional legacy carry-over so older persisted profiles + * still type-check; it is ignored at connect time. */ export interface SshConnectionConfig { host: string; @@ -243,6 +266,7 @@ export interface SshConnectionConfig { username: string; authMethod: SshAuthMethod; password?: string; + /** @deprecated Replaced by IdentityFile resolution via `ssh -G`. Ignored. */ privateKeyPath?: string; } @@ -256,6 +280,7 @@ export interface SshConnectionProfile { port: number; username: string; authMethod: SshAuthMethod; + /** @deprecated Replaced by IdentityFile resolution via `ssh -G`. Ignored. */ privateKeyPath?: string; } @@ -280,6 +305,7 @@ export interface SshLastConnection { port: number; username: string; authMethod: SshAuthMethod; + /** @deprecated Replaced by IdentityFile resolution via `ssh -G`. Ignored. */ privateKeyPath?: string; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 41e53ef1..1292fd61 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -20,3 +20,4 @@ export type * from './visualization'; // Re-export API types (ElectronAPI, ConfigAPI, etc.) export type * from './api'; +export { normalizeSshAuthMethod } from './api'; diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 766d7044..a285a650 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -290,7 +290,7 @@ export interface AppConfig { host: string; port: number; username: string; - authMethod: 'password' | 'privateKey' | 'agent' | 'auto'; + authMethod: 'password' | 'sshConfig'; privateKeyPath?: string; } | null; /** Whether to auto-reconnect on launch */ @@ -302,7 +302,7 @@ export interface AppConfig { host: string; port: number; username: string; - authMethod: 'password' | 'privateKey' | 'agent' | 'auto'; + authMethod: 'password' | 'sshConfig'; privateKeyPath?: string; }[]; /** Last active context ID */