diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index f9eaaa65e9e3..0bbf63093547 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -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 diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 1c7330e78d09..9619cade4754 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -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) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 510405ac3666..f766ac0d7f72 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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: -> 127.0.0.1:, +// 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 ` (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', () => { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 68f75c7b81fd..195da315057a 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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), diff --git a/apps/desktop/electron/ssh-config.cjs b/apps/desktop/electron/ssh-config.cjs new file mode 100644 index 000000000000..c71c4475f6cc --- /dev/null +++ b/apps/desktop/electron/ssh-config.cjs @@ -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 ` 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 ` 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 +} diff --git a/apps/desktop/electron/ssh-config.test.cjs b/apps/desktop/electron/ssh-config.test.cjs new file mode 100644 index 000000000000..00825d375650 --- /dev/null +++ b/apps/desktop/electron/ssh-config.test.cjs @@ -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) +}) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 15e449e16125..99bad3c09e7c 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -31,6 +31,8 @@ declare global { saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise + sshConfigHosts: () => Promise + sshResolveHost: (host: string) => Promise probeConnectionConfig: (remoteUrl: string) => Promise oauthLoginConnectionConfig: (remoteUrl: string) => Promise oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise @@ -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 {