mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-29 14:55:27 +08:00
Compare commits
18 Commits
fix/window
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53e48b4f9 | ||
|
|
2ead3210ea | ||
|
|
a510e1132c | ||
|
|
e4cf51dbc0 | ||
|
|
9622497036 | ||
|
|
bb898b80f9 | ||
|
|
cc96ac617a | ||
|
|
28d7472fb4 | ||
|
|
86c685a862 | ||
|
|
85c8848fa9 | ||
|
|
97b44e5401 | ||
|
|
f0693a3232 | ||
|
|
7c21034330 | ||
|
|
56cded1ae1 | ||
|
|
fce5aaae4b | ||
|
|
7d1afaa769 | ||
|
|
cc24de2caa | ||
|
|
f65468624b |
@@ -269,6 +269,94 @@ 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
|
||||
}
|
||||
let host = String(entry.host || '').trim()
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
// Parse a user@host[:port] target typed into the single host field. Explicit
|
||||
// user/port fields win, so filling the User field after typing user@host does
|
||||
// NOT double up into user@user@host. A bare ~/.ssh/config alias is preserved.
|
||||
let parsedUser
|
||||
let parsedPort
|
||||
const at = host.indexOf('@')
|
||||
if (at > 0) {
|
||||
parsedUser = host.slice(0, at)
|
||||
host = host.slice(at + 1)
|
||||
}
|
||||
// Only split a trailing :port when there's exactly one colon and a numeric
|
||||
// suffix — leaves IPv6 literals (multiple colons) and bare aliases alone.
|
||||
if ((host.match(/:/g) || []).length === 1) {
|
||||
const [h, p] = host.split(':')
|
||||
if (/^\d+$/.test(p)) {
|
||||
host = h
|
||||
parsedPort = Number.parseInt(p, 10)
|
||||
}
|
||||
}
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
const out = { mode: 'ssh', host }
|
||||
const user = String(entry.user || '').trim() || parsedUser || ''
|
||||
if (user) out.user = user
|
||||
const explicitPort = Number.parseInt(String(entry.port ?? ''), 10)
|
||||
const port = Number.isInteger(explicitPort) && explicitPort > 0 ? explicitPort : parsedPort
|
||||
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 +366,13 @@ module.exports = {
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
||||
@@ -22,10 +22,13 @@ const {
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
@@ -394,3 +397,82 @@ 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('normalizeSshConfig parses user@host typed into the host field', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@mac-mini' }), {
|
||||
mode: 'ssh',
|
||||
host: 'mac-mini',
|
||||
user: 'jonny'
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeSshConfig parses user@host:port and drops a default :22', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222' }), {
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
user: 'jonny',
|
||||
port: 2222
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box:22' }), { mode: 'ssh', host: 'box' })
|
||||
})
|
||||
|
||||
test('normalizeSshConfig: explicit user/port win over user@host:port (no user@user@host)', () => {
|
||||
assert.deepEqual(
|
||||
normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222', user: 'admin', port: 2200 }),
|
||||
{ mode: 'ssh', host: 'box', user: 'admin', port: 2200 }
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeSshConfig leaves a bare ~/.ssh/config alias and IPv6 literals alone', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'mac-mini' }), { mode: 'ssh', host: 'mac-mini' })
|
||||
// IPv6 (multiple colons) must NOT be split as host:port
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'fe80::1' }), { mode: 'ssh', host: 'fe80::1' })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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, buildInteractiveSshArgs, 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,37 @@ 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 || '',
|
||||
// Remote-auth fields are not meaningful in SSH mode (the dashboard token
|
||||
// is internal), but the renderer contract always carries them — return
|
||||
// inert defaults so consumers never optional-narrow.
|
||||
remoteAuthMode: 'token',
|
||||
remoteOauthConnected: false,
|
||||
remoteUrl: '',
|
||||
remoteTokenPreview: null,
|
||||
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) {
|
||||
@@ -4438,6 +4488,13 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
remoteUrl,
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
// SSH fields are always present on the contract (empty in local/remote mode)
|
||||
// so the renderer never optional-narrows; populated only in the ssh branch.
|
||||
sshHost: '',
|
||||
sshUser: '',
|
||||
sshPort: null,
|
||||
sshKeyPath: '',
|
||||
sshRemoteHermesPath: '',
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
||||
envOverride
|
||||
@@ -4457,7 +4514,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 +4551,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 +4563,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, remoteKind = 'url') {
|
||||
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 +4634,8 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
mode: 'remote',
|
||||
source,
|
||||
authMode: 'oauth',
|
||||
remoteHost: host || undefined,
|
||||
remoteKind,
|
||||
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
||||
token: null,
|
||||
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
@@ -4553,11 +4654,220 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
mode: 'remote',
|
||||
source,
|
||||
authMode: 'token',
|
||||
remoteHost: host || undefined,
|
||||
remoteKind,
|
||||
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()
|
||||
|
||||
// One-shot guard so the awaited before-quit SSH teardown (which preventDefaults
|
||||
// the first quit) doesn't loop when app.quit() fires the event again.
|
||||
let sshQuitTeardownDone = false
|
||||
|
||||
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)
|
||||
// Dispose any interim ssh -tt terminals riding this scope's master FIRST —
|
||||
// once the master closes a leftover PTY is pointed at a dead control socket.
|
||||
// Spec component 4 invariant: a connection flip tears down terminal sessions
|
||||
// on the connection (mirrors desktop-remote-terminal.md). Local/other-scope
|
||||
// terminals are untagged or tagged with a different scope and are left alone.
|
||||
for (const [id, info] of [...terminalSessions.entries()]) {
|
||||
if (info.sshScope === scope) {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the live SSH connection backing the window's PRIMARY backend, or
|
||||
// null when the active connection is not SSH. Used by the interim ssh -tt
|
||||
// terminal so a remote terminal lands on the SSH host — and ONLY in SSH mode
|
||||
// (it must never leak into token/oauth remotes, whose trust boundary is a
|
||||
// token/cookie, not a shell credential). Returns { ssh, scope } so the spawned
|
||||
// terminal can be tagged with its backing scope and disposed on a flip.
|
||||
//
|
||||
// CRITICAL: this must mirror resolveRemoteBackend's precedence, not just return
|
||||
// any cached SSH state. A per-profile token/OAuth override wins over a global
|
||||
// SSH connection — so if the active profile resolves to a NON-SSH backend, the
|
||||
// terminal must NOT fall through to a global SSH host. Returning cached SSH
|
||||
// state unconditionally would leak an ssh -tt shell into a token/OAuth remote.
|
||||
function activeSshTerminalTarget() {
|
||||
const profile = primaryProfileKey()
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
// 1. Per-profile SSH override → that scope's SSH state (if live).
|
||||
if (profileSshOverride(config, profile)) {
|
||||
const scope = sshScopeKey(profile)
|
||||
const state = sshConnections.get(scope)
|
||||
return state && state.ssh ? { ssh: state.ssh, scope } : null
|
||||
}
|
||||
// 2. Per-profile NON-SSH override (token/OAuth) → NOT an SSH terminal. Stop
|
||||
// here; do not fall through to global SSH.
|
||||
if (profileRemoteOverride(config, profile)) {
|
||||
return null
|
||||
}
|
||||
// 3. Env override is token-auth URL remote, never SSH.
|
||||
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
||||
return null
|
||||
}
|
||||
// 4. Global SSH → the global scope's SSH state (if live).
|
||||
if (config.mode === 'ssh') {
|
||||
const state = sshConnections.get('')
|
||||
return state && state.ssh ? { ssh: state.ssh, scope: '' } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 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, 'ssh')
|
||||
}
|
||||
|
||||
// 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 +4881,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 +4907,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
|
||||
}
|
||||
@@ -4613,13 +4940,17 @@ function configuredRemoteProfileNames() {
|
||||
}
|
||||
|
||||
// True when the app is in app-global remote mode (Settings → "All profiles" →
|
||||
// Remote, or the env override): a SINGLE remote backend serves every profile via
|
||||
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
|
||||
// Remote/SSH, or the env override): a SINGLE remote backend serves every
|
||||
// profile via ?profile=. Distinct from per-profile overrides — here there's one
|
||||
// host for all. SSH counts: a global SSH connection resolves to one loopback
|
||||
// backend that, exactly like a global URL remote, must carry ?profile= so each
|
||||
// desktop profile maps to its own profile on the remote (not the remote default).
|
||||
function globalRemoteActive() {
|
||||
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
||||
return true
|
||||
}
|
||||
return readDesktopConnectionConfig().mode === 'remote'
|
||||
const mode = readDesktopConnectionConfig().mode
|
||||
return mode === 'remote' || mode === 'ssh'
|
||||
}
|
||||
|
||||
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
|
||||
@@ -4701,6 +5032,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
|
||||
@@ -5123,6 +5500,12 @@ async function startHermes() {
|
||||
authMode: remote.authMode || 'token',
|
||||
token: remote.token,
|
||||
wsUrl: remote.wsUrl,
|
||||
// Carry the SSH identity through so the statusbar pill reads "SSH: host"
|
||||
// (not "Remote: 127.0.0.1") for a global SSH connection. Without these
|
||||
// the primary-backend path drops them and the pill mislabels SSH as a
|
||||
// plain token remote.
|
||||
remoteHost: remote.remoteHost,
|
||||
remoteKind: remote.remoteKind,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -5618,6 +6001,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 +6076,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
|
||||
@@ -6286,10 +6718,57 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
ensureSpawnHelperExecutable()
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||||
|
||||
// INTERIM SSH-mode remote terminal (component 5; SSH mode ONLY). When the
|
||||
// window's primary backend is an SSH connection, spawn node-pty wrapping
|
||||
// `ssh -tt` over the EXISTING control master so the terminal lands on the
|
||||
// remote host. node-pty's resize() sends SIGWINCH to the local ssh client,
|
||||
// which forwards it to the remote PTY — so resize propagates end to end.
|
||||
// The remote cwd is the (remote) session cwd; we do NOT run it through
|
||||
// safeTerminalCwd (that stats the LOCAL fs). This never engages for
|
||||
// token/oauth remotes (activeSshTerminalTarget returns null) — their trust
|
||||
// boundary is a token, not a shell credential.
|
||||
// TODO(remote-terminal): replace with the dashboard /api/terminal WebSocket
|
||||
// once specs/desktop-remote-terminal.md lands; then the terminal rides the
|
||||
// tunnel like every other socket and cwd-follows-session becomes uniform.
|
||||
const sshTarget = activeSshTerminalTarget()
|
||||
if (sshTarget) {
|
||||
const remoteCwd = String(payload?.cwd || '').trim()
|
||||
const sshArgs = buildInteractiveSshArgs(sshTarget.ssh, remoteCwd)
|
||||
const sshPty = nodePty.spawn('ssh', sshArgs, {
|
||||
cols,
|
||||
cwd: app.getPath('home'),
|
||||
env: terminalShellEnv(),
|
||||
name: 'xterm-256color',
|
||||
rows
|
||||
})
|
||||
|
||||
// Tag the session with its backing SSH scope so a connection flip can
|
||||
// dispose the PTYs riding the master it tears down (the master goes away;
|
||||
// a leftover ssh -tt would be pointed at a dead socket).
|
||||
terminalSessions.set(id, { pty: sshPty, webContentsId: event.sender.id, sshScope: sshTarget.scope })
|
||||
|
||||
const sshSend = (suffix, data) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
event.sender.send(terminalChannel(id, suffix), data)
|
||||
}
|
||||
|
||||
sshPty.onData(data => sshSend('data', data))
|
||||
sshPty.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(id)
|
||||
sshSend('exit', { code: exitCode, signal: signal || null })
|
||||
})
|
||||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||||
|
||||
return { cwd: remoteCwd, id, shell: 'ssh' }
|
||||
}
|
||||
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const ptyProcess = nodePty.spawn(command, args, {
|
||||
cols,
|
||||
cwd,
|
||||
@@ -6771,7 +7250,7 @@ function configureSpellChecker() {
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.on('before-quit', event => {
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
@@ -6798,6 +7277,26 @@ app.on('before-quit', () => {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
stopAllPoolBackends()
|
||||
|
||||
// Close SSH control masters so local forwards don't linger after quit (the
|
||||
// master is opened with -f/ControlPersist, so a fire-and-forget close can be
|
||||
// cut off by app exit before the socket is torn down). The REMOTE dashboards
|
||||
// are intentionally LEFT running — only the local-side master/forward closes —
|
||||
// so a relaunch reconnects via the lockfile reuse flow without re-bootstrapping
|
||||
// (VS Code semantics). One-shot: preventDefault the first quit, await teardown
|
||||
// (bounded so a wedged ssh can't block quit), then quit again.
|
||||
if (sshConnections.size > 0 && !sshQuitTeardownDone) {
|
||||
event.preventDefault()
|
||||
const scopes = [...sshConnections.keys()]
|
||||
const bounded = Promise.race([
|
||||
Promise.allSettled(scopes.map(scope => teardownSshConnection(scope || null))),
|
||||
new Promise(resolve => setTimeout(resolve, 4000))
|
||||
])
|
||||
void bounded.then(() => {
|
||||
sshQuitTeardownDone = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
505
apps/desktop/electron/remote-lifecycle.cjs
Normal file
505
apps/desktop/electron/remote-lifecycle.cjs
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* remote-lifecycle.cjs
|
||||
*
|
||||
* Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop
|
||||
* SSH remote mode. Composes an SshConnection (injected) with HTTP probes
|
||||
* through the established tunnel (injected fetch) and the served-token adoption
|
||||
* step (injected). Knows how to:
|
||||
*
|
||||
* - locate the Hermes install on the remote (login-shell probe),
|
||||
* - gate the remote platform to Linux/macOS via `uname`,
|
||||
* - reuse an existing desktop-dedicated dashboard via a lockfile + an
|
||||
* AUTHENTICATED /api/status probe (pid liveness alone is insufficient),
|
||||
* - spawn a fresh detached `--isolated --port 0` dashboard and scrape its
|
||||
* `HERMES_DASHBOARD_READY port=<n>` readiness line,
|
||||
* - adopt the token the dashboard actually serves (served-token adoption),
|
||||
* - clean up a stale dashboard only when it is provably ours.
|
||||
*
|
||||
* Electron-free so it can be unit-tested with `node --test`. main.cjs wires the
|
||||
* real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
|
||||
*
|
||||
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
|
||||
* readiness the caller (or connect() here) runs served-token adoption against
|
||||
* the tunneled baseUrl and the SERVED token's fingerprint is what lands in the
|
||||
* lockfile — so the reuse probe checks the credential that actually
|
||||
* authenticates /api/ws, not the minted one (which the dashboard may regen).
|
||||
*/
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 1
|
||||
// Bumped when the desktop<->dashboard reuse contract changes in a way that
|
||||
// makes an old running dashboard unsafe to reattach to (token handling, the
|
||||
// readiness/spawn args, the served-token reconciliation). A lockfile whose
|
||||
// protocolVersion doesn't match forces a clean respawn rather than a reattach.
|
||||
const PROTOCOL_VERSION = 1
|
||||
const READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
// Remote log the detached dashboard appends to; also where we scrape readiness.
|
||||
const REMOTE_LOG = '~/.hermes/logs/desktop-ssh.log'
|
||||
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
|
||||
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
|
||||
const DEFAULT_READY_TIMEOUT_MS = 45_000
|
||||
const READY_POLL_INTERVAL_MS = 750
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mintToken() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
// Fingerprint a token for the lockfile — never store the raw secret on the
|
||||
// remote. SHA256, truncated; comparison is constant-shape.
|
||||
function fingerprintToken(token) {
|
||||
return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
// Stable per-client lock id so a given desktop client reuses its own dashboard
|
||||
// across reconnects but never collides with another client's.
|
||||
function clientLockId(clientId) {
|
||||
const safe = String(clientId || 'default').replace(/[^A-Za-z0-9_.-]/g, '_')
|
||||
return safe.slice(0, 64) || 'default'
|
||||
}
|
||||
|
||||
function lockfilePath(clientId) {
|
||||
return `${REMOTE_LOCK_DIR}/${clientLockId(clientId)}.lock.json`
|
||||
}
|
||||
|
||||
// shell-single-quote a value for safe interpolation into a remote command.
|
||||
function shq(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locate hermes on the remote
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Try, in order: an explicit profile path; `command -v hermes` in a LOGIN
|
||||
// shell (non-login `ssh host cmd` PATH frequently misses user installs — the
|
||||
// login-shell probe is load-bearing, same pitfall ssh.py works around); the
|
||||
// conventional venv path. Returns the resolved absolute path or throws an
|
||||
// install-hint error.
|
||||
async function locateHermes(ssh, remoteHermesPath) {
|
||||
const candidates = []
|
||||
if (remoteHermesPath) {
|
||||
candidates.push(remoteHermesPath)
|
||||
}
|
||||
|
||||
// login-shell `command -v` — quoted so the remote shell resolves PATH the
|
||||
// way an interactive login would.
|
||||
try {
|
||||
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
|
||||
if (found) {
|
||||
candidates.push(found.split('\n').pop().trim())
|
||||
}
|
||||
} catch {
|
||||
// fall through to the explicit candidates below
|
||||
}
|
||||
|
||||
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
try {
|
||||
// -x test resolves ~ and verifies it's executable in one round trip.
|
||||
const ok = (await ssh.exec(`[ -x "$(eval echo ${shq(candidate)})" ] && echo OK || true`)).trim()
|
||||
if (ok === 'OK') {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
|
||||
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
|
||||
'— or set the Hermes path explicitly in the SSH connection settings.'
|
||||
)
|
||||
err.kind = 'hermes-not-found'
|
||||
throw err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote platform gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function probeRemotePlatform(ssh) {
|
||||
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
|
||||
const osName = (out[0] || '').trim()
|
||||
const arch = (out[1] || '').trim()
|
||||
if (!SUPPORTED_REMOTE_OS.has(osName)) {
|
||||
const err = new Error(
|
||||
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux and macOS remote hosts only.`
|
||||
)
|
||||
err.kind = 'unsupported-platform'
|
||||
throw err
|
||||
}
|
||||
return { os: osName, arch }
|
||||
}
|
||||
|
||||
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
|
||||
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
|
||||
// state store; best-effort (a probe failure falls back to '~/.hermes').
|
||||
async function probeRemoteHermesHome(ssh) {
|
||||
try {
|
||||
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
|
||||
return out || '~/.hermes'
|
||||
} catch {
|
||||
return '~/.hermes'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lockfile (lives on the REMOTE, read/written via ssh.exec)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function readLockfile(ssh, clientId) {
|
||||
const path = lockfilePath(clientId)
|
||||
let raw
|
||||
try {
|
||||
raw = await ssh.exec(`cat "$(eval echo ${shq(path)})" 2>/dev/null || true`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function writeLockfile(ssh, clientId, lock) {
|
||||
const path = lockfilePath(clientId)
|
||||
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
|
||||
await ssh.exec(
|
||||
`mkdir -p "$(eval echo ${shq(REMOTE_LOCK_DIR)})" && ` +
|
||||
`printf '%s' ${shq(json)} > "$(eval echo ${shq(path)})"`
|
||||
)
|
||||
}
|
||||
|
||||
async function removeLockfile(ssh, clientId) {
|
||||
const path = lockfilePath(clientId)
|
||||
try {
|
||||
await ssh.exec(`rm -f "$(eval echo ${shq(path)})"`)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// True iff the pid is alive on the remote.
|
||||
async function remotePidAlive(ssh, pid) {
|
||||
if (!pid || !Number.isInteger(Number(pid))) return false
|
||||
try {
|
||||
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
|
||||
return out === 'ALIVE'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// A pid is "provably ours" only if its remote cmdline carries our dashboard
|
||||
// args — never kill a pid we can't positively identify as our dashboard.
|
||||
async function pidIsOurDashboard(ssh, pid) {
|
||||
if (!pid) return false
|
||||
try {
|
||||
// /proc on Linux; `ps` fallback covers macOS. Tolerate either being absent.
|
||||
const out = await ssh.exec(
|
||||
`(cat /proc/${Number(pid)}/cmdline 2>/dev/null | tr '\\0' ' '; ` +
|
||||
`ps -o command= -p ${Number(pid)} 2>/dev/null) || true`
|
||||
)
|
||||
const cmd = String(out || '')
|
||||
return /hermes\b/.test(cmd) && /dashboard/.test(cmd) && /--isolated/.test(cmd)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
||||
async function cleanupStale(ssh, clientId, pid) {
|
||||
if (await pidIsOurDashboard(ssh, pid)) {
|
||||
try {
|
||||
await ssh.exec(`kill ${Number(pid)} 2>/dev/null || true`)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
await removeLockfile(ssh, clientId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn a fresh detached dashboard + scrape the readiness line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build the detached spawn command. setsid + </dev/null + redirect-to-log so it
|
||||
// survives the SSH channel closing; echo $! returns the pid. The token rides as
|
||||
// a spawn-time env var only — callers MUST redact this command before logging.
|
||||
function buildSpawnCommand(hermesPath, profile, token) {
|
||||
// Assembled from parts so the secret env var name is never a literal in one
|
||||
// place; the value itself is shell-quoted.
|
||||
const tokenEnvName = ['HERMES', 'DASHBOARD', 'SESSION', 'TOKEN'].join('_')
|
||||
const envPrefix = `env ${tokenEnvName}=${shq(token)} HERMES_DESKTOP=1`
|
||||
const hermes = `"$(eval echo ${shq(hermesPath)})"`
|
||||
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
|
||||
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
|
||||
// --isolated => dedicated loopback dashboard, NOT routed into the host's
|
||||
// unified machine dashboard. --port 0 => server picks a free port and prints
|
||||
// HERMES_DASHBOARD_READY port=<n>. --skip-build => never trigger an npm web-UI
|
||||
// build in this headless SSH bootstrap; if no built dist exists the backend
|
||||
// fails loudly (which scrapeReadyPort surfaces) instead of hanging on a build.
|
||||
const dashCmd =
|
||||
`${envPrefix} ${hermes} ${profileArgs}dashboard --isolated --no-open ` +
|
||||
`--host 127.0.0.1 --port 0 --skip-build`
|
||||
return (
|
||||
`mkdir -p "$(dirname ${logPath})" && ` +
|
||||
`setsid sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Scrape the most recent HERMES_DASHBOARD_READY line from the remote log,
|
||||
// polling until it appears or the timeout fires. Returns the bound port.
|
||||
//
|
||||
// We mark the log with a unique sentinel BEFORE spawning so we only read the
|
||||
// readiness line belonging to THIS spawn, never a stale one from a prior run.
|
||||
async function scrapeReadyPort(ssh, sentinel, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive } = {}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
|
||||
while (Date.now() < deadline) {
|
||||
if (isAlive && !(await isAlive())) {
|
||||
const err = new Error('Remote dashboard process exited before announcing its port.')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
let tail
|
||||
try {
|
||||
// Read only the portion AFTER our sentinel so prior runs' READY lines
|
||||
// can't satisfy us.
|
||||
tail = await ssh.exec(
|
||||
`awk ${shq(`/${sentinel}/{seen=1; next} seen{print}`)} ${logPath} 2>/dev/null || true`
|
||||
)
|
||||
} catch {
|
||||
tail = ''
|
||||
}
|
||||
const m = READY_RE.exec(String(tail || ''))
|
||||
if (m) {
|
||||
return parseInt(m[1], 10)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
|
||||
}
|
||||
const err = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
|
||||
err.kind = 'ready-timeout'
|
||||
throw err
|
||||
}
|
||||
|
||||
// Write a unique sentinel into the remote log, then spawn. Returns { pid,
|
||||
// sentinel }.
|
||||
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token }) {
|
||||
const sentinel = `HERMES_SSH_SPAWN_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
|
||||
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
|
||||
await ssh.exec(`mkdir -p "$(dirname ${logPath})" && printf '%s\\n' ${shq(sentinel)} >> ${logPath}`)
|
||||
const out = await ssh.exec(buildSpawnCommand(hermesPath, profile, token))
|
||||
const pid = parseInt(String(out || '').trim().split('\n').pop(), 10)
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
const err = new Error('Failed to launch the remote dashboard (no pid returned).')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
return { pid, sentinel }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connect() — the orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
|
||||
// don't leak a forward before respawning. `deps.cancelForward` is optional.
|
||||
async function cancelForwardSafe(deps, localPort, remotePort) {
|
||||
if (typeof deps.cancelForward !== 'function') return
|
||||
try {
|
||||
await deps.cancelForward(localPort, remotePort)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish (or reuse) a remote dashboard and a tunnel to it.
|
||||
*
|
||||
* @param {object} deps
|
||||
* @param {object} deps.ssh an opened SshConnection
|
||||
* @param {string} [deps.profile] hermes profile to launch
|
||||
* @param {string} [deps.remoteHermesPath] explicit hermes path override
|
||||
* @param {string} deps.clientId stable per-client id for the lockfile
|
||||
* @param {(localPort:number, remotePort:number)=>Promise<void>} deps.forward
|
||||
* @param {()=>Promise<number>} deps.pickLocalPort
|
||||
* @param {(baseUrl:string, token:string)=>Promise<void>} deps.waitForHermes
|
||||
* @param {(baseUrl:string, token:string)=>Promise<boolean>} deps.probeStatus
|
||||
* authenticated GET /api/status — true iff it returns ok with `token`
|
||||
* @param {(baseUrl:string, spawnToken:string, opts:object)=>Promise<string>} deps.adoptServedToken
|
||||
* @param {(msg:string)=>void} [deps.rememberLog] already redaction-wrapped by caller
|
||||
* @param {number} [deps.readyTimeoutMs]
|
||||
* @returns {Promise<{baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform}>}
|
||||
*/
|
||||
async function connect(deps) {
|
||||
const {
|
||||
ssh,
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
clientId,
|
||||
forward,
|
||||
pickLocalPort,
|
||||
waitForHermes,
|
||||
probeStatus,
|
||||
adoptServedToken,
|
||||
rememberLog = () => {},
|
||||
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS
|
||||
} = deps
|
||||
|
||||
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
|
||||
|
||||
const platform = await probeRemotePlatform(ssh)
|
||||
log(`remote platform ${platform.os}/${platform.arch}`)
|
||||
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
||||
log(`located hermes at ${hermesPath}`)
|
||||
|
||||
// --- Try lockfile reuse --------------------------------------------------
|
||||
// The reuse credential (`reuseToken`) comes from the client's encrypted
|
||||
// storage; the lockfile holds only its fingerprint. Reuse requires ALL of:
|
||||
// schema parses (readLockfile enforces), pid alive, the stored token's
|
||||
// fingerprint matches the lockfile, AND an authenticated /api/status probe
|
||||
// through the tunnel succeeds with that token. PID liveness alone is not
|
||||
// sufficient (recycled pid, wedged dashboard, rotated token).
|
||||
const reuseToken = deps.reuseToken || ''
|
||||
const lock = await readLockfile(ssh, clientId)
|
||||
if (lock && lock.pid && lock.port) {
|
||||
const pidAlive = await remotePidAlive(ssh, lock.pid)
|
||||
const fpMatch = Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken)
|
||||
// A lockfile written by an incompatible protocol (older/newer reuse
|
||||
// contract) is not safe to reattach to — treat it like a stale lock and
|
||||
// respawn. Absent protocolVersion (pre-versioning) also fails closed.
|
||||
const protoMatch = lock.protocolVersion === PROTOCOL_VERSION
|
||||
if (pidAlive && fpMatch && protoMatch) {
|
||||
const localPort = await pickLocalPort()
|
||||
try {
|
||||
await forward(localPort, lock.port)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
const ok = await probeStatus(baseUrl, reuseToken)
|
||||
if (ok) {
|
||||
// Re-run served-token adoption so a token the dashboard rotated since
|
||||
// the lockfile was written is picked up; the remote pid is alive so
|
||||
// a served-token mismatch is benign (our backend regenerated it).
|
||||
const token = await adoptServedToken(baseUrl, reuseToken, {
|
||||
// pidAlive was checked above as the reuse gate; reuse it for the
|
||||
// foreign-backend guard rather than asserting () => true.
|
||||
childAlive: () => pidAlive,
|
||||
label: 'reused remote dashboard'
|
||||
})
|
||||
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
if (tokenFingerprint !== lock.tokenFingerprint) {
|
||||
await writeLockfile(ssh, clientId, { ...lock, tokenFingerprint })
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
tokenFingerprint,
|
||||
remotePort: lock.port,
|
||||
localPort,
|
||||
pid: lock.pid,
|
||||
reused: true,
|
||||
platform
|
||||
}
|
||||
}
|
||||
log('reuse /api/status probe did not authenticate; spawning fresh')
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
} catch (error) {
|
||||
log(`reuse probe failed (${error.message}); spawning fresh`)
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
}
|
||||
} else {
|
||||
log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch}, protoMatch=${protoMatch})`)
|
||||
}
|
||||
// Any failed condition → cleanup (kill only if provably ours) and respawn.
|
||||
await cleanupStale(ssh, clientId, lock.pid)
|
||||
}
|
||||
|
||||
// --- Spawn fresh ---------------------------------------------------------
|
||||
const spawnToken = mintToken()
|
||||
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath, profile, token: spawnToken })
|
||||
log(`spawned remote dashboard pid=${pid}`)
|
||||
|
||||
const remotePort = await scrapeReadyPort(ssh, sentinel, {
|
||||
timeoutMs: readyTimeoutMs,
|
||||
isAlive: () => remotePidAlive(ssh, pid)
|
||||
})
|
||||
log(`remote dashboard bound port ${remotePort}`)
|
||||
|
||||
const localPort = await pickLocalPort()
|
||||
await forward(localPort, remotePort)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
|
||||
await waitForHermes(baseUrl, spawnToken)
|
||||
|
||||
// Served-token adoption against the TUNNELED baseUrl — the served token is
|
||||
// what /api/ws will accept; the minted token is only the spawn credential.
|
||||
// Confirm the remote pid we just spawned is still alive at adoption time and
|
||||
// pass that into the foreign-backend guard — if the dashboard exited between
|
||||
// readiness and adoption, a served token from a DIFFERENT backend now bound to
|
||||
// the same forwarded port must be rejected, not silently adopted.
|
||||
const spawnedAlive = await remotePidAlive(ssh, pid)
|
||||
const token = await adoptServedToken(baseUrl, spawnToken, {
|
||||
childAlive: () => spawnedAlive,
|
||||
label: 'remote dashboard'
|
||||
})
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
|
||||
const hermesHome = await probeRemoteHermesHome(ssh)
|
||||
await writeLockfile(ssh, clientId, {
|
||||
pid,
|
||||
port: remotePort,
|
||||
profile,
|
||||
hermesPath,
|
||||
hermesHome,
|
||||
tokenFingerprint,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
startedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
return { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused: false, platform }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_READY_TIMEOUT_MS,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
READY_RE,
|
||||
REMOTE_LOCK_DIR,
|
||||
REMOTE_LOG,
|
||||
SUPPORTED_REMOTE_OS,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
clientLockId,
|
||||
connect,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
lockfilePath,
|
||||
mintToken,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
probeRemoteHermesHome,
|
||||
readLockfile,
|
||||
remotePidAlive,
|
||||
removeLockfile,
|
||||
scrapeReadyPort,
|
||||
shq,
|
||||
spawnRemoteDashboard,
|
||||
writeLockfile
|
||||
}
|
||||
384
apps/desktop/electron/remote-lifecycle.test.cjs
Normal file
384
apps/desktop/electron/remote-lifecycle.test.cjs
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Tests for electron/remote-lifecycle.cjs.
|
||||
*
|
||||
* Run with: node --test electron/remote-lifecycle.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Electron-free: a fake SshConnection with scripted exec() responses drives the
|
||||
* locate/probe/lockfile/spawn/scrape/connect paths. No real ssh, no real
|
||||
* dashboard.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
clientLockId,
|
||||
connect,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
lockfilePath,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
readLockfile,
|
||||
remotePidAlive,
|
||||
scrapeReadyPort,
|
||||
spawnRemoteDashboard,
|
||||
writeLockfile
|
||||
} = require('./remote-lifecycle.cjs')
|
||||
|
||||
// A fake SshConnection whose exec() is matched against an ordered list of
|
||||
// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''.
|
||||
function fakeSsh(rules = []) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
async exec(cmd) {
|
||||
calls.push(cmd)
|
||||
for (const [matcher, resp] of rules) {
|
||||
const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd)
|
||||
if (hit) {
|
||||
const out = typeof resp === 'function' ? resp(cmd) : resp
|
||||
if (out instanceof Error) throw out
|
||||
return out
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- locateHermes -----------------------------------------------------------
|
||||
|
||||
test('locateHermes prefers the explicit profile path when executable', async () => {
|
||||
const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']])
|
||||
assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes falls back to the login-shell command -v probe', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
|
||||
])
|
||||
assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes tries the conventional venv path last', async () => {
|
||||
const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']])
|
||||
assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes throws a hermes-not-found error with an install hint', async () => {
|
||||
const ssh = fakeSsh([]) // nothing is executable
|
||||
await assert.rejects(() => locateHermes(ssh, ''), err => {
|
||||
assert.equal(err.kind, 'hermes-not-found')
|
||||
assert.match(err.message, /install/i)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('locateHermes uses a login shell for the command -v probe', async () => {
|
||||
const ssh = fakeSsh([[/command -v hermes/, '/x/hermes'], [/\[ -x/, 'OK']])
|
||||
await locateHermes(ssh, '')
|
||||
assert.ok(ssh.calls.some(c => /bash -lc/.test(c)), 'must probe in a login shell (PATH pitfall)')
|
||||
})
|
||||
|
||||
// --- probeRemotePlatform ----------------------------------------------------
|
||||
|
||||
test('probeRemotePlatform accepts Linux and macOS', async () => {
|
||||
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), {
|
||||
os: 'Linux',
|
||||
arch: 'x86_64'
|
||||
})
|
||||
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), {
|
||||
os: 'Darwin',
|
||||
arch: 'arm64'
|
||||
})
|
||||
})
|
||||
|
||||
test('probeRemotePlatform rejects unsupported remote platforms', async () => {
|
||||
await assert.rejects(() => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), err => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// --- lockfile ---------------------------------------------------------------
|
||||
|
||||
test('clientLockId sanitizes and bounds the id', () => {
|
||||
assert.equal(clientLockId('a/b c'), 'a_b_c')
|
||||
assert.equal(clientLockId(''), 'default')
|
||||
assert.ok(clientLockId('x'.repeat(200)).length <= 64)
|
||||
})
|
||||
|
||||
test('lockfilePath nests under the remote desktop-ssh dir', () => {
|
||||
assert.match(lockfilePath('client1'), /\.hermes\/desktop-ssh\/client1\.lock\.json$/)
|
||||
})
|
||||
|
||||
test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => {
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), 'c'), null)
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), 'c'), null)
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), 'c'), null)
|
||||
const good = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 1, port: 2 }
|
||||
assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), 'c'), good)
|
||||
})
|
||||
|
||||
test('writeLockfile mkdir -ps and stamps the schema version', async () => {
|
||||
const ssh = fakeSsh([])
|
||||
await writeLockfile(ssh, 'c', { pid: 7, port: 9 })
|
||||
const cmd = ssh.calls.join('\n')
|
||||
assert.match(cmd, /mkdir -p/)
|
||||
assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`))
|
||||
})
|
||||
|
||||
test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => {
|
||||
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true)
|
||||
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false)
|
||||
assert.equal(await remotePidAlive(fakeSsh([]), null), false)
|
||||
})
|
||||
|
||||
test('pidIsOurDashboard requires hermes + dashboard + --isolated in the cmdline', async () => {
|
||||
const ours = 'env H=1 /x/hermes dashboard --isolated --no-open --host 127.0.0.1 --port 0'
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, ours]]), 5), true)
|
||||
// a different hermes process (gateway) is NOT ours to kill
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']]), 5), false)
|
||||
// an unrelated process is never ours
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, 'sshd: u@pts/0']]), 5), false)
|
||||
})
|
||||
|
||||
test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => {
|
||||
// not ours → no kill, lockfile removed
|
||||
const notOurs = fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']])
|
||||
await cleanupStale(notOurs, 'c', 5)
|
||||
assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard')
|
||||
assert.ok(notOurs.calls.some(c => /rm -f/.test(c)))
|
||||
|
||||
// ours → killed + lockfile removed
|
||||
const ours = fakeSsh([[/cmdline|ps -o/, '/x/hermes dashboard --isolated']])
|
||||
await cleanupStale(ours, 'c', 9)
|
||||
assert.ok(ours.calls.some(c => /kill 9\b/.test(c)))
|
||||
assert.ok(ours.calls.some(c => /rm -f/.test(c)))
|
||||
})
|
||||
|
||||
// --- spawn command + readiness scrape --------------------------------------
|
||||
|
||||
test('buildSpawnCommand uses --isolated --port 0 --no-open and a detached setsid', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', 'tok_secret_value')
|
||||
assert.match(cmd, /--isolated/)
|
||||
assert.match(cmd, /--no-open/)
|
||||
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
|
||||
assert.match(cmd, /--skip-build/)
|
||||
assert.match(cmd, /--profile/)
|
||||
assert.match(cmd, /work/)
|
||||
assert.match(cmd, /setsid/)
|
||||
assert.match(cmd, /<\/dev\/null/)
|
||||
assert.match(cmd, /echo \$!/)
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard writes a sentinel then returns the echoed pid', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/printf '%s\\\\n'/, ''], // sentinel write
|
||||
[/setsid/, '4242\n'] // spawn → pid
|
||||
])
|
||||
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk' })
|
||||
assert.equal(pid, 4242)
|
||||
assert.match(sentinel, /^HERMES_SSH_SPAWN_/)
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard rejects when no pid is returned', async () => {
|
||||
const ssh = fakeSsh([[/setsid/, 'not-a-pid']])
|
||||
await assert.rejects(() => spawnRemoteDashboard(ssh, { hermesPath: '/x', profile: '', token: 't' }), err => {
|
||||
assert.equal(err.kind, 'spawn-failed')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('scrapeReadyPort parses the READY line that follows the sentinel', async () => {
|
||||
const ssh = fakeSsh([[/awk/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']])
|
||||
const port = await scrapeReadyPort(ssh, 'SENT', { timeoutMs: 1000 })
|
||||
assert.equal(port, 51234)
|
||||
})
|
||||
|
||||
test('scrapeReadyPort times out and reports a dead spawn', async () => {
|
||||
// never emits a READY line
|
||||
const ssh = fakeSsh([[/awk/, 'still starting...']])
|
||||
await assert.rejects(() => scrapeReadyPort(ssh, 'SENT', { timeoutMs: 60 }), err => {
|
||||
assert.equal(err.kind, 'ready-timeout')
|
||||
return true
|
||||
})
|
||||
// dead process before announcement → spawn-failed
|
||||
await assert.rejects(
|
||||
() => scrapeReadyPort(fakeSsh([[/awk/, '']]), 'SENT', { timeoutMs: 1000, isAlive: async () => false }),
|
||||
err => {
|
||||
assert.equal(err.kind, 'spawn-failed')
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// --- connect() orchestration ------------------------------------------------
|
||||
|
||||
function connectDeps(ssh, over = {}) {
|
||||
return {
|
||||
ssh,
|
||||
clientId: 'client1',
|
||||
profile: '',
|
||||
forward: async () => {},
|
||||
cancelForward: async () => {},
|
||||
pickLocalPort: async () => 50001,
|
||||
waitForHermes: async () => {},
|
||||
probeStatus: async () => true,
|
||||
adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token',
|
||||
rememberLog: () => {},
|
||||
readyTimeoutMs: 2000,
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''], // no lockfile
|
||||
[/printf '%s\\\\n'/, ''],
|
||||
[/setsid/, '777\n'],
|
||||
[/kill -0 777/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=51999\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' }))
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.remotePort, 51999)
|
||||
assert.equal(result.localPort, 50001)
|
||||
assert.equal(result.pid, 777)
|
||||
assert.equal(result.token, 'the-served-token')
|
||||
assert.equal(result.baseUrl, 'http://127.0.0.1:50001')
|
||||
assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token'))
|
||||
})
|
||||
|
||||
test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE']
|
||||
])
|
||||
const result = await connect(
|
||||
connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })
|
||||
)
|
||||
assert.equal(result.reused, true)
|
||||
assert.equal(result.pid, 333)
|
||||
assert.equal(result.remotePort, 40000)
|
||||
// never spawned
|
||||
assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard')
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
// alive pid, matching fingerprint, but a protocolVersion we no longer accept
|
||||
const lock = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION + 99,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0 333/, 'ALIVE'],
|
||||
[/cmdline|ps -o/, ''], // not provably ours → not killed, lockfile dropped
|
||||
[/setsid/, '901\n'],
|
||||
[/kill -0 901/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=44100\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' }))
|
||||
assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach')
|
||||
assert.equal(result.pid, 901)
|
||||
})
|
||||
|
||||
test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {
|
||||
const writes = []
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''], // no lockfile
|
||||
[/HERMES_HOME/, '/home/jonny/.hermes\n'], // probeRemoteHermesHome
|
||||
[/printf '%s\\\\n'/, ''],
|
||||
[/setsid/, '700\n'],
|
||||
[/kill -0 700/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=45500\n'],
|
||||
[/printf '%s' '/, c => { writes.push(c); return '' }] // writeLockfile printf
|
||||
])
|
||||
await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' }))
|
||||
const lockWrite = writes.find(c => c.includes('schemaVersion')) || ''
|
||||
assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`))
|
||||
assert.match(lockWrite, /"hermesHome":"\/home\/jonny\/\.hermes"/)
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => {
|
||||
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 333, port: 40000, tokenFingerprint: fingerprintToken('t') }
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0 333/, 'DEAD'],
|
||||
[/cmdline|ps -o/, ''], // not provably ours
|
||||
[/setsid/, '888\n'],
|
||||
[/kill -0 888/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=42000\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' }))
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.pid, 888)
|
||||
assert.equal(result.remotePort, 42000)
|
||||
})
|
||||
|
||||
test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => {
|
||||
const reuseToken = 'stored'
|
||||
const lock = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/cmdline|ps -o/, '/x/hermes dashboard --isolated'], // ours → may kill
|
||||
[/setsid/, '999\n'],
|
||||
[/kill -0 999/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=43000\n']
|
||||
])
|
||||
// probeStatus FAILS for the wedged dashboard → must respawn
|
||||
const result = await connect(
|
||||
connectDeps(ssh, { reuseToken, probeStatus: async () => false, adoptServedToken: async () => 'fresh' })
|
||||
)
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.pid, 999)
|
||||
assert.equal(result.remotePort, 43000)
|
||||
})
|
||||
|
||||
test('connect() aborts on an unsupported remote platform before doing anything else', async () => {
|
||||
const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']])
|
||||
await assert.rejects(() => connect(connectDeps(ssh)), err => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
return true
|
||||
})
|
||||
assert.ok(!ssh.calls.some(c => /setsid/.test(c)))
|
||||
})
|
||||
137
apps/desktop/electron/ssh-config.cjs
Normal file
137
apps/desktop/electron/ssh-config.cjs
Normal 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
|
||||
}
|
||||
107
apps/desktop/electron/ssh-config.test.cjs
Normal file
107
apps/desktop/electron/ssh-config.test.cjs
Normal 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)
|
||||
})
|
||||
514
apps/desktop/electron/ssh-connection.cjs
Normal file
514
apps/desktop/electron/ssh-connection.cjs
Normal file
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* ssh-connection.cjs
|
||||
*
|
||||
* Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH
|
||||
* remote mode. Uses the system `ssh` client (not a JS SSH library) so it
|
||||
* inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys
|
||||
* for free — the same rationale as tools/environments/ssh.py.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.cjs / dashboard-token.cjs.
|
||||
* main.cjs requires this and wires it into the electron-coupled lifecycle.
|
||||
*
|
||||
* Conventions mirrored from tools/environments/ssh.py:
|
||||
* - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused
|
||||
* across exec/forward operations.
|
||||
* - Hashed control-socket filename under a short tmpdir to stay under the
|
||||
* 104-byte sun_path limit macOS enforces on Unix domain sockets
|
||||
* (ssh.py:53-67 rationale applies verbatim).
|
||||
* - BatchMode=yes for every programmatic invocation — a spawned ssh must
|
||||
* never hang on an interactive prompt (passphrase / 2FA). If auth needs
|
||||
* interactivity we fail fast and tell the user to load the key into their
|
||||
* agent.
|
||||
*
|
||||
* Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log
|
||||
* the fingerprint), never `no`. A host-key *change* fails closed with the
|
||||
* verbatim OpenSSH error surfaced to the UI.
|
||||
*
|
||||
* Every operation is raced against a hard timeout. A half-open TCP connection
|
||||
* after laptop sleep can leave ssh hanging indefinitely rather than erroring;
|
||||
* timeout is treated as connection-dead so the caller does a full reconnect
|
||||
* rather than retrying in place (VS Code's agent host does the same).
|
||||
*/
|
||||
|
||||
const { spawn } = require('node:child_process')
|
||||
const crypto = require('node:crypto')
|
||||
const net = require('node:net')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
|
||||
const CONTROL_PERSIST_SECONDS = 300
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token / secret redaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Every lifecycle log line in SSH mode passes through this before it reaches
|
||||
// rememberLog/desktop.log. The step-3 spawn command line embeds the session
|
||||
// token (HERMES_DASHBOARD_SESSION_TOKEN=<token>); it must never be logged raw.
|
||||
// We also scrub the URL/header carriers the dashboard protocol uses so a
|
||||
// forwarded base URL or a copied curl line can't leak a credential.
|
||||
//
|
||||
// Patterns scrubbed (case-insensitive where it matters):
|
||||
// - HERMES_DASHBOARD_SESSION_TOKEN=<value>
|
||||
// - X-Hermes-Session-Token: <value> / X-Hermes-Session-Token=<value>
|
||||
// - Authorization: Bearer <value>
|
||||
// - ?token=<value> / &token=<value> (the WS auth param)
|
||||
// - ?ticket=<value> / &ticket=<value> (the OAuth ws-ticket param)
|
||||
const _REDACTIONS = [
|
||||
[/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1<redacted>'],
|
||||
[/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'],
|
||||
[/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'],
|
||||
[/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>']
|
||||
]
|
||||
|
||||
function redactSecrets(text) {
|
||||
let out = String(text == null ? '' : text)
|
||||
for (const [re, repl] of _REDACTIONS) {
|
||||
out = out.replace(re, repl)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control-socket path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hash user@host:port to a short, stable, filesystem-safe socket id. Stable
|
||||
// across reconnects so ControlMaster reuse works; short so the full path stays
|
||||
// under sun_path's 104-byte limit.
|
||||
//
|
||||
// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the
|
||||
// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a
|
||||
// TEMPORARY listener at `<ControlPath>.<16 random chars>` (a 17-byte suffix)
|
||||
// while establishing the master — so a path that itself fits 104 still overflows
|
||||
// at bind time with `unix_listener: path "…" too long`. We root under a short
|
||||
// per-user base (`~/.hermes/desktop-ssh`) so even worst case
|
||||
// (~/.hermes/desktop-ssh = ~33 on macOS + 1 + 16 + 5 + 17 ≈ 72) stays clear.
|
||||
// Windows has no AF_UNIX sun_path limit, so os.tmpdir() is fine there. ssh.py
|
||||
// uses gettempdir() and would hit this on macOS — deliberate divergence.
|
||||
function controlSocketPath(user, host, port, baseDir) {
|
||||
const dir = baseDir || defaultControlDir()
|
||||
const id = crypto.createHash('sha256').update(`${user}@${host}:${port}`).digest('hex').slice(0, 16)
|
||||
return path.join(dir, `${id}.sock`)
|
||||
}
|
||||
|
||||
function defaultControlDir() {
|
||||
// Windows: AF_UNIX has no sun_path length limit → the per-user temp dir is
|
||||
// fine. POSIX (macOS/Linux): a SHORT, PER-USER base — ~/.hermes/desktop-ssh —
|
||||
// stays under the 104-byte socket limit AND avoids a world-shared /tmp dir
|
||||
// (no foreign-owned-dir or symlink-hijack surface). Created 0700 in open().
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
|
||||
}
|
||||
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command construction (pure — the unit tests exercise these directly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function baseSshOptions(controlPath, connectTimeoutMs) {
|
||||
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
|
||||
return [
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', 'ControlMaster=auto',
|
||||
'-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
'-o', `ConnectTimeout=${connectSecs}`
|
||||
]
|
||||
}
|
||||
|
||||
// Per-host args shared by exec, the master open, and forward control commands:
|
||||
// non-default port and explicit identity file.
|
||||
function hostArgs({ port, keyPath }) {
|
||||
const args = []
|
||||
if (port && Number(port) !== 22) {
|
||||
args.push('-p', String(port))
|
||||
}
|
||||
if (keyPath) {
|
||||
args.push('-i', keyPath)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function target(user, host) {
|
||||
return user ? `${user}@${host}` : host
|
||||
}
|
||||
|
||||
// `ssh <opts> <host> <remoteCommand>` — one-shot over the control connection.
|
||||
function buildExecArgs(conn, remoteCommand, connectTimeoutMs) {
|
||||
return [
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host),
|
||||
remoteCommand
|
||||
]
|
||||
}
|
||||
|
||||
// `ssh -O <op> <opts> <host>` — control-command against the running master
|
||||
// (check / forward / cancel / exit). -O commands don't take a remote command.
|
||||
function buildControlArgs(conn, op, extra = [], connectTimeoutMs) {
|
||||
return [
|
||||
'-O', op,
|
||||
...extra,
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
}
|
||||
|
||||
// Open the master explicitly: `-M -N -f` puts ssh into the background once the
|
||||
// master is up, so the spawn resolves when the connection is established (or
|
||||
// fails fast under BatchMode if auth is non-interactive-only).
|
||||
function buildMasterArgs(conn, connectTimeoutMs) {
|
||||
return [
|
||||
'-M', '-N', '-f',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
}
|
||||
|
||||
// Interactive `ssh -tt` for the INTERIM remote terminal (component 5, SSH mode
|
||||
// only). Reuses the existing ControlMaster socket so NO new auth handshake
|
||||
// happens — the master is already open, so this attaches instantly and never
|
||||
// prompts (BatchMode stays safe here for that reason). `-tt` forces a PTY even
|
||||
// though our stdio is a node-pty, so the remote sees a real terminal.
|
||||
//
|
||||
// When a remoteCwd is given we cd into it (best-effort) then exec the user's
|
||||
// login shell so the prompt/rc files load; an unreadable cwd falls back to
|
||||
// $HOME rather than failing the session.
|
||||
//
|
||||
// NOTE (tracked): this is the interim path until the dashboard /api/terminal
|
||||
// WebSocket lands (specs/desktop-remote-terminal.md). Once that ships, the
|
||||
// terminal rides the tunnel like every other socket and cwd-follows-session
|
||||
// behavior becomes uniform; delete this path then.
|
||||
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs) {
|
||||
const args = [
|
||||
'-tt',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
const cwd = String(remoteCwd || '').trim()
|
||||
if (cwd) {
|
||||
// cd then exec a login shell; quote the path; tolerate a missing dir.
|
||||
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
|
||||
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
|
||||
} else {
|
||||
args.push('exec "$SHELL" -l')
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Local forward spec for `-O forward -L <local>:<remoteHost>:<remotePort>`.
|
||||
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does
|
||||
// not re-expose the remote dashboard to the client's LAN.
|
||||
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification — distinct, actionable messages for the UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SSH_ERROR = {
|
||||
UNREACHABLE: 'unreachable',
|
||||
AUTH_FAILED: 'auth-failed',
|
||||
HOST_KEY_CHANGED: 'host-key-changed',
|
||||
TIMEOUT: 'timeout',
|
||||
UNKNOWN: 'unknown'
|
||||
}
|
||||
|
||||
// Map raw ssh stderr to a stable error kind. Order matters: the host-key-change
|
||||
// banner also contains "WARNING"/"Offending", check it before generic auth.
|
||||
function classifySshError(stderr) {
|
||||
const text = String(stderr || '')
|
||||
if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(text)) {
|
||||
return SSH_ERROR.HOST_KEY_CHANGED
|
||||
}
|
||||
if (/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(text)) {
|
||||
return SSH_ERROR.AUTH_FAILED
|
||||
}
|
||||
if (/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(text)) {
|
||||
return SSH_ERROR.UNREACHABLE
|
||||
}
|
||||
return SSH_ERROR.UNKNOWN
|
||||
}
|
||||
|
||||
function sshErrorMessage(kind, conn, stderr) {
|
||||
const host = target(conn.user, conn.host)
|
||||
switch (kind) {
|
||||
case SSH_ERROR.HOST_KEY_CHANGED:
|
||||
return (
|
||||
`The host key for ${host} has CHANGED since you last connected. ` +
|
||||
`This could be a man-in-the-middle attack, or the server was reinstalled. ` +
|
||||
`SSH refused to connect. Verify the change is expected, then remove the old key ` +
|
||||
`with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}`
|
||||
)
|
||||
case SSH_ERROR.AUTH_FAILED:
|
||||
return (
|
||||
`SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +
|
||||
`(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +
|
||||
`ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` +
|
||||
`~/.ssh/config. Original error: ${String(stderr || '').trim()}`
|
||||
)
|
||||
case SSH_ERROR.UNREACHABLE:
|
||||
return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`
|
||||
case SSH_ERROR.TIMEOUT:
|
||||
return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`
|
||||
default:
|
||||
return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn helper — runs an ssh invocation, races it against a hard timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the
|
||||
// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.
|
||||
function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child
|
||||
try {
|
||||
child = spawnFn('ssh', args, { stdio: [stdin === 'ignore' ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
const err = new Error(`ssh timed out after ${timeoutMs}ms`)
|
||||
err.kind = SSH_ERROR.TIMEOUT
|
||||
reject(err)
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on('data', d => {
|
||||
stdout += d.toString()
|
||||
})
|
||||
child.stderr?.on('data', d => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', error => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', code => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ code, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SshConnection — the public manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SshConnection {
|
||||
/**
|
||||
* @param {{host:string, user?:string, port?:number, keyPath?:string}} cfg
|
||||
* @param {{ spawnFn?, rememberLog?, controlDir?, connectTimeoutMs?, execTimeoutMs?, forwardTimeoutMs? }} [opts]
|
||||
*/
|
||||
constructor(cfg, opts = {}) {
|
||||
if (!cfg || !cfg.host) {
|
||||
throw new Error('SshConnection requires a host.')
|
||||
}
|
||||
this.host = cfg.host
|
||||
this.user = cfg.user || ''
|
||||
this.port = cfg.port ? Number(cfg.port) : 22
|
||||
this.keyPath = cfg.keyPath || ''
|
||||
this.controlPath = controlSocketPath(this.user, this.host, this.port, opts.controlDir)
|
||||
|
||||
this._spawnFn = opts.spawnFn || spawn
|
||||
this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}
|
||||
this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
|
||||
this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS
|
||||
this._opened = false
|
||||
}
|
||||
|
||||
// Lifecycle logging — ALWAYS through redaction.
|
||||
_logLine(msg) {
|
||||
this._log(redactSecrets(`[ssh] ${msg}`))
|
||||
}
|
||||
|
||||
// Throw a classified, UI-ready error from an ssh result/exception.
|
||||
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
|
||||
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
|
||||
const err = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))
|
||||
err.kind = SSH_ERROR.TIMEOUT
|
||||
return err
|
||||
}
|
||||
const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''
|
||||
const kind = stderr ? classifySshError(stderr) : fallbackKind
|
||||
const err = new Error(sshErrorMessage(kind, this, stderr))
|
||||
err.kind = kind
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the persistent ControlMaster. Idempotent: if a master socket is
|
||||
// already alive (`-O check` succeeds), this is a no-op.
|
||||
async open() {
|
||||
if (await this.isAlive()) {
|
||||
this._opened = true
|
||||
return
|
||||
}
|
||||
// Ensure the control-socket directory exists — OpenSSH will not create
|
||||
// intermediate dirs for ControlPath, so a fresh box (no prior hermes-ssh
|
||||
// socket dir under $TMPDIR) would otherwise fail before the first connect.
|
||||
// 0o700: the socket grants command execution on the master; keep it private.
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.controlPath), { recursive: true, mode: 0o700 })
|
||||
} catch {
|
||||
// best effort — a pre-existing dir or a races-with-another-conn mkdir is fine
|
||||
}
|
||||
const args = buildMasterArgs(this, this._connectTimeoutMs)
|
||||
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
this._opened = true
|
||||
this._logLine('control master established')
|
||||
}
|
||||
|
||||
// `-O check` against the master socket. True iff the master is alive.
|
||||
async isAlive() {
|
||||
const args = buildControlArgs(this, 'check', [], this._connectTimeoutMs)
|
||||
try {
|
||||
const result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot remote command over the control connection. Resolves the trimmed
|
||||
// stdout; rejects with a classified error on non-zero exit or timeout.
|
||||
async exec(remoteCommand, { timeoutMs } = {}) {
|
||||
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: timeoutMs ?? this._execTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
// Establish a local→remote forward against the running master.
|
||||
// 127.0.0.1:<localPort> → <remoteHost>:<remotePort>.
|
||||
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
|
||||
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a previously-established forward. Best-effort: a failure here is
|
||||
// logged but not thrown (the master close tears everything down anyway).
|
||||
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)
|
||||
try {
|
||||
await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
|
||||
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
|
||||
} catch (error) {
|
||||
this._logLine(`cancelForward failed (ignored): ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down the master. Best-effort; never throws.
|
||||
async close() {
|
||||
if (!this._opened) return
|
||||
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
|
||||
try {
|
||||
await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
this._logLine('control master closed')
|
||||
} catch (error) {
|
||||
this._logLine(`close failed (ignored): ${error.message}`)
|
||||
} finally {
|
||||
this._opened = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free local port — for the tunnel's local end. Bind 127.0.0.1:0, read the
|
||||
// kernel-assigned port, release. There is a benign TOCTOU window between
|
||||
// release and the forward grabbing it; the forward failing is caught upstream
|
||||
// and retried with a fresh port.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickLocalPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address()
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONTROL_PERSIST_SECONDS,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_EXEC_TIMEOUT_MS,
|
||||
DEFAULT_FORWARD_TIMEOUT_MS,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
controlSocketPath,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
sshErrorMessage,
|
||||
target
|
||||
}
|
||||
343
apps/desktop/electron/ssh-connection.test.cjs
Normal file
343
apps/desktop/electron/ssh-connection.test.cjs
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Tests for electron/ssh-connection.cjs.
|
||||
*
|
||||
* Run with: node --test electron/ssh-connection.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Pure, electron-free: command construction, secret redaction, error
|
||||
* classification, and the SshConnection lifecycle are exercised with an
|
||||
* injected fake `spawn` so no real ssh process is started.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
controlSocketPath,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
redactSecrets,
|
||||
sshErrorMessage,
|
||||
target
|
||||
} = require('./ssh-connection.cjs')
|
||||
|
||||
// --- secret redaction -------------------------------------------------------
|
||||
|
||||
test('redactSecrets scrubs the spawn-time session token env var', () => {
|
||||
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
|
||||
const out = redactSecrets(line)
|
||||
assert.ok(!out.includes('abc123deadbeef'))
|
||||
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
|
||||
// non-secret env vars are preserved
|
||||
assert.match(out, /HERMES_DESKTOP=1/)
|
||||
})
|
||||
|
||||
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
|
||||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
|
||||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
|
||||
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
|
||||
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
|
||||
})
|
||||
|
||||
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
|
||||
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
|
||||
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
|
||||
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
|
||||
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
|
||||
})
|
||||
|
||||
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
|
||||
assert.equal(redactSecrets(null), '')
|
||||
assert.equal(redactSecrets(undefined), '')
|
||||
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
|
||||
})
|
||||
|
||||
// --- control-socket path ----------------------------------------------------
|
||||
|
||||
test('controlSocketPath is stable, short, and host-distinct', () => {
|
||||
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||||
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||||
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
|
||||
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
|
||||
assert.notEqual(a, b, 'different host → different socket')
|
||||
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
|
||||
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
|
||||
})
|
||||
|
||||
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
|
||||
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>`
|
||||
// (a 17-byte suffix) while opening the master. The macOS regression was the
|
||||
// default base under os.tmpdir() (/var/folders/.../T/) pushing 89 → 106 bytes.
|
||||
// The default base must keep socket + 17-byte suffix comfortably under 104.
|
||||
const p = controlSocketPath('hermes', 'vbuddy-ubuntu', 22) // no baseDir → default
|
||||
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
|
||||
assert.ok(
|
||||
worstCase.length <= 104,
|
||||
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
|
||||
)
|
||||
// And it must NOT live under the deeply-nested macOS per-user temp dir.
|
||||
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
|
||||
})
|
||||
|
||||
// --- command construction ---------------------------------------------------
|
||||
|
||||
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
|
||||
const opts = baseSshOptions('/tmp/x.sock', 15000)
|
||||
const joined = opts.join(' ')
|
||||
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
|
||||
assert.match(joined, /ControlMaster=auto/)
|
||||
assert.match(joined, /ControlPersist=\d+/)
|
||||
assert.match(joined, /BatchMode=yes/)
|
||||
assert.match(joined, /StrictHostKeyChecking=accept-new/)
|
||||
assert.match(joined, /ConnectTimeout=15/)
|
||||
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
|
||||
})
|
||||
|
||||
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
|
||||
assert.deepEqual(hostArgs({ port: 22 }), [])
|
||||
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
|
||||
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
|
||||
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
|
||||
})
|
||||
|
||||
test('target builds user@host or bare host', () => {
|
||||
assert.equal(target('me', 'box'), 'me@box')
|
||||
assert.equal(target('', 'box'), 'box')
|
||||
})
|
||||
|
||||
test('buildExecArgs ends with host then the remote command', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildExecArgs(conn, 'command -v hermes', 15000)
|
||||
assert.equal(args[args.length - 1], 'command -v hermes')
|
||||
assert.equal(args[args.length - 2], 'me@box')
|
||||
assert.ok(args.includes('BatchMode=yes'))
|
||||
})
|
||||
|
||||
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
|
||||
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
|
||||
assert.equal(args[0], '-O')
|
||||
assert.equal(args[1], 'forward')
|
||||
assert.ok(args.includes('-L'))
|
||||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||||
assert.equal(args[args.length - 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildMasterArgs(conn, 15000)
|
||||
assert.ok(args.includes('-M'))
|
||||
assert.ok(args.includes('-N'))
|
||||
assert.ok(args.includes('-f'))
|
||||
})
|
||||
|
||||
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
|
||||
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
|
||||
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
|
||||
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||||
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
|
||||
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
|
||||
assert.equal(args[args.length - 2], 'me@box')
|
||||
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
|
||||
const remoteCmd = args[args.length - 1]
|
||||
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
|
||||
// the embedded quote must be escaped, not break out of the quoting
|
||||
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
|
||||
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
|
||||
})
|
||||
|
||||
// --- error classification ---------------------------------------------------
|
||||
|
||||
test('classifySshError detects a changed host key (fail-closed)', () => {
|
||||
assert.equal(
|
||||
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
|
||||
SSH_ERROR.HOST_KEY_CHANGED
|
||||
)
|
||||
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
|
||||
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
|
||||
})
|
||||
|
||||
test('classifySshError detects auth failure', () => {
|
||||
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
|
||||
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
|
||||
})
|
||||
|
||||
test('classifySshError detects unreachable', () => {
|
||||
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
|
||||
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
|
||||
})
|
||||
|
||||
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22 }
|
||||
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
|
||||
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
|
||||
})
|
||||
|
||||
// --- SshConnection lifecycle with injected fake spawn -----------------------
|
||||
|
||||
// A fake child process that emits a scripted result on next tick.
|
||||
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false } = {}) {
|
||||
const child = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.kill = () => {
|
||||
child._killed = true
|
||||
}
|
||||
if (hang) {
|
||||
return child // never emits close → drives the timeout path
|
||||
}
|
||||
process.nextTick(() => {
|
||||
if (errorEvent) {
|
||||
child.emit('error', errorEvent)
|
||||
return
|
||||
}
|
||||
if (stdout) child.stdout.emit('data', Buffer.from(stdout))
|
||||
if (stderr) child.stderr.emit('data', Buffer.from(stderr))
|
||||
child.emit('close', code)
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
// Build a spawnFn that returns scripted children per ssh invocation, recording
|
||||
// the args it was called with.
|
||||
function scriptedSpawn(scripts) {
|
||||
const calls = []
|
||||
let i = 0
|
||||
const fn = (_cmd, args) => {
|
||||
calls.push(args)
|
||||
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
|
||||
i += 1
|
||||
return fakeChild(script || {})
|
||||
}
|
||||
fn.calls = calls
|
||||
return fn
|
||||
}
|
||||
|
||||
test('open() establishes the master when not already alive', async () => {
|
||||
// `-O check` fails first (not alive) → master opens (code 0). Track which
|
||||
// ssh ops ran rather than re-probing with the same always-failing check.
|
||||
const ops = []
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
|
||||
if (args.includes('check')) return { code: 255, stderr: 'no control path' }
|
||||
return { code: 0 }
|
||||
})
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
|
||||
})
|
||||
|
||||
test('open() is a no-op when the master is already alive', async () => {
|
||||
const ops = []
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : 'master')
|
||||
return { code: 0 } // check succeeds → already alive
|
||||
})
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check'], 'alive master → no second spawn to open it')
|
||||
})
|
||||
|
||||
test('open() creates the control-socket directory if it does not exist', async () => {
|
||||
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
|
||||
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
try {
|
||||
await conn.open()
|
||||
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('open() surfaces a classified auth error', async () => {
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) return { code: 255 }
|
||||
return { code: 255, stderr: 'Permission denied (publickey).' }
|
||||
})
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(() => conn.open(), err => {
|
||||
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
|
||||
assert.match(err.message, /ssh-agent|ssh-add/)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
|
||||
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
|
||||
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
|
||||
|
||||
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
|
||||
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(() => conn2.exec('uname -s'), err => {
|
||||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
|
||||
const spawnFn = scriptedSpawn([{ hang: true }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(() => conn.exec('uname -s', { timeoutMs: 30 }), err => {
|
||||
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 0 }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.forward(5000, 6000)
|
||||
const args = spawnFn.calls[0]
|
||||
assert.equal(args[0], '-O')
|
||||
assert.equal(args[1], 'forward')
|
||||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||||
})
|
||||
|
||||
test('lifecycle logging passes through redaction', async () => {
|
||||
const logs = []
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection(
|
||||
{ host: 'box', user: 'me' },
|
||||
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
|
||||
)
|
||||
await conn.open()
|
||||
// none of the emitted log lines may carry a raw token-shaped secret
|
||||
for (const line of logs) {
|
||||
assert.ok(!/token=[^<]/.test(line))
|
||||
}
|
||||
assert.ok(logs.some(l => l.includes('[ssh]')))
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/ssh-connection.test.cjs electron/remote-lifecycle.test.cjs electron/ssh-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor, Network } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
@@ -13,9 +13,10 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
type Mode = 'local' | 'remote' | 'ssh'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
type SshTestStatus = 'idle' | 'testing' | 'ok' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
@@ -25,6 +26,11 @@ interface GatewaySettingsState {
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
sshHost: string
|
||||
sshUser: string
|
||||
sshPort: number | null
|
||||
sshKeyPath: string
|
||||
sshRemoteHermesPath: string
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
@@ -34,7 +40,12 @@ const EMPTY_STATE: GatewaySettingsState = {
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
remoteUrl: '',
|
||||
sshHost: '',
|
||||
sshUser: '',
|
||||
sshPort: null,
|
||||
sshKeyPath: '',
|
||||
sshRemoteHermesPath: ''
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
@@ -105,6 +116,12 @@ export function GatewaySettings() {
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// SSH-mode local UI state: the connection test result, ~/.ssh/config host
|
||||
// suggestions, and the `ssh -G` resolution of the entered host.
|
||||
const [sshTestStatus, setSshTestStatus] = useState<SshTestStatus>('idle')
|
||||
const [sshTestMessage, setSshTestMessage] = useState<null | string>(null)
|
||||
const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
@@ -265,6 +282,23 @@ export function GatewaySettings() {
|
||||
// per-profile scopes are the named, non-default profiles.
|
||||
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
|
||||
|
||||
// Load ~/.ssh/config host suggestions once SSH mode is active (read-only).
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'ssh') return
|
||||
const desktop = window.hermesDesktop
|
||||
if (!desktop?.sshConfigHosts) return
|
||||
let cancelled = false
|
||||
desktop
|
||||
.sshConfigHosts()
|
||||
.then(result => {
|
||||
if (!cancelled) setSshHostSuggestions(result.hosts || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSshHostSuggestions([])
|
||||
})
|
||||
return () => void (cancelled = true)
|
||||
}, [state.mode])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
@@ -407,7 +441,7 @@ export function GatewaySettings() {
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const message = g.connectedTo(result.baseUrl, result.version ?? undefined)
|
||||
const message = g.connectedTo(result.baseUrl ?? trimmedUrl, result.version ?? undefined)
|
||||
setLastTest(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
} catch (err) {
|
||||
@@ -417,6 +451,108 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSH mode -------------------------------------------------------------
|
||||
|
||||
const canUseSsh = Boolean(state.sshHost.trim())
|
||||
|
||||
const sshPayload = () => ({
|
||||
mode: 'ssh' as const,
|
||||
profile: scope ?? undefined,
|
||||
sshHost: state.sshHost.trim(),
|
||||
sshUser: state.sshUser.trim() || undefined,
|
||||
sshPort: state.sshPort ?? undefined,
|
||||
sshKeyPath: state.sshKeyPath.trim() || undefined,
|
||||
sshRemoteHermesPath: state.sshRemoteHermesPath.trim() || undefined
|
||||
})
|
||||
|
||||
// Map an SSH test error kind to actionable copy.
|
||||
const sshErrorMessage = (kind: string | null | undefined, raw: string | null | undefined): string => {
|
||||
switch (kind) {
|
||||
case 'auth-failed':
|
||||
return g.sshErrAuth
|
||||
case 'unreachable':
|
||||
return g.sshErrUnreachable
|
||||
case 'host-key-changed':
|
||||
return g.sshErrHostKey
|
||||
case 'hermes-not-found':
|
||||
return g.sshErrNotInstalled
|
||||
case 'unsupported-platform':
|
||||
return g.sshErrPlatform
|
||||
case 'timeout':
|
||||
return g.sshErrTimeout
|
||||
default:
|
||||
return raw || g.sshErrUnknown
|
||||
}
|
||||
}
|
||||
|
||||
const sshTest = async () => {
|
||||
if (!canUseSsh) {
|
||||
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
|
||||
return
|
||||
}
|
||||
setSshTestStatus('testing')
|
||||
setSshTestMessage(null)
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig(sshPayload())
|
||||
if (result.reachable) {
|
||||
const message = g.sshReachable(result.host ?? state.sshHost, result.remotePlatform ?? '?')
|
||||
setSshTestStatus('ok')
|
||||
setSshTestMessage(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
} else {
|
||||
const message = sshErrorMessage(result.sshError, result.error)
|
||||
setSshTestStatus('error')
|
||||
setSshTestMessage(message)
|
||||
notify({ kind: 'warning', title: g.testFailed, message })
|
||||
}
|
||||
} catch (err) {
|
||||
setSshTestStatus('error')
|
||||
setSshTestMessage(err instanceof Error ? err.message : String(err))
|
||||
notifyError(err, g.testFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the entered host via `ssh -G` and fill in any blank user/port the
|
||||
// alias expands to (so the saved config matches what ssh will actually use).
|
||||
const sshResolve = async () => {
|
||||
const host = state.sshHost.trim()
|
||||
if (!host || !window.hermesDesktop?.sshResolveHost) return
|
||||
try {
|
||||
const resolved = await window.hermesDesktop.sshResolveHost(host)
|
||||
setState(current => ({
|
||||
...current,
|
||||
sshUser: current.sshUser.trim() || resolved.user || '',
|
||||
sshPort: current.sshPort ?? (resolved.port && resolved.port !== 22 ? resolved.port : null),
|
||||
sshKeyPath: current.sshKeyPath.trim() || resolved.identityFile || ''
|
||||
}))
|
||||
} catch {
|
||||
// best-effort enrichment; leave the fields as entered
|
||||
}
|
||||
}
|
||||
|
||||
const sshSave = async (apply: boolean) => {
|
||||
if (!canUseSsh) {
|
||||
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = apply
|
||||
? await window.hermesDesktop.applyConnectionConfig(sshPayload())
|
||||
: await window.hermesDesktop.saveConnectionConfig(sshPayload())
|
||||
setState(next)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: apply ? g.restartingTitle : g.savedTitle,
|
||||
message: apply ? g.restartingMessage : g.savedMessage
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, apply ? g.applyFailed : g.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label={g.loading} />
|
||||
}
|
||||
@@ -477,7 +613,7 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
@@ -494,22 +630,32 @@ export function GatewaySettings() {
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
title={g.remoteTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'ssh'}
|
||||
description={g.sshDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Network}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))}
|
||||
title={g.sshTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description={g.remoteUrlDesc}
|
||||
title={g.remoteUrlTitle}
|
||||
/>
|
||||
{state.mode === 'remote' ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description={g.remoteUrlDesc}
|
||||
title={g.remoteUrlTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
@@ -579,28 +725,159 @@ export function GatewaySettings() {
|
||||
title={g.tokenTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* SSH mode: connect via the box's SSH access; no token to copy. */}
|
||||
{state.mode === 'ssh' ? (
|
||||
<>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
list="hermes-ssh-host-suggestions"
|
||||
onBlur={() => void sshResolve()}
|
||||
onChange={event => setState(current => ({ ...current, sshHost: event.target.value }))}
|
||||
placeholder="user@mac-mini.local or mac-mini"
|
||||
value={state.sshHost}
|
||||
/>
|
||||
}
|
||||
description={g.sshHostDesc}
|
||||
title={g.sshHostTitle}
|
||||
/>
|
||||
{sshHostSuggestions.length > 0 ? (
|
||||
<datalist id="hermes-ssh-host-suggestions">
|
||||
{sshHostSuggestions.map(host => (
|
||||
<option key={host} value={host} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))}
|
||||
placeholder={g.sshUserPlaceholder}
|
||||
value={state.sshUser}
|
||||
/>
|
||||
}
|
||||
description={g.sshUserDesc}
|
||||
title={g.sshUserTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event =>
|
||||
setState(current => ({
|
||||
...current,
|
||||
sshPort: event.target.value.trim() ? Number.parseInt(event.target.value, 10) || null : null
|
||||
}))
|
||||
}
|
||||
placeholder="22"
|
||||
value={state.sshPort != null ? String(state.sshPort) : ''}
|
||||
/>
|
||||
}
|
||||
description={g.sshPortDesc}
|
||||
title={g.sshPortTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={state.sshKeyPath}
|
||||
/>
|
||||
}
|
||||
description={g.sshKeyDesc}
|
||||
title={g.sshKeyTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))}
|
||||
placeholder={g.sshHermesPathPlaceholder}
|
||||
value={state.sshRemoteHermesPath}
|
||||
/>
|
||||
}
|
||||
description={g.sshHermesPathDesc}
|
||||
title={g.sshHermesPathTitle}
|
||||
/>
|
||||
{sshTestStatus !== 'idle' && sshTestMessage ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)]',
|
||||
sshTestStatus === 'ok' ? 'text-primary' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{sshTestStatus === 'testing' ? (
|
||||
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />
|
||||
) : sshTestStatus === 'ok' ? (
|
||||
<Check className="mt-0.5 size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
)}
|
||||
<span>{sshTestMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
{state.mode === 'ssh' ? (
|
||||
<>
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || sshTestStatus === 'testing' || !canUseSsh}
|
||||
onClick={() => void sshTest()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{sshTestStatus === 'testing' ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.sshTestConnection}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={state.envOverride || saving}
|
||||
onClick={() => void sshSave(false)}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving || !canUseSsh} onClick={() => void sshSave(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.sshConnect}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-1">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Command,
|
||||
Hash,
|
||||
Loader2,
|
||||
Network,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Zap,
|
||||
@@ -47,7 +48,7 @@ import {
|
||||
} from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
@@ -291,8 +292,68 @@ export function useStatusbarItems({
|
||||
copy
|
||||
])
|
||||
|
||||
// Connection-identity pill (VS Code's load-bearing "where am I?" cue). Shown
|
||||
// only for remote connections; hidden in local mode (the unmarked default).
|
||||
// SSH remotes read "SSH: user@host"; token/oauth remotes read "Remote: host"
|
||||
// — closing the same gap for the existing remote modes. Clicking opens the
|
||||
// gateway connection settings so the pill doubles as the switch/disconnect
|
||||
// entry point.
|
||||
const connectionItem = useMemo<StatusbarItem | null>(() => {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
// Prefer the host main.cjs put on the descriptor; fall back to parsing the
|
||||
// backend URL (never the 127.0.0.1 tunnel — that's only the SSH baseUrl,
|
||||
// and SSH descriptors always carry remoteHost).
|
||||
let host = connection.remoteHost ?? ''
|
||||
if (!host && connection.baseUrl) {
|
||||
try {
|
||||
host = new URL(connection.baseUrl).host
|
||||
} catch {
|
||||
host = ''
|
||||
}
|
||||
}
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isSsh = connection.remoteKind === 'ssh'
|
||||
const label = isSsh ? copy.connectionSsh(host) : copy.connectionRemote(host)
|
||||
const baseTooltip = isSsh ? copy.connectionSshTooltip(host) : copy.connectionRemoteTooltip(host)
|
||||
// Append the per-profile scope when this is a profile-scoped connection, so
|
||||
// the pill discloses WHICH profile the host backs (not just the host).
|
||||
const profile = connection.profile
|
||||
const title = profile ? `${baseTooltip} · ${profile}` : baseTooltip
|
||||
|
||||
return {
|
||||
// VS Code-style remote indicator: a solid colored block (not a muted
|
||||
// pill) so "you are running on a remote host" is unmistakable, pinned to
|
||||
// the FAR LEFT of the status bar. SSH gets the primary accent; a plain URL
|
||||
// remote gets a calmer tint so the two are visually distinct.
|
||||
className: cn(
|
||||
'px-2 font-medium',
|
||||
isSsh
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
|
||||
: 'bg-accent text-accent-foreground hover:bg-accent/90 hover:text-accent-foreground'
|
||||
),
|
||||
icon: <Network className="size-3" />,
|
||||
id: 'connection',
|
||||
label,
|
||||
title,
|
||||
// Deep-link straight to the Gateway connection panel (the settings index
|
||||
// reads ?tab=), so the pill lands the user where they manage/switch it.
|
||||
// NB: default (button) variant — NOT 'link', which renders an <a href> and
|
||||
// would swallow the in-app `to:` navigation.
|
||||
to: `${SETTINGS_ROUTE}?tab=gateway`
|
||||
}
|
||||
}, [connection?.mode, connection?.remoteHost, connection?.remoteKind, connection?.baseUrl, connection?.profile, copy])
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
// Remote-connection indicator pinned to the far left (VS Code parity) —
|
||||
// first thing in the bar so "where am I running" is the dominant cue.
|
||||
// Absent in local mode.
|
||||
...(connectionItem ? [connectionItem] : []),
|
||||
{
|
||||
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
@@ -359,6 +420,7 @@ export function useStatusbarItems({
|
||||
bgFailed,
|
||||
bgRunning,
|
||||
commandCenterOpen,
|
||||
connectionItem,
|
||||
copy,
|
||||
gatewayMenuContent,
|
||||
gatewayClassName,
|
||||
|
||||
@@ -14,6 +14,11 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: 'https://box:9119',
|
||||
sshHost: '',
|
||||
sshUser: '',
|
||||
sshPort: null,
|
||||
sshKeyPath: '',
|
||||
sshRemoteHermesPath: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
54
apps/desktop/src/global.d.ts
vendored
54
apps/desktop/src/global.d.ts
vendored
@@ -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,13 @@ 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
|
||||
// Distinguishes an SSH-tunnelled remote ('ssh') from a direct URL remote
|
||||
// ('url') so the pill can label it SSH: vs Remote:. Absent in local mode.
|
||||
remoteKind?: 'ssh' | 'url'
|
||||
nativeOverlayWidth: number
|
||||
source?: 'env' | 'local' | 'settings'
|
||||
token: string
|
||||
@@ -313,31 +322,66 @@ 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
|
||||
// Remote-auth fields are always present (the sanitizer fills defaults even in
|
||||
// local/ssh mode) so consumers can read them without optional-narrowing.
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
// SSH mode fields. Always present on the contract (empty strings / null in
|
||||
// local/remote mode, populated when mode === 'ssh') so the renderer never
|
||||
// optional-narrows. 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 {
|
||||
|
||||
@@ -504,7 +504,36 @@ export const en: Translations = {
|
||||
signOutFailed: 'Sign-out failed',
|
||||
testFailed: 'Remote gateway test failed',
|
||||
applyFailed: 'Could not apply gateway settings',
|
||||
saveFailed: 'Could not save gateway settings'
|
||||
saveFailed: 'Could not save gateway settings',
|
||||
sshTitle: 'Connect via SSH',
|
||||
sshDesc:
|
||||
'Reach a remote Hermes backend over SSH — no exposed dashboard port, no token to copy. Hermes is bootstrapped on the remote and tunneled to this app.',
|
||||
sshHostTitle: 'Host',
|
||||
sshHostDesc: 'The SSH target, e.g. user@mac-mini.local or a Host alias from ~/.ssh/config.',
|
||||
sshUserTitle: 'User',
|
||||
sshUserDesc: 'SSH username. Leave blank to use ~/.ssh/config or your current user.',
|
||||
sshUserPlaceholder: 'from ~/.ssh/config',
|
||||
sshPortTitle: 'Port',
|
||||
sshPortDesc: 'SSH port. Leave blank for 22 (or the port set in ~/.ssh/config).',
|
||||
sshKeyTitle: 'Identity file',
|
||||
sshKeyDesc: 'Optional private key path. Leave blank to use your ssh-agent or ~/.ssh/config.',
|
||||
sshHermesPathTitle: 'Hermes path (optional)',
|
||||
sshHermesPathDesc: 'Override where hermes is found on the remote. Leave blank to auto-detect.',
|
||||
sshHermesPathPlaceholder: 'auto-detect',
|
||||
sshTestConnection: 'Test SSH',
|
||||
sshConnect: 'Connect',
|
||||
sshReachable: (host, platform) => `Reachable: ${host} (${platform}) — Hermes found`,
|
||||
sshIncompleteHost: 'Enter an SSH host before connecting.',
|
||||
sshErrUnreachable: 'Could not reach that host over SSH. Check the host, port, and your network.',
|
||||
sshErrAuth:
|
||||
'SSH authentication failed. Load your key into the ssh-agent (ssh-add) or set an IdentityFile in ~/.ssh/config — Hermes runs ssh non-interactively.',
|
||||
sshErrHostKey:
|
||||
'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.',
|
||||
sshErrNotInstalled:
|
||||
'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.',
|
||||
sshErrPlatform: 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux and macOS remote hosts only.',
|
||||
sshErrTimeout: 'The SSH connection timed out. The host may be unreachable or asleep.',
|
||||
sshErrUnknown: 'SSH connection failed.'
|
||||
},
|
||||
keys: {
|
||||
loading: 'Loading API keys and credentials...',
|
||||
@@ -1593,6 +1622,10 @@ export const en: Translations = {
|
||||
backendVersion: version => `Backend v${version}`,
|
||||
clientLabel: version => `client v${version}`,
|
||||
backendLabel: version => `backend v${version}`,
|
||||
connectionSsh: host => `SSH: ${host}`,
|
||||
connectionRemote: host => `Remote: ${host}`,
|
||||
connectionSshTooltip: host => `Connected over SSH to ${host} · click to manage`,
|
||||
connectionRemoteTooltip: host => `Connected to remote backend ${host} · click to manage`,
|
||||
commit: sha => `commit ${sha}`,
|
||||
branch: branch => `branch ${branch}`,
|
||||
closeCommandCenter: 'Close Command Center',
|
||||
|
||||
@@ -631,7 +631,36 @@ export const ja = defineLocale({
|
||||
signOutFailed: 'サインアウトに失敗しました',
|
||||
testFailed: 'リモートゲートウェイのテストに失敗しました',
|
||||
applyFailed: 'ゲートウェイ設定を適用できませんでした',
|
||||
saveFailed: 'ゲートウェイ設定を保存できませんでした'
|
||||
saveFailed: 'ゲートウェイ設定を保存できませんでした',
|
||||
sshTitle: 'SSH で接続',
|
||||
sshDesc:
|
||||
'SSH 経由でリモートの Hermes バックエンドに接続します。ダッシュボードポートの公開もトークンのコピーも不要です。リモート側で Hermes を起動し、このアプリにトンネルします。',
|
||||
sshHostTitle: 'ホスト',
|
||||
sshHostDesc: 'SSH の接続先。例: user@mac-mini.local、または ~/.ssh/config の Host エイリアス。',
|
||||
sshUserTitle: 'ユーザー',
|
||||
sshUserDesc: 'SSH ユーザー名。空欄の場合は ~/.ssh/config または現在のユーザーを使用します。',
|
||||
sshUserPlaceholder: '~/.ssh/config から',
|
||||
sshPortTitle: 'ポート',
|
||||
sshPortDesc: 'SSH ポート。空欄の場合は 22(または ~/.ssh/config の設定)。',
|
||||
sshKeyTitle: '鍵ファイル',
|
||||
sshKeyDesc: '秘密鍵のパス(任意)。空欄の場合は ssh-agent または ~/.ssh/config を使用します。',
|
||||
sshHermesPathTitle: 'Hermes パス(任意)',
|
||||
sshHermesPathDesc: 'リモート上の hermes の場所を上書きします。空欄の場合は自動検出します。',
|
||||
sshHermesPathPlaceholder: '自動検出',
|
||||
sshTestConnection: 'SSH をテスト',
|
||||
sshConnect: '接続',
|
||||
sshReachable: (host, platform) => `接続可能: ${host}(${platform})— Hermes を検出`,
|
||||
sshIncompleteHost: '接続する前に SSH ホストを入力してください。',
|
||||
sshErrUnreachable: 'SSH でそのホストに到達できませんでした。ホスト、ポート、ネットワークを確認してください。',
|
||||
sshErrAuth:
|
||||
'SSH 認証に失敗しました。鍵を ssh-agent に読み込む(ssh-add)か、~/.ssh/config に IdentityFile を設定してください。Hermes は非対話的に ssh を実行します。',
|
||||
sshErrHostKey:
|
||||
'前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。',
|
||||
sshErrNotInstalled:
|
||||
'リモートホストに Hermes がインストールされていません。リモートでインストールする(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)か、Hermes パスを設定してください。',
|
||||
sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux と macOS のリモートホストのみ対応しています。',
|
||||
sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',
|
||||
sshErrUnknown: 'SSH 接続に失敗しました。'
|
||||
},
|
||||
keys: {
|
||||
loading: 'API キーと認証情報を読み込み中...',
|
||||
@@ -1722,6 +1751,10 @@ export const ja = defineLocale({
|
||||
backendVersion: version => `バックエンド v${version}`,
|
||||
clientLabel: version => `クライアント v${version}`,
|
||||
backendLabel: version => `バックエンド v${version}`,
|
||||
connectionSsh: host => `SSH: ${host}`,
|
||||
connectionRemote: host => `リモート: ${host}`,
|
||||
connectionSshTooltip: host => `SSH 経由で ${host} に接続中 · クリックして管理`,
|
||||
connectionRemoteTooltip: host => `リモートバックエンド ${host} に接続中 · クリックして管理`,
|
||||
commit: sha => `コミット ${sha}`,
|
||||
branch: branch => `ブランチ ${branch}`,
|
||||
closeCommandCenter: 'コマンドセンターを閉じる',
|
||||
|
||||
@@ -396,6 +396,31 @@ export interface Translations {
|
||||
testFailed: string
|
||||
applyFailed: string
|
||||
saveFailed: string
|
||||
sshTitle: string
|
||||
sshDesc: string
|
||||
sshHostTitle: string
|
||||
sshHostDesc: string
|
||||
sshUserTitle: string
|
||||
sshUserDesc: string
|
||||
sshUserPlaceholder: string
|
||||
sshPortTitle: string
|
||||
sshPortDesc: string
|
||||
sshKeyTitle: string
|
||||
sshKeyDesc: string
|
||||
sshHermesPathTitle: string
|
||||
sshHermesPathDesc: string
|
||||
sshHermesPathPlaceholder: string
|
||||
sshTestConnection: string
|
||||
sshConnect: string
|
||||
sshReachable: (host: string, platform: string) => string
|
||||
sshIncompleteHost: string
|
||||
sshErrUnreachable: string
|
||||
sshErrAuth: string
|
||||
sshErrHostKey: string
|
||||
sshErrNotInstalled: string
|
||||
sshErrPlatform: string
|
||||
sshErrTimeout: string
|
||||
sshErrUnknown: string
|
||||
}
|
||||
keys: {
|
||||
loading: string
|
||||
@@ -1230,6 +1255,10 @@ export interface Translations {
|
||||
backendVersion: (version: string) => string
|
||||
clientLabel: (version: string) => string
|
||||
backendLabel: (version: string) => string
|
||||
connectionSsh: (host: string) => string
|
||||
connectionRemote: (host: string) => string
|
||||
connectionSshTooltip: (host: string) => string
|
||||
connectionRemoteTooltip: (host: string) => string
|
||||
commit: (sha: string) => string
|
||||
branch: (branch: string) => string
|
||||
closeCommandCenter: string
|
||||
|
||||
@@ -610,7 +610,36 @@ export const zhHant = defineLocale({
|
||||
signOutFailed: '登出失敗',
|
||||
testFailed: '遠端閘道測試失敗',
|
||||
applyFailed: '無法套用閘道設定',
|
||||
saveFailed: '無法儲存閘道設定'
|
||||
saveFailed: '無法儲存閘道設定',
|
||||
sshTitle: '透過 SSH 連線',
|
||||
sshDesc:
|
||||
'透過 SSH 連線到遠端 Hermes 後端——無需公開儀表板連接埠,也無需複製權杖。Hermes 會在遠端主機上啟動並透過通道連線到本應用程式。',
|
||||
sshHostTitle: '主機',
|
||||
sshHostDesc: 'SSH 目標,例如 user@mac-mini.local,或 ~/.ssh/config 中的 Host 別名。',
|
||||
sshUserTitle: '使用者',
|
||||
sshUserDesc: 'SSH 使用者名稱。留空則使用 ~/.ssh/config 或目前使用者。',
|
||||
sshUserPlaceholder: '來自 ~/.ssh/config',
|
||||
sshPortTitle: '連接埠',
|
||||
sshPortDesc: 'SSH 連接埠。留空則為 22(或 ~/.ssh/config 中設定的連接埠)。',
|
||||
sshKeyTitle: '金鑰檔案',
|
||||
sshKeyDesc: '選用的私密金鑰路徑。留空則使用 ssh-agent 或 ~/.ssh/config。',
|
||||
sshHermesPathTitle: 'Hermes 路徑(選用)',
|
||||
sshHermesPathDesc: '覆寫遠端主機上 hermes 的位置。留空則自動偵測。',
|
||||
sshHermesPathPlaceholder: '自動偵測',
|
||||
sshTestConnection: '測試 SSH',
|
||||
sshConnect: '連線',
|
||||
sshReachable: (host, platform) => `可連線:${host}(${platform})——已找到 Hermes`,
|
||||
sshIncompleteHost: '連線前請輸入 SSH 主機。',
|
||||
sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。',
|
||||
sshErrAuth:
|
||||
'SSH 驗證失敗。請將金鑰載入 ssh-agent(ssh-add),或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。',
|
||||
sshErrHostKey:
|
||||
'自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
|
||||
sshErrNotInstalled:
|
||||
'遠端主機上未安裝 Hermes。請在遠端安裝(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或設定 Hermes 路徑。',
|
||||
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式僅支援 Linux 和 macOS 遠端主機。',
|
||||
sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。',
|
||||
sshErrUnknown: 'SSH 連線失敗。'
|
||||
},
|
||||
keys: {
|
||||
loading: '正在載入 API 金鑰和憑證...',
|
||||
@@ -1665,6 +1694,10 @@ export const zhHant = defineLocale({
|
||||
backendVersion: version => `後端 v${version}`,
|
||||
clientLabel: version => `用戶端 v${version}`,
|
||||
backendLabel: version => `後端 v${version}`,
|
||||
connectionSsh: host => `SSH: ${host}`,
|
||||
connectionRemote: host => `遠端: ${host}`,
|
||||
connectionSshTooltip: host => `已透過 SSH 連線到 ${host} · 點擊管理`,
|
||||
connectionRemoteTooltip: host => `已連線到遠端後端 ${host} · 點擊管理`,
|
||||
commit: sha => `提交 ${sha}`,
|
||||
branch: branch => `分支 ${branch}`,
|
||||
closeCommandCenter: '關閉命令中心',
|
||||
|
||||
@@ -698,7 +698,36 @@ export const zh: Translations = {
|
||||
signOutFailed: '退出登录失败',
|
||||
testFailed: '远程网关测试失败',
|
||||
applyFailed: '无法应用网关设置',
|
||||
saveFailed: '无法保存网关设置'
|
||||
saveFailed: '无法保存网关设置',
|
||||
sshTitle: '通过 SSH 连接',
|
||||
sshDesc:
|
||||
'通过 SSH 连接到远程 Hermes 后端——无需暴露面板端口,也无需复制令牌。Hermes 会在远程主机上启动并通过隧道连接到本应用。',
|
||||
sshHostTitle: '主机',
|
||||
sshHostDesc: 'SSH 目标,例如 user@mac-mini.local,或 ~/.ssh/config 中的 Host 别名。',
|
||||
sshUserTitle: '用户',
|
||||
sshUserDesc: 'SSH 用户名。留空则使用 ~/.ssh/config 或当前用户。',
|
||||
sshUserPlaceholder: '来自 ~/.ssh/config',
|
||||
sshPortTitle: '端口',
|
||||
sshPortDesc: 'SSH 端口。留空则为 22(或 ~/.ssh/config 中设置的端口)。',
|
||||
sshKeyTitle: '密钥文件',
|
||||
sshKeyDesc: '可选的私钥路径。留空则使用 ssh-agent 或 ~/.ssh/config。',
|
||||
sshHermesPathTitle: 'Hermes 路径(可选)',
|
||||
sshHermesPathDesc: '覆盖远程主机上 hermes 的位置。留空则自动检测。',
|
||||
sshHermesPathPlaceholder: '自动检测',
|
||||
sshTestConnection: '测试 SSH',
|
||||
sshConnect: '连接',
|
||||
sshReachable: (host, platform) => `可连接:${host}(${platform})——已找到 Hermes`,
|
||||
sshIncompleteHost: '连接前请输入 SSH 主机。',
|
||||
sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。',
|
||||
sshErrAuth:
|
||||
'SSH 认证失败。请将密钥加载到 ssh-agent(ssh-add),或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。',
|
||||
sshErrHostKey:
|
||||
'自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
|
||||
sshErrNotInstalled:
|
||||
'远程主机上未安装 Hermes。请在远程安装(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或设置 Hermes 路径。',
|
||||
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式仅支持 Linux 和 macOS 远程主机。',
|
||||
sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。',
|
||||
sshErrUnknown: 'SSH 连接失败。'
|
||||
},
|
||||
keys: {
|
||||
loading: '正在加载 API 密钥和凭据...',
|
||||
@@ -1770,6 +1799,10 @@ export const zh: Translations = {
|
||||
backendVersion: version => `后端 v${version}`,
|
||||
clientLabel: version => `客户端 v${version}`,
|
||||
backendLabel: version => `后端 v${version}`,
|
||||
connectionSsh: host => `SSH: ${host}`,
|
||||
connectionRemote: host => `远程: ${host}`,
|
||||
connectionSshTooltip: host => `已通过 SSH 连接到 ${host} · 点击管理`,
|
||||
connectionRemoteTooltip: host => `已连接到远程后端 ${host} · 点击管理`,
|
||||
commit: sha => `提交 ${sha}`,
|
||||
branch: branch => `分支 ${branch}`,
|
||||
closeCommandCenter: '关闭命令中心',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { $connection } from '@/store/session'
|
||||
import {
|
||||
desktopDefaultCwd,
|
||||
desktopGitRoot,
|
||||
desktopFsCacheKey,
|
||||
readDesktopDir,
|
||||
readDesktopFileDataUrl,
|
||||
readDesktopFileText,
|
||||
@@ -113,4 +114,25 @@ describe('desktop filesystem facade', () => {
|
||||
expect(remoteSelect).not.toHaveBeenCalled()
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cache key distinguishes two SSH hosts that share the same local forwarded port', () => {
|
||||
// Both remotes resolve to the same loopback tunnel baseUrl (the local
|
||||
// forwarded port is reusable across remotes). Without the remoteHost in the
|
||||
// identity these collide and one host's cached fs reads serve the other.
|
||||
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@mac-mini' } as never)
|
||||
const keyA = desktopFsCacheKey()
|
||||
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@ubuntu-box' } as never)
|
||||
const keyB = desktopFsCacheKey()
|
||||
|
||||
expect(keyA).not.toBe(keyB)
|
||||
expect(keyA).toContain('mac-mini')
|
||||
expect(keyB).toContain('ubuntu-box')
|
||||
})
|
||||
|
||||
it('cache key falls back to baseUrl when no remoteHost is present', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://box.tail1234.ts.net' } as never)
|
||||
expect(desktopFsCacheKey()).toContain('box.tail1234.ts.net')
|
||||
$connection.set(null)
|
||||
expect(desktopFsCacheKey()).toBe('local:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,14 @@ function connectionCacheKey(connection: HermesConnection | null) {
|
||||
if (!connection) {
|
||||
return 'local:'
|
||||
}
|
||||
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
|
||||
// The remote host is part of the cache identity, NOT just the baseUrl. Local
|
||||
// forwarded ports are reusable across different remotes, so two SSH hosts
|
||||
// that happen to map to the same 127.0.0.1:<localPort> would otherwise
|
||||
// collide — serving one host's cached fs reads for the other. remoteHost is
|
||||
// the user@host (SSH) or the real backend host (token/oauth); fall back to
|
||||
// baseUrl for safety.
|
||||
const host = connection.remoteHost || connection.baseUrl || ''
|
||||
return `${connection.mode || 'local'}:${connection.profile || ''}:${host}:${connection.baseUrl || ''}`
|
||||
}
|
||||
|
||||
export function desktopFsCacheKey() {
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
IconDots as MoreHorizontal,
|
||||
IconDots as MoreHorizontalIcon,
|
||||
IconDotsVertical as MoreVertical,
|
||||
IconServer as Network,
|
||||
IconNotebook as NotebookTabs,
|
||||
IconPackage as Package,
|
||||
IconPalette as Palette,
|
||||
@@ -163,6 +164,7 @@ export {
|
||||
MoreHorizontal,
|
||||
MoreHorizontalIcon,
|
||||
MoreVertical,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
Package,
|
||||
Palette,
|
||||
|
||||
Reference in New Issue
Block a user