Files
hermes-agent/apps/desktop/electron/ssh-binary.ts
Austin Pickett a35f600b8a fix(desktop): resolve the Windows ssh client instead of hard-coding System32 OpenSSH
All ssh spawns (the -G config probe, the ssh-config:resolve IPC, SSH
terminals, and SshConnection) now go through one pure resolver:
desktop.ssh_path in config.yaml, then System32 OpenSSH, then Git for
Windows' usr\bin\ssh.exe, then bare ssh. Non-Windows keeps bare ssh.

A failed local ssh -G probe is now tagged terminal: boot latches on the
failure overlay (naming the client and the ssh_path setting) instead of
re-driving the same probe every ~2s.

Fixes #103288
2026-09-29 22:56:15 -04:00

78 lines
2.7 KiB
TypeScript

// Which ssh client the desktop spawns (#103288).
//
// Windows used to hard-code %SystemRoot%\System32\OpenSSH\ssh.exe for the
// `ssh -G` probes and the SSH terminal, while SshConnection spawned a bare
// `ssh` off PATH. When the in-box OpenSSH component is missing or broken
// (component-store corruption after an update makes every native ssh.exe exit
// 255 with no output), no config could point the desktop at a working client
// such as Git for Windows' bundled `usr\bin\ssh.exe`.
//
// Every ssh spawn now resolves through resolveSshBinary. It is pure: platform,
// env slice and filesystem probe are injected so vitest can drive each branch.
// Non-Windows always gets bare `ssh`, exactly as before.
import path from 'node:path'
import { type GitCandidateFs, windowsGitCandidates } from './git-binary-candidates'
/** Windows env slice the resolver reads (injectable for tests). */
export interface WindowsSshEnv {
localAppData: string
programFiles: string
programFilesX86: string
systemRoot: string
}
export interface SshBinaryInputs {
platform: string
/** `desktop.ssh_path` from config.yaml; empty when unset. Windows only. */
override?: string
env: WindowsSshEnv
fs: GitCandidateFs
}
/** The in-box Windows OpenSSH client under a Windows root. */
export function system32OpenSsh(systemRoot: string): string {
return path.win32.join(systemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
}
/**
* Git-for-Windows ssh.exe candidates, derived from the same install list
* resolveGitBinary uses (Hermes PortableGit, UGit, Program Files, per-user).
* Git ships its MSYS OpenSSH at `<git root>\usr\bin\ssh.exe`, and every git
* candidate is `<git root>\{cmd,bin}\git.exe`.
*/
export function gitForWindowsSshCandidates(env: WindowsSshEnv, fs: GitCandidateFs): string[] {
const roots = windowsGitCandidates(env, fs).map(gitExe => path.win32.dirname(path.win32.dirname(gitExe)))
return [...new Set(roots)].map(root => path.win32.join(root, 'usr', 'bin', 'ssh.exe'))
}
/**
* The ssh executable to spawn.
*
* Windows, in order: an explicit `desktop.ssh_path` (returned as-is so a typo
* fails loudly with its own path instead of silently using another client),
* then the in-box System32 OpenSSH, then Git for Windows' bundled ssh.exe,
* then bare `ssh` for PATH lookup. Every other platform: bare `ssh`.
*/
export function resolveSshBinary({ platform, override, env, fs }: SshBinaryInputs): string {
if (platform !== 'win32') {
return 'ssh'
}
const explicit = String(override ?? '').trim()
if (explicit) {
return explicit
}
const inbox = system32OpenSsh(env.systemRoot)
if (fs.existsSync(inbox)) {
return inbox
}
return gitForWindowsSshCandidates(env, fs).find(candidate => fs.existsSync(candidate)) || 'ssh'
}