hermes-agent/apps/desktop/electron/ssh-config.cjs
yoniebans 7d1afaa769 feat(desktop): connection-config ssh mode plumbing
Wire mode:"ssh" through the desktop connection resolution chain so an SSH
remote resolves into the EXISTING token-remote machinery — SSH mode is
desktop-local mode with the loopback stretched over SSH.

connection-config.cjs (pure, node --test):
- normalizeSshConfig() validates a {mode:ssh, host, user?, port?, keyPath?,
  remoteHermesPath?} entry (drops the default port, requires a host).
- profileSshOverride() resolves a profile-scoped SSH entry.
- hostLabelFromBaseUrl() derives the pill host for token/oauth remotes.

ssh-config.cjs (pure, node --test): parse ~/.ssh/config host aliases (follows
Include, filters wildcard/negated patterns, read-only, cycle-safe) and parse
ssh -G output (hostname/user/port/identityfile) for the settings UI.

main.cjs:
- readDesktopConnectionConfig / sanitizeConnectionProfiles preserve mode:ssh
  and the SSH fields; coerceDesktopConnectionConfig + buildSshBlock build/save
  SSH blocks (no user token; the dashboard token rides separately, encrypted).
- resolveRemoteBackend gains per-profile and global SSH branches that
  bootstrap via SshConnection + remote-lifecycle.connect(), persist the served
  token (encrypted), and hand buildRemoteConnection a 127.0.0.1 tunnel baseUrl
  with the SSH host as the pill label.
- buildRemoteConnection gains a remoteHost param + remoteHost on every
  descriptor (token/oauth pill host derived from the real URL).
- SSH connection-state registry (master + tunnel ports + remote pid per scope);
  teardownSshConnection cancels the forward + closes the master but LEAVES the
  remote dashboard running (reconnect-instant VS Code semantics); wired into
  before-quit and connection-config:apply (flip = re-bootstrap).
- testDesktopConnectionConfig SSH branch: ssh open + uname gate + locate-hermes
  WITHOUT spawning, returning distinct unreachable/auth-failed/hermes-not-found/
  unsupported-platform errors. New IPC: ssh-hosts, ssh-resolve. preload +
  global.d.ts updated (HermesConnection.remoteHost, SSH config/test/resolve
  types).

node --test: ssh-connection 23, remote-lifecycle 24, ssh-config 9,
connection-config 55 — 111 pass. (tsc/vitest deferred: no node_modules in the
worktree; renderer typecheck to be run by the user in a populated tree.)
2026-06-23 13:22:14 +02:00

137 lines
4.6 KiB
JavaScript

/**
* ssh-config.cjs
*
* Pure, electron-free helpers for reading the user's OpenSSH client config:
* - parseSshConfigHosts(text): extract concrete `Host` aliases for the
* settings UI's host suggestions, filtering wildcard/negated patterns.
* - collectSshConfigHosts(rootPath, deps): read ~/.ssh/config and follow
* `Include` directives (read-only — we NEVER write that file).
* - parseSshGOutput(text): parse `ssh -G <host>` key/value output into the
* resolved hostname/user/port/identityfile for display + normalization.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test`. main.cjs requires this and wires the fs + `ssh -G` exec in.
*/
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
// Pull concrete host aliases out of an ssh_config body. A `Host` line can list
// several patterns; we keep only literal aliases (no `*`, `?`, or `!` negation)
// since those are the ones a user can actually connect to by name.
function parseSshConfigHosts(text) {
const hosts = []
const seen = new Set()
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = /^host\s+(.+)$/i.exec(line)
if (!m) continue
for (const pattern of m[1].split(/\s+/)) {
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
continue
}
if (!seen.has(pattern)) {
seen.add(pattern)
hosts.push(pattern)
}
}
}
return hosts
}
// Extract `Include` paths from an ssh_config body (relative paths resolve under
// ~/.ssh). Globs are expanded by the caller's fs deps when supported; here we
// just return the raw tokens for the collector to resolve.
function parseSshConfigIncludes(text) {
const includes = []
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = /^include\s+(.+)$/i.exec(line)
if (!m) continue
for (const token of m[1].split(/\s+/)) {
if (token) includes.push(token)
}
}
return includes
}
// Read ~/.ssh/config and any files it Includes, returning a de-duplicated list
// of concrete host aliases. Read-only; bounded include depth to avoid cycles.
// `deps` injects { readFile, homeDir, globSync } for tests.
function collectSshConfigHosts(rootPath, deps = {}) {
const readFile =
deps.readFile ||
(p => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
})
const homeDir = deps.homeDir || os.homedir()
const root = rootPath || path.join(homeDir, '.ssh', 'config')
const sshDir = path.join(homeDir, '.ssh')
const out = []
const seen = new Set()
const visited = new Set()
const resolveIncludePath = token => {
if (token.startsWith('~/')) return path.join(homeDir, token.slice(2))
if (path.isAbsolute(token)) return token
return path.join(sshDir, token)
}
const walk = (filePath, depth) => {
if (depth > 8 || visited.has(filePath)) return
visited.add(filePath)
const text = readFile(filePath)
if (text == null) return
for (const host of parseSshConfigHosts(text)) {
if (!seen.has(host)) {
seen.add(host)
out.push(host)
}
}
for (const token of parseSshConfigIncludes(text)) {
const target = resolveIncludePath(token)
// Optional glob expansion (token may contain * — e.g. config.d/*).
const expanded = deps.globSync ? deps.globSync(target) : [target]
for (const p of expanded) {
walk(p, depth + 1)
}
}
}
walk(root, 0)
return out
}
// Parse `ssh -G <host>` output. Keys are lowercased by ssh; we surface the ones
// the settings UI cares about. Returns { hostname, user, port, identityFile }.
function parseSshGOutput(text) {
const out = { hostname: null, user: null, port: null, identityFile: null }
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line) continue
const sp = line.indexOf(' ')
if (sp === -1) continue
const key = line.slice(0, sp).toLowerCase()
const value = line.slice(sp + 1).trim()
if (key === 'hostname' && !out.hostname) out.hostname = value
else if (key === 'user' && !out.user) out.user = value
else if (key === 'port' && !out.port) out.port = Number.parseInt(value, 10) || null
else if (key === 'identityfile' && !out.identityFile) out.identityFile = value
}
return out
}
module.exports = {
collectSshConfigHosts,
parseSshConfigHosts,
parseSshConfigIncludes,
parseSshGOutput
}