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.)
This commit is contained in:
yoniebans 2026-06-19 11:47:36 +02:00
parent cc24de2caa
commit 7d1afaa769
7 changed files with 776 additions and 14 deletions

View file

@ -269,6 +269,71 @@ function cookiesHaveLiveSession(cookies) {
)
}
/**
* Normalize a stored SSH connection entry into a clean descriptor, or null when
* it is not a usable SSH config. Pure: no secrets here — the per-connection
* dashboard token is persisted separately (encrypted) and decrypted by main.cjs,
* exactly like the token-remote secret. An SSH entry needs at least a host.
*
* Shape in/out: { mode:'ssh', host, user?, port?, keyPath?, remoteHermesPath? }
*/
function normalizeSshConfig(entry) {
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
return null
}
const host = String(entry.host || '').trim()
if (!host) {
return null
}
const out = { mode: 'ssh', host }
const user = String(entry.user || '').trim()
if (user) out.user = user
const port = Number.parseInt(String(entry.port ?? ''), 10)
if (Number.isInteger(port) && port > 0 && port !== 22) {
out.port = port
}
const keyPath = String(entry.keyPath || '').trim()
if (keyPath) out.keyPath = keyPath
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath
return out
}
/**
* Select a profile's SSH connection override from a connection config, or null
* when it has none. Mirrors profileRemoteOverride() but for `mode: 'ssh'`
* entries. Returns the normalized SSH descriptor (no token).
*/
function profileSshOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
return normalizeSshConfig(entry)
}
/**
* Human-facing host label for the connection statusbar pill. For SSH mode the
* caller passes the resolved/entered host directly; for token/oauth remotes we
* derive it from the (real) backend URL — NOT the loopback tunnel URL. Returns
* a bare hostname (and :port when non-default) or null.
*/
function hostLabelFromBaseUrl(baseUrl) {
const raw = String(baseUrl || '').trim()
if (!raw) return null
let parsed
try {
parsed = new URL(raw)
} catch {
return null
}
const host = parsed.hostname
if (!host) return null
const port = parsed.port
if (port && port !== '80' && port !== '443') {
return `${host}:${port}`
}
return host
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
@ -278,10 +343,13 @@ module.exports = {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview

View file

@ -22,10 +22,13 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@ -394,3 +397,51 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
/mintTicket function is required/
)
})
// --- SSH mode helpers ---
test('normalizeSshConfig requires mode:ssh and a host', () => {
assert.equal(normalizeSshConfig(null), null)
assert.equal(normalizeSshConfig({ mode: 'remote', url: 'http://x' }), null)
assert.equal(normalizeSshConfig({ mode: 'ssh' }), null)
assert.equal(normalizeSshConfig({ mode: 'ssh', host: ' ' }), null)
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box' }), { mode: 'ssh', host: 'box' })
})
test('normalizeSshConfig keeps user/keyPath/remoteHermesPath and drops the default port', () => {
assert.deepEqual(
normalizeSshConfig({
mode: 'ssh',
host: 'box',
user: 'me',
port: 22,
keyPath: '~/.ssh/id_ed25519',
remoteHermesPath: '/opt/hermes'
}),
{ mode: 'ssh', host: 'box', user: 'me', keyPath: '~/.ssh/id_ed25519', remoteHermesPath: '/opt/hermes' }
)
})
test('normalizeSshConfig preserves a non-default port', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 2222 }), {
mode: 'ssh',
host: 'box',
port: 2222
})
})
test('profileSshOverride returns a profile-scoped ssh descriptor or null', () => {
const config = { profiles: { work: { mode: 'ssh', host: 'mac-mini', user: 'jonny' }, other: { mode: 'remote', url: 'http://x' } } }
assert.deepEqual(profileSshOverride(config, 'work'), { mode: 'ssh', host: 'mac-mini', user: 'jonny' })
assert.equal(profileSshOverride(config, 'other'), null, 'token-remote entry is not an ssh override')
assert.equal(profileSshOverride(config, 'missing'), null)
assert.equal(profileSshOverride(config, ''), null, 'global scope has no profile entry')
})
test('hostLabelFromBaseUrl gives a bare host, with :port only when non-default', () => {
assert.equal(hostLabelFromBaseUrl('https://box.tail1234.ts.net'), 'box.tail1234.ts.net')
assert.equal(hostLabelFromBaseUrl('http://box.local:8080'), 'box.local:8080')
assert.equal(hostLabelFromBaseUrl('https://box:443'), 'box')
assert.equal(hostLabelFromBaseUrl(''), null)
assert.equal(hostLabelFromBaseUrl('not a url'), null)
})

View file

@ -38,6 +38,9 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { SSH_ERROR, SshConnection, pickLocalPort, redactSecrets } = require('./ssh-connection.cjs')
const remoteLifecycle = require('./remote-lifecycle.cjs')
const { collectSshConfigHosts, parseSshGOutput } = require('./ssh-config.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
@ -74,10 +77,13 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@ -4300,6 +4306,20 @@ function sanitizeConnectionProfiles(raw) {
continue
}
// SSH-mode entries carry host/user/port/keyPath/remoteHermesPath instead of
// a url, and (like remote entries) an encrypted token blob — the per-
// connection dashboard session token minted in main, NOT a user secret.
if (entry.mode === 'ssh') {
const ssh = normalizeSshConfig(entry)
if (ssh) {
if (entry.token && typeof entry.token === 'object') {
ssh.token = entry.token
}
out[name] = ssh
}
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
@ -4343,7 +4363,10 @@ function readDesktopConnectionConfig() {
// backward compatibility with configs written before OAuth support.
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
// 'ssh' joins 'remote'/'local' as a top-level mode; SSH connection
// fields (host/user/port/keyPath/remoteHermesPath) ride on the `remote`
// sub-object, which is preserved verbatim below.
mode: parsed.mode === 'remote' ? 'remote' : parsed.mode === 'ssh' ? 'ssh' : 'local',
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
@ -4411,10 +4434,30 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
const scopedMode = key ? scoped?.mode : config.mode
// SSH-mode block: surface the connection fields (no token to the renderer —
// it's an internal artifact). remoteTokenSet reports whether a dashboard
// token has already been adopted (i.e. a running dashboard can be reused).
if (scopedMode === 'ssh') {
const sshConfig = normalizeSshConfig({ mode: 'ssh', ...block })
return {
mode: 'ssh',
profile: key,
sshHost: sshConfig?.host || '',
sshUser: sshConfig?.user || '',
sshPort: sshConfig?.port || null,
sshKeyPath: sshConfig?.keyPath || '',
sshRemoteHermesPath: sshConfig?.remoteHermesPath || '',
remoteTokenSet: Boolean(decryptDesktopSecret(block.token)),
envOverride: false
}
}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
const mode = envOverride || scopedMode === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@ -4457,7 +4500,21 @@ function buildRemoteBlock(remoteUrl, authMode, token) {
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : 'local'
const mode = input.mode === 'remote' ? 'remote' : input.mode === 'ssh' ? 'ssh' : 'local'
// SSH-mode save: connection fields are host/user/port/keyPath/remoteHermesPath
// (no user-entered token; the dashboard token is minted + reconciled at
// bootstrap and persisted separately). A saved SSH block preserves any
// already-adopted token so a reconnect can reuse the running dashboard.
if (mode === 'ssh') {
const sshBlock = buildSshBlock(input, key ? existing.profiles?.[key] || {} : existing.remote || {})
if (key) {
const profiles = { ...(existing.profiles || {}) }
profiles[key] = sshBlock
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
}
return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} }
}
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
@ -4480,7 +4537,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
} else {
delete profiles[key]
}
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
}
const nextRemote =
@ -4492,13 +4549,41 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
// Build an SSH connection block from a save payload, preserving an
// already-adopted dashboard token from the existing block (the token is minted
// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so
// normalizeSshConfig/profileSshOverride recognize it.
function buildSshBlock(input, existingBlock = {}) {
const merged = normalizeSshConfig({
mode: 'ssh',
host: input.sshHost ?? existingBlock.host,
user: input.sshUser ?? existingBlock.user,
port: input.sshPort ?? existingBlock.port,
keyPath: input.sshKeyPath ?? existingBlock.keyPath,
remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath
})
if (!merged) {
throw new Error('SSH host is required.')
}
// Carry forward an already-adopted dashboard token unless the host changed
// (a different host invalidates the old dashboard's token).
if (existingBlock.token && existingBlock.host === merged.host) {
merged.token = existingBlock.token
}
return merged
}
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source) {
async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
// For token/oauth remotes the meaningful host is the real backend URL; for
// SSH remotes the caller passes the entered/resolved host explicitly (the
// baseUrl is a 127.0.0.1 tunnel and would be useless in the pill).
const host = remoteHost || hostLabelFromBaseUrl(baseUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookies in the OAuth
@ -4535,6 +4620,7 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
mode: 'remote',
source,
authMode: 'oauth',
remoteHost: host || undefined,
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
@ -4553,11 +4639,166 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
mode: 'remote',
source,
authMode: 'token',
remoteHost: host || undefined,
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// ---------------------------------------------------------------------------
// SSH remote-mode bootstrap
//
// SSH mode is architecturally desktop-local mode with the loopback stretched
// over SSH: open a ControlMaster, bring up (or reuse) a dedicated --isolated
// dashboard on the remote, forward 127.0.0.1:<local> -> 127.0.0.1:<remote>,
// then hand the EXISTING token-remote machinery a 127.0.0.1 baseUrl. Everything
// downstream (REST bridge, /api/ws, sessions, /api/fs/*, version/update pills)
// is unchanged — it keys off the connection descriptor, not how it was made.
// ---------------------------------------------------------------------------
// Live SSH connections keyed by scope ('' for global, or the profile name).
// Holds the SshConnection (the control master), the tunnel ports, and the
// remote pid so liveness/reconnect/teardown can find them. Survives across
// resolveRemoteBackend calls within one app run.
const sshConnections = new Map()
function sshScopeKey(profile) {
return connectionScopeKey(profile) || ''
}
// Redaction-wrapped logger so NOTHING that flows through the SSH lifecycle
// (spawn command lines carry the session token) reaches desktop.log raw.
function sshRememberLog(chunk) {
rememberLog(redactSecrets(String(chunk == null ? '' : chunk)))
}
// Authenticated GET /api/status through the tunnel — the authoritative reuse
// probe. True iff the dashboard answers ok with this token.
async function sshProbeStatus(baseUrl, token) {
try {
await fetchJson(`${baseUrl}/api/status`, token)
return true
} catch {
return false
}
}
// Tear down a scope's SSH state: cancel the forward, close the master, forget
// it. Leaves the REMOTE dashboard running (reconnect is instant; in-flight
// agent turns survive a client drop) — that is the VS Code semantics the spec
// chose. The lockfile reuse flow recovers it on next connect.
async function teardownSshConnection(profile) {
const scope = sshScopeKey(profile)
const state = sshConnections.get(scope)
if (!state) return
sshConnections.delete(scope)
try {
if (state.localPort && state.remotePort) {
await state.ssh.cancelForward(state.localPort, state.remotePort)
}
} catch {
// best effort
}
try {
await state.ssh.close()
} catch {
// best effort
}
}
// Bring up (or reuse) the SSH-tunneled dashboard for one scope and return a
// token-remote connection descriptor. `sshConfig` is the normalized
// { host, user?, port?, keyPath?, remoteHermesPath? }; `reuseToken` is the
// decrypted per-connection token from encrypted storage (or '').
async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {
const scope = sshScopeKey(profile)
const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
// Reuse a live master for this scope if we still have one; otherwise open
// fresh. A dead master (sleep/network flap) is closed and reopened.
let ssh = sshConnections.get(scope)?.ssh
if (ssh && !(await ssh.isAlive())) {
try {
await ssh.close()
} catch {
// ignore
}
ssh = null
sshConnections.delete(scope)
}
if (!ssh) {
ssh = new SshConnection(
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
{ rememberLog: sshRememberLog }
)
await ssh.open()
}
let result
try {
result = await remoteLifecycle.connect({
ssh,
profile: connectionScopeKey(profile) || '',
remoteHermesPath: sshConfig.remoteHermesPath || '',
clientId: scope || 'default',
reuseToken: reuseToken || '',
forward: (localPort, remotePort) => ssh.forward(localPort, remotePort),
cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort),
pickLocalPort,
waitForHermes,
probeStatus: sshProbeStatus,
adoptServedToken: adoptServedDashboardToken,
rememberLog: sshRememberLog
})
} catch (error) {
// Map lifecycle/SSH failures into a single actionable message; the boot
// overlay shows this verbatim instead of the generic gateway error.
const err = new Error(error.message)
err.sshError = error.kind || 'unknown'
err.isSshBootstrap = true
throw err
}
// Persist the served token (encrypted) so the next launch can reuse this
// dashboard via the lockfile fingerprint without re-bootstrapping.
persistSshConnectionToken(profile, source, result.token)
sshConnections.set(scope, {
ssh,
localPort: result.localPort,
remotePort: result.remotePort,
pid: result.pid,
host: sshConfig.host,
hostLabel
})
// Hand the existing token-remote machinery the loopback baseUrl. The pill's
// host is the SSH host, NOT 127.0.0.1.
return buildRemoteConnection(result.baseUrl, 'token', result.token, source, hostLabel)
}
// Save the served token back into the SSH connection entry (encrypted), so a
// later launch reuses the running dashboard. Global SSH lives under
// config.remote; a per-profile SSH override lives under config.profiles[name].
function persistSshConnectionToken(profile, source, token) {
try {
const config = readDesktopConnectionConfig()
const encrypted = encryptDesktopSecret(token)
if (source === 'profile') {
const key = connectionScopeKey(profile)
if (key && config.profiles?.[key]?.mode === 'ssh') {
config.profiles[key].token = encrypted
writeDesktopConnectionConfig(config)
}
} else if (config.mode === 'ssh' && config.remote) {
config.remote.token = encrypted
writeDesktopConnectionConfig(config)
}
} catch (error) {
sshRememberLog(`[ssh] could not persist served token: ${error.message}`)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
@ -4571,6 +4812,12 @@ async function resolveRemoteBackend(profile) {
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const sshOverride = profileSshOverride(config, profile)
if (sshOverride) {
const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token)
return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')
}
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
@ -4591,6 +4838,17 @@ async function resolveRemoteBackend(profile) {
}
// 3. Global remote.
// 3a. Global SSH remote — bootstrap the tunnel + dashboard, hand the
// token-remote machinery a loopback baseUrl.
if (config.mode === 'ssh') {
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
if (!ssh) {
throw new Error('SSH remote mode is selected but no host is configured. Open Settings → Gateway → Connect via SSH.')
}
const reuseToken = decryptDesktopSecret(config.remote?.token)
return bootstrapSshConnection(null, ssh, reuseToken, 'settings')
}
if (config.mode !== 'remote') {
return null
}
@ -4701,6 +4959,52 @@ async function probeRemoteAuthMode(rawUrl) {
}
async function testDesktopConnectionConfig(input = {}) {
// SSH mode: test reachability + that hermes is locatable on a supported
// platform, WITHOUT spawning a dashboard. Distinct errors for unreachable /
// auth-failed / hermes-not-found / unsupported-platform.
if (input.mode === 'ssh') {
const sshConfig = normalizeSshConfig({
mode: 'ssh',
host: input.sshHost,
user: input.sshUser,
port: input.sshPort,
keyPath: input.sshKeyPath,
remoteHermesPath: input.sshRemoteHermesPath
})
if (!sshConfig) {
return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }
}
const ssh = new SshConnection(
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
{ rememberLog: sshRememberLog }
)
try {
await ssh.open()
const platform = await remoteLifecycle.probeRemotePlatform(ssh)
const hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')
return {
reachable: true,
sshError: null,
error: null,
remotePlatform: `${platform.os}/${platform.arch}`,
remoteHermesPath: hermesPath,
host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
}
} catch (error) {
return {
reachable: false,
sshError: error.kind || 'unknown',
error: error.message
}
} finally {
try {
await ssh.close()
} catch {
// best effort — a transient test connection
}
}
}
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
@ -5618,6 +5922,51 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:ssh-hosts', async () => {
// Read-only host suggestions from ~/.ssh/config (+ Includes). Never writes.
try {
return { hosts: collectSshConfigHosts() }
} catch {
return { hosts: [] }
}
})
ipcMain.handle('hermes:connection-config:ssh-resolve', async (_event, host) => {
// Resolve the effective target with `ssh -G <host>` (short timeout) so the
// UI can show/normalize the real hostname/user/port/identityfile a host
// alias expands to. Best-effort: a failure returns nulls, not an error.
const target = String(host || '').trim()
if (!target) return { hostname: null, user: null, port: null, identityFile: null }
return new Promise(resolve => {
let out = ''
let settled = false
const child = spawn('ssh', ['-G', target], { stdio: ['ignore', 'pipe', 'ignore'] })
const timer = setTimeout(() => {
if (settled) return
settled = true
try {
child.kill('SIGKILL')
} catch {
// already gone
}
resolve({ hostname: null, user: null, port: null, identityFile: null })
}, 5_000)
child.stdout.on('data', d => {
out += d.toString()
})
child.on('error', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ hostname: null, user: null, port: null, identityFile: null })
})
child.on('close', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(parseSshGOutput(out))
})
})
})
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
@ -5648,6 +5997,10 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const key = connectionScopeKey(payload?.profile)
// A connection change for this scope invalidates any live SSH tunnel for it —
// tear it down so the next resolve re-bootstraps against the new target.
await teardownSshConnection(key || null)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
@ -6798,6 +7151,13 @@ app.on('before-quit', () => {
hermesProcess.kill('SIGTERM')
}
stopAllPoolBackends()
// Close SSH control masters (best-effort, fire-and-forget). The REMOTE
// dashboards are intentionally LEFT running so a relaunch reconnects via the
// lockfile reuse flow without re-bootstrapping (VS Code semantics).
for (const scope of [...sshConnections.keys()]) {
void teardownSshConnection(scope || null)
}
})
app.on('window-all-closed', () => {

View file

@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
sshConfigHosts: () => ipcRenderer.invoke('hermes:connection-config:ssh-hosts'),
sshResolveHost: host => ipcRenderer.invoke('hermes:connection-config:ssh-resolve', host),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),

View file

@ -0,0 +1,137 @@
/**
* 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
}

View file

@ -0,0 +1,107 @@
/**
* Tests for electron/ssh-config.cjs.
*
* Run with: node --test electron/ssh-config.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
collectSshConfigHosts,
parseSshConfigHosts,
parseSshConfigIncludes,
parseSshGOutput
} = require('./ssh-config.cjs')
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
const cfg = [
'Host mac-mini',
' HostName 10.0.0.5',
'Host *.internal prod !staging glob*',
'Host alpha beta',
'# Host commented-out',
'host lower-case'
].join('\n')
assert.deepEqual(parseSshConfigHosts(cfg), ['mac-mini', 'prod', 'alpha', 'beta', 'lower-case'])
})
test('parseSshConfigHosts de-duplicates', () => {
assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other'])
})
test('parseSshConfigIncludes extracts include tokens', () => {
const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored'
assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])
})
test('collectSshConfigHosts follows Include directives (read-only)', () => {
const files = {
'/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc',
'/home/u/.ssh/work': 'Host work-box\nInclude nested',
'/home/u/.ssh/nested': 'Host deep',
'/home/u/abs_inc': 'Host home-abs'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
})
test('collectSshConfigHosts tolerates a missing config file', () => {
assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])
})
test('collectSshConfigHosts does not loop on a self-include cycle', () => {
const files = {
'/home/u/.ssh/config': 'Host a\nInclude loop',
'/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['a', 'b'])
})
test('collectSshConfigHosts expands globbed includes via injected globSync', () => {
const files = {
'/home/u/.ssh/config': 'Host root\nInclude config.d/*',
'/home/u/.ssh/config.d/10-work': 'Host work',
'/home/u/.ssh/config.d/20-home': 'Host home'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null,
globSync: pattern =>
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
})
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
})
test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
const out = [
'host mac-mini',
'hostname 10.0.0.5',
'user jonny',
'port 2222',
'identityfile ~/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
assert.deepEqual(parseSshGOutput(out), {
hostname: '10.0.0.5',
user: 'jonny',
port: 2222,
identityFile: '~/.ssh/id_ed25519'
})
})
test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {
const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b'
const parsed = parseSshGOutput(out)
assert.equal(parsed.identityFile, '~/.ssh/a')
assert.equal(parsed.user, null)
assert.equal(parsed.port, null)
})

View file

@ -31,6 +31,8 @@ declare global {
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
sshConfigHosts: () => Promise<DesktopSshHostsResult>
sshResolveHost: (host: string) => Promise<DesktopSshResolveResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
@ -283,6 +285,10 @@ export interface HermesConnection {
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
// Human-facing host for the statusbar connection pill. For SSH remotes this
// is the user@host the tunnel reaches; for token/oauth remotes it's the host
// parsed from the real backend URL. Absent in local mode.
remoteHost?: string
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
@ -313,31 +319,62 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
mode: 'local' | 'remote' | 'ssh'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteAuthMode?: 'oauth' | 'token'
remoteOauthConnected?: boolean
remoteTokenPreview?: string | null
remoteTokenSet: boolean
remoteUrl: string
remoteUrl?: string
// SSH mode fields (present when mode === 'ssh'). No token is surfaced — the
// dashboard session token is an internal artifact reconciled at bootstrap.
sshHost?: string
sshUser?: string
sshPort?: number | null
sshKeyPath?: string
sshRemoteHermesPath?: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
mode: 'local' | 'remote' | 'ssh'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
// SSH mode input fields.
sshHost?: string
sshUser?: string
sshPort?: number | null
sshKeyPath?: string
sshRemoteHermesPath?: string
}
export interface DesktopConnectionTestResult {
baseUrl: string
ok: boolean
version: string | null
baseUrl?: string
ok?: boolean
version?: string | null
// SSH-mode test result fields.
reachable?: boolean
sshError?: 'unreachable' | 'auth-failed' | 'host-key-changed' | 'hermes-not-found' | 'unsupported-platform' | 'timeout' | 'unknown' | null
error?: string | null
remotePlatform?: string
remoteHermesPath?: string
host?: string
}
export interface DesktopSshResolveResult {
hostname: string | null
user: string | null
port: number | null
identityFile: string | null
}
export interface DesktopSshHostsResult {
hosts: string[]
}
export interface DesktopAuthProvider {