mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(desktop): connection-config ssh mode plumbing
Wire mode:"ssh" through the desktop connection resolution chain so an SSH
remote resolves into the EXISTING token-remote machinery — SSH mode is
desktop-local mode with the loopback stretched over SSH.
connection-config.cjs (pure, node --test):
- normalizeSshConfig() validates a {mode:ssh, host, user?, port?, keyPath?,
remoteHermesPath?} entry (drops the default port, requires a host).
- profileSshOverride() resolves a profile-scoped SSH entry.
- hostLabelFromBaseUrl() derives the pill host for token/oauth remotes.
ssh-config.cjs (pure, node --test): parse ~/.ssh/config host aliases (follows
Include, filters wildcard/negated patterns, read-only, cycle-safe) and parse
ssh -G output (hostname/user/port/identityfile) for the settings UI.
main.cjs:
- readDesktopConnectionConfig / sanitizeConnectionProfiles preserve mode:ssh
and the SSH fields; coerceDesktopConnectionConfig + buildSshBlock build/save
SSH blocks (no user token; the dashboard token rides separately, encrypted).
- resolveRemoteBackend gains per-profile and global SSH branches that
bootstrap via SshConnection + remote-lifecycle.connect(), persist the served
token (encrypted), and hand buildRemoteConnection a 127.0.0.1 tunnel baseUrl
with the SSH host as the pill label.
- buildRemoteConnection gains a remoteHost param + remoteHost on every
descriptor (token/oauth pill host derived from the real URL).
- SSH connection-state registry (master + tunnel ports + remote pid per scope);
teardownSshConnection cancels the forward + closes the master but LEAVES the
remote dashboard running (reconnect-instant VS Code semantics); wired into
before-quit and connection-config:apply (flip = re-bootstrap).
- testDesktopConnectionConfig SSH branch: ssh open + uname gate + locate-hermes
WITHOUT spawning, returning distinct unreachable/auth-failed/hermes-not-found/
unsupported-platform errors. New IPC: ssh-hosts, ssh-resolve. preload +
global.d.ts updated (HermesConnection.remoteHost, SSH config/test/resolve
types).
node --test: ssh-connection 23, remote-lifecycle 24, ssh-config 9,
connection-config 55 — 111 pass. (tsc/vitest deferred: no node_modules in the
worktree; renderer typecheck to be run by the user in a populated tree.)
This commit is contained in:
parent
cc24de2caa
commit
7d1afaa769
7 changed files with 776 additions and 14 deletions
|
|
@ -269,6 +269,71 @@ function cookiesHaveLiveSession(cookies) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stored SSH connection entry into a clean descriptor, or null when
|
||||
* it is not a usable SSH config. Pure: no secrets here — the per-connection
|
||||
* dashboard token is persisted separately (encrypted) and decrypted by main.cjs,
|
||||
* exactly like the token-remote secret. An SSH entry needs at least a host.
|
||||
*
|
||||
* Shape in/out: { mode:'ssh', host, user?, port?, keyPath?, remoteHermesPath? }
|
||||
*/
|
||||
function normalizeSshConfig(entry) {
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
|
||||
return null
|
||||
}
|
||||
const host = String(entry.host || '').trim()
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
const out = { mode: 'ssh', host }
|
||||
const user = String(entry.user || '').trim()
|
||||
if (user) out.user = user
|
||||
const port = Number.parseInt(String(entry.port ?? ''), 10)
|
||||
if (Number.isInteger(port) && port > 0 && port !== 22) {
|
||||
out.port = port
|
||||
}
|
||||
const keyPath = String(entry.keyPath || '').trim()
|
||||
if (keyPath) out.keyPath = keyPath
|
||||
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
|
||||
if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's SSH connection override from a connection config, or null
|
||||
* when it has none. Mirrors profileRemoteOverride() but for `mode: 'ssh'`
|
||||
* entries. Returns the normalized SSH descriptor (no token).
|
||||
*/
|
||||
function profileSshOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
return normalizeSshConfig(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-facing host label for the connection statusbar pill. For SSH mode the
|
||||
* caller passes the resolved/entered host directly; for token/oauth remotes we
|
||||
* derive it from the (real) backend URL — NOT the loopback tunnel URL. Returns
|
||||
* a bare hostname (and :port when non-default) or null.
|
||||
*/
|
||||
function hostLabelFromBaseUrl(baseUrl) {
|
||||
const raw = String(baseUrl || '').trim()
|
||||
if (!raw) return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const host = parsed.hostname
|
||||
if (!host) return null
|
||||
const port = parsed.port
|
||||
if (port && port !== '80' && port !== '443') {
|
||||
return `${host}:${port}`
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
|
|
@ -278,10 +343,13 @@ module.exports = {
|
|||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
|
|
|||
|
|
@ -22,10 +22,13 @@ const {
|
|||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
|
@ -394,3 +397,51 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
|||
/mintTicket function is required/
|
||||
)
|
||||
})
|
||||
|
||||
// --- SSH mode helpers ---
|
||||
|
||||
test('normalizeSshConfig requires mode:ssh and a host', () => {
|
||||
assert.equal(normalizeSshConfig(null), null)
|
||||
assert.equal(normalizeSshConfig({ mode: 'remote', url: 'http://x' }), null)
|
||||
assert.equal(normalizeSshConfig({ mode: 'ssh' }), null)
|
||||
assert.equal(normalizeSshConfig({ mode: 'ssh', host: ' ' }), null)
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box' }), { mode: 'ssh', host: 'box' })
|
||||
})
|
||||
|
||||
test('normalizeSshConfig keeps user/keyPath/remoteHermesPath and drops the default port', () => {
|
||||
assert.deepEqual(
|
||||
normalizeSshConfig({
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
user: 'me',
|
||||
port: 22,
|
||||
keyPath: '~/.ssh/id_ed25519',
|
||||
remoteHermesPath: '/opt/hermes'
|
||||
}),
|
||||
{ mode: 'ssh', host: 'box', user: 'me', keyPath: '~/.ssh/id_ed25519', remoteHermesPath: '/opt/hermes' }
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeSshConfig preserves a non-default port', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 2222 }), {
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
port: 2222
|
||||
})
|
||||
})
|
||||
|
||||
test('profileSshOverride returns a profile-scoped ssh descriptor or null', () => {
|
||||
const config = { profiles: { work: { mode: 'ssh', host: 'mac-mini', user: 'jonny' }, other: { mode: 'remote', url: 'http://x' } } }
|
||||
assert.deepEqual(profileSshOverride(config, 'work'), { mode: 'ssh', host: 'mac-mini', user: 'jonny' })
|
||||
assert.equal(profileSshOverride(config, 'other'), null, 'token-remote entry is not an ssh override')
|
||||
assert.equal(profileSshOverride(config, 'missing'), null)
|
||||
assert.equal(profileSshOverride(config, ''), null, 'global scope has no profile entry')
|
||||
})
|
||||
|
||||
test('hostLabelFromBaseUrl gives a bare host, with :port only when non-default', () => {
|
||||
assert.equal(hostLabelFromBaseUrl('https://box.tail1234.ts.net'), 'box.tail1234.ts.net')
|
||||
assert.equal(hostLabelFromBaseUrl('http://box.local:8080'), 'box.local:8080')
|
||||
assert.equal(hostLabelFromBaseUrl('https://box:443'), 'box')
|
||||
assert.equal(hostLabelFromBaseUrl(''), null)
|
||||
assert.equal(hostLabelFromBaseUrl('not a url'), null)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs')
|
|||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { SSH_ERROR, SshConnection, pickLocalPort, redactSecrets } = require('./ssh-connection.cjs')
|
||||
const remoteLifecycle = require('./remote-lifecycle.cjs')
|
||||
const { collectSshConfigHosts, parseSshGOutput } = require('./ssh-config.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
|
|
@ -74,10 +77,13 @@ const {
|
|||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
|
@ -4300,6 +4306,20 @@ function sanitizeConnectionProfiles(raw) {
|
|||
continue
|
||||
}
|
||||
|
||||
// SSH-mode entries carry host/user/port/keyPath/remoteHermesPath instead of
|
||||
// a url, and (like remote entries) an encrypted token blob — the per-
|
||||
// connection dashboard session token minted in main, NOT a user secret.
|
||||
if (entry.mode === 'ssh') {
|
||||
const ssh = normalizeSshConfig(entry)
|
||||
if (ssh) {
|
||||
if (entry.token && typeof entry.token === 'object') {
|
||||
ssh.token = entry.token
|
||||
}
|
||||
out[name] = ssh
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
|
||||
const url = String(entry.url || '').trim()
|
||||
if (url) {
|
||||
|
|
@ -4343,7 +4363,10 @@ function readDesktopConnectionConfig() {
|
|||
// backward compatibility with configs written before OAuth support.
|
||||
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
// 'ssh' joins 'remote'/'local' as a top-level mode; SSH connection
|
||||
// fields (host/user/port/keyPath/remoteHermesPath) ride on the `remote`
|
||||
// sub-object, which is preserved verbatim below.
|
||||
mode: parsed.mode === 'remote' ? 'remote' : parsed.mode === 'ssh' ? 'ssh' : 'local',
|
||||
remote,
|
||||
// Per-profile remote overrides: each profile may point at its own
|
||||
// backend (local spawn or its own remote URL). Preserved verbatim so
|
||||
|
|
@ -4411,10 +4434,30 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
|||
|
||||
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
|
||||
const scopedMode = key ? scoped?.mode : config.mode
|
||||
|
||||
// SSH-mode block: surface the connection fields (no token to the renderer —
|
||||
// it's an internal artifact). remoteTokenSet reports whether a dashboard
|
||||
// token has already been adopted (i.e. a running dashboard can be reused).
|
||||
if (scopedMode === 'ssh') {
|
||||
const sshConfig = normalizeSshConfig({ mode: 'ssh', ...block })
|
||||
return {
|
||||
mode: 'ssh',
|
||||
profile: key,
|
||||
sshHost: sshConfig?.host || '',
|
||||
sshUser: sshConfig?.user || '',
|
||||
sshPort: sshConfig?.port || null,
|
||||
sshKeyPath: sshConfig?.keyPath || '',
|
||||
sshRemoteHermesPath: sshConfig?.remoteHermesPath || '',
|
||||
remoteTokenSet: Boolean(decryptDesktopSecret(block.token)),
|
||||
envOverride: false
|
||||
}
|
||||
}
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
|
||||
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
const mode = envOverride || scopedMode === 'remote' ? 'remote' : 'local'
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
|
|
@ -4457,7 +4500,21 @@ function buildRemoteBlock(remoteUrl, authMode, token) {
|
|||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||||
const persistToken = options.persistToken !== false
|
||||
const key = connectionScopeKey(input.profile)
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
const mode = input.mode === 'remote' ? 'remote' : input.mode === 'ssh' ? 'ssh' : 'local'
|
||||
|
||||
// SSH-mode save: connection fields are host/user/port/keyPath/remoteHermesPath
|
||||
// (no user-entered token; the dashboard token is minted + reconciled at
|
||||
// bootstrap and persisted separately). A saved SSH block preserves any
|
||||
// already-adopted token so a reconnect can reuse the running dashboard.
|
||||
if (mode === 'ssh') {
|
||||
const sshBlock = buildSshBlock(input, key ? existing.profiles?.[key] || {} : existing.remote || {})
|
||||
if (key) {
|
||||
const profiles = { ...(existing.profiles || {}) }
|
||||
profiles[key] = sshBlock
|
||||
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
|
||||
}
|
||||
return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} }
|
||||
}
|
||||
|
||||
// The block being edited: a per-profile entry or the global remote block.
|
||||
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
||||
|
|
@ -4480,7 +4537,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
|||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
|
||||
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
|
||||
}
|
||||
|
||||
const nextRemote =
|
||||
|
|
@ -4492,13 +4549,41 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
|||
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
|
||||
}
|
||||
|
||||
// Build an SSH connection block from a save payload, preserving an
|
||||
// already-adopted dashboard token from the existing block (the token is minted
|
||||
// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so
|
||||
// normalizeSshConfig/profileSshOverride recognize it.
|
||||
function buildSshBlock(input, existingBlock = {}) {
|
||||
const merged = normalizeSshConfig({
|
||||
mode: 'ssh',
|
||||
host: input.sshHost ?? existingBlock.host,
|
||||
user: input.sshUser ?? existingBlock.user,
|
||||
port: input.sshPort ?? existingBlock.port,
|
||||
keyPath: input.sshKeyPath ?? existingBlock.keyPath,
|
||||
remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath
|
||||
})
|
||||
if (!merged) {
|
||||
throw new Error('SSH host is required.')
|
||||
}
|
||||
// Carry forward an already-adopted dashboard token unless the host changed
|
||||
// (a different host invalidates the old dashboard's token).
|
||||
if (existingBlock.token && existingBlock.host === merged.host) {
|
||||
merged.token = existingBlock.token
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Build a remote backend connection descriptor from an already-resolved remote
|
||||
// config. Handles both auth models (OAuth ws-ticket vs static session token)
|
||||
// and is shared by the per-profile, env, and global resolution paths. `token`
|
||||
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
|
||||
// for diagnostics ('profile' | 'env' | 'settings').
|
||||
async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost) {
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
// For token/oauth remotes the meaningful host is the real backend URL; for
|
||||
// SSH remotes the caller passes the entered/resolved host explicitly (the
|
||||
// baseUrl is a 127.0.0.1 tunnel and would be useless in the pill).
|
||||
const host = remoteHost || hostLabelFromBaseUrl(baseUrl)
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
// OAuth gateway: auth comes from the session cookies in the OAuth
|
||||
|
|
@ -4535,6 +4620,7 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
|||
mode: 'remote',
|
||||
source,
|
||||
authMode: 'oauth',
|
||||
remoteHost: host || undefined,
|
||||
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
||||
token: null,
|
||||
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
|
|
@ -4553,11 +4639,166 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
|||
mode: 'remote',
|
||||
source,
|
||||
authMode: 'token',
|
||||
remoteHost: host || undefined,
|
||||
token,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSH remote-mode bootstrap
|
||||
//
|
||||
// SSH mode is architecturally desktop-local mode with the loopback stretched
|
||||
// over SSH: open a ControlMaster, bring up (or reuse) a dedicated --isolated
|
||||
// dashboard on the remote, forward 127.0.0.1:<local> -> 127.0.0.1:<remote>,
|
||||
// then hand the EXISTING token-remote machinery a 127.0.0.1 baseUrl. Everything
|
||||
// downstream (REST bridge, /api/ws, sessions, /api/fs/*, version/update pills)
|
||||
// is unchanged — it keys off the connection descriptor, not how it was made.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Live SSH connections keyed by scope ('' for global, or the profile name).
|
||||
// Holds the SshConnection (the control master), the tunnel ports, and the
|
||||
// remote pid so liveness/reconnect/teardown can find them. Survives across
|
||||
// resolveRemoteBackend calls within one app run.
|
||||
const sshConnections = new Map()
|
||||
|
||||
function sshScopeKey(profile) {
|
||||
return connectionScopeKey(profile) || ''
|
||||
}
|
||||
|
||||
// Redaction-wrapped logger so NOTHING that flows through the SSH lifecycle
|
||||
// (spawn command lines carry the session token) reaches desktop.log raw.
|
||||
function sshRememberLog(chunk) {
|
||||
rememberLog(redactSecrets(String(chunk == null ? '' : chunk)))
|
||||
}
|
||||
|
||||
// Authenticated GET /api/status through the tunnel — the authoritative reuse
|
||||
// probe. True iff the dashboard answers ok with this token.
|
||||
async function sshProbeStatus(baseUrl, token) {
|
||||
try {
|
||||
await fetchJson(`${baseUrl}/api/status`, token)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down a scope's SSH state: cancel the forward, close the master, forget
|
||||
// it. Leaves the REMOTE dashboard running (reconnect is instant; in-flight
|
||||
// agent turns survive a client drop) — that is the VS Code semantics the spec
|
||||
// chose. The lockfile reuse flow recovers it on next connect.
|
||||
async function teardownSshConnection(profile) {
|
||||
const scope = sshScopeKey(profile)
|
||||
const state = sshConnections.get(scope)
|
||||
if (!state) return
|
||||
sshConnections.delete(scope)
|
||||
try {
|
||||
if (state.localPort && state.remotePort) {
|
||||
await state.ssh.cancelForward(state.localPort, state.remotePort)
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
try {
|
||||
await state.ssh.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// Bring up (or reuse) the SSH-tunneled dashboard for one scope and return a
|
||||
// token-remote connection descriptor. `sshConfig` is the normalized
|
||||
// { host, user?, port?, keyPath?, remoteHermesPath? }; `reuseToken` is the
|
||||
// decrypted per-connection token from encrypted storage (or '').
|
||||
async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {
|
||||
const scope = sshScopeKey(profile)
|
||||
const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
|
||||
|
||||
// Reuse a live master for this scope if we still have one; otherwise open
|
||||
// fresh. A dead master (sleep/network flap) is closed and reopened.
|
||||
let ssh = sshConnections.get(scope)?.ssh
|
||||
if (ssh && !(await ssh.isAlive())) {
|
||||
try {
|
||||
await ssh.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ssh = null
|
||||
sshConnections.delete(scope)
|
||||
}
|
||||
if (!ssh) {
|
||||
ssh = new SshConnection(
|
||||
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
|
||||
{ rememberLog: sshRememberLog }
|
||||
)
|
||||
await ssh.open()
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await remoteLifecycle.connect({
|
||||
ssh,
|
||||
profile: connectionScopeKey(profile) || '',
|
||||
remoteHermesPath: sshConfig.remoteHermesPath || '',
|
||||
clientId: scope || 'default',
|
||||
reuseToken: reuseToken || '',
|
||||
forward: (localPort, remotePort) => ssh.forward(localPort, remotePort),
|
||||
cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort),
|
||||
pickLocalPort,
|
||||
waitForHermes,
|
||||
probeStatus: sshProbeStatus,
|
||||
adoptServedToken: adoptServedDashboardToken,
|
||||
rememberLog: sshRememberLog
|
||||
})
|
||||
} catch (error) {
|
||||
// Map lifecycle/SSH failures into a single actionable message; the boot
|
||||
// overlay shows this verbatim instead of the generic gateway error.
|
||||
const err = new Error(error.message)
|
||||
err.sshError = error.kind || 'unknown'
|
||||
err.isSshBootstrap = true
|
||||
throw err
|
||||
}
|
||||
|
||||
// Persist the served token (encrypted) so the next launch can reuse this
|
||||
// dashboard via the lockfile fingerprint without re-bootstrapping.
|
||||
persistSshConnectionToken(profile, source, result.token)
|
||||
|
||||
sshConnections.set(scope, {
|
||||
ssh,
|
||||
localPort: result.localPort,
|
||||
remotePort: result.remotePort,
|
||||
pid: result.pid,
|
||||
host: sshConfig.host,
|
||||
hostLabel
|
||||
})
|
||||
|
||||
// Hand the existing token-remote machinery the loopback baseUrl. The pill's
|
||||
// host is the SSH host, NOT 127.0.0.1.
|
||||
return buildRemoteConnection(result.baseUrl, 'token', result.token, source, hostLabel)
|
||||
}
|
||||
|
||||
// Save the served token back into the SSH connection entry (encrypted), so a
|
||||
// later launch reuses the running dashboard. Global SSH lives under
|
||||
// config.remote; a per-profile SSH override lives under config.profiles[name].
|
||||
function persistSshConnectionToken(profile, source, token) {
|
||||
try {
|
||||
const config = readDesktopConnectionConfig()
|
||||
const encrypted = encryptDesktopSecret(token)
|
||||
if (source === 'profile') {
|
||||
const key = connectionScopeKey(profile)
|
||||
if (key && config.profiles?.[key]?.mode === 'ssh') {
|
||||
config.profiles[key].token = encrypted
|
||||
writeDesktopConnectionConfig(config)
|
||||
}
|
||||
} else if (config.mode === 'ssh' && config.remote) {
|
||||
config.remote.token = encrypted
|
||||
writeDesktopConnectionConfig(config)
|
||||
}
|
||||
} catch (error) {
|
||||
sshRememberLog(`[ssh] could not persist served token: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the remote backend for a given profile, or null when that profile
|
||||
// should run a LOCAL backend. Precedence:
|
||||
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
|
||||
|
|
@ -4571,6 +4812,12 @@ async function resolveRemoteBackend(profile) {
|
|||
// 1. Per-profile override — "a profile with its own remote host". Wins even
|
||||
// over the env override so an explicitly-configured profile always
|
||||
// reaches its intended backend.
|
||||
const sshOverride = profileSshOverride(config, profile)
|
||||
if (sshOverride) {
|
||||
const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token)
|
||||
return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')
|
||||
}
|
||||
|
||||
const override = profileRemoteOverride(config, profile)
|
||||
if (override) {
|
||||
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
|
||||
|
|
@ -4591,6 +4838,17 @@ async function resolveRemoteBackend(profile) {
|
|||
}
|
||||
|
||||
// 3. Global remote.
|
||||
// 3a. Global SSH remote — bootstrap the tunnel + dashboard, hand the
|
||||
// token-remote machinery a loopback baseUrl.
|
||||
if (config.mode === 'ssh') {
|
||||
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
|
||||
if (!ssh) {
|
||||
throw new Error('SSH remote mode is selected but no host is configured. Open Settings → Gateway → Connect via SSH.')
|
||||
}
|
||||
const reuseToken = decryptDesktopSecret(config.remote?.token)
|
||||
return bootstrapSshConnection(null, ssh, reuseToken, 'settings')
|
||||
}
|
||||
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
|
@ -4701,6 +4959,52 @@ async function probeRemoteAuthMode(rawUrl) {
|
|||
}
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
// SSH mode: test reachability + that hermes is locatable on a supported
|
||||
// platform, WITHOUT spawning a dashboard. Distinct errors for unreachable /
|
||||
// auth-failed / hermes-not-found / unsupported-platform.
|
||||
if (input.mode === 'ssh') {
|
||||
const sshConfig = normalizeSshConfig({
|
||||
mode: 'ssh',
|
||||
host: input.sshHost,
|
||||
user: input.sshUser,
|
||||
port: input.sshPort,
|
||||
keyPath: input.sshKeyPath,
|
||||
remoteHermesPath: input.sshRemoteHermesPath
|
||||
})
|
||||
if (!sshConfig) {
|
||||
return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }
|
||||
}
|
||||
const ssh = new SshConnection(
|
||||
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
|
||||
{ rememberLog: sshRememberLog }
|
||||
)
|
||||
try {
|
||||
await ssh.open()
|
||||
const platform = await remoteLifecycle.probeRemotePlatform(ssh)
|
||||
const hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')
|
||||
return {
|
||||
reachable: true,
|
||||
sshError: null,
|
||||
error: null,
|
||||
remotePlatform: `${platform.os}/${platform.arch}`,
|
||||
remoteHermesPath: hermesPath,
|
||||
host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
reachable: false,
|
||||
sshError: error.kind || 'unknown',
|
||||
error: error.message
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await ssh.close()
|
||||
} catch {
|
||||
// best effort — a transient test connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||||
const key = connectionScopeKey(input.profile)
|
||||
// The block under test: a per-profile entry or the global remote. Coerce has
|
||||
|
|
@ -5618,6 +5922,51 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
|||
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
|
||||
)
|
||||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:ssh-hosts', async () => {
|
||||
// Read-only host suggestions from ~/.ssh/config (+ Includes). Never writes.
|
||||
try {
|
||||
return { hosts: collectSshConfigHosts() }
|
||||
} catch {
|
||||
return { hosts: [] }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:ssh-resolve', async (_event, host) => {
|
||||
// Resolve the effective target with `ssh -G <host>` (short timeout) so the
|
||||
// UI can show/normalize the real hostname/user/port/identityfile a host
|
||||
// alias expands to. Best-effort: a failure returns nulls, not an error.
|
||||
const target = String(host || '').trim()
|
||||
if (!target) return { hostname: null, user: null, port: null, identityFile: null }
|
||||
return new Promise(resolve => {
|
||||
let out = ''
|
||||
let settled = false
|
||||
const child = spawn('ssh', ['-G', target], { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
resolve({ hostname: null, user: null, port: null, identityFile: null })
|
||||
}, 5_000)
|
||||
child.stdout.on('data', d => {
|
||||
out += d.toString()
|
||||
})
|
||||
child.on('error', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ hostname: null, user: null, port: null, identityFile: null })
|
||||
})
|
||||
child.on('close', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(parseSshGOutput(out))
|
||||
})
|
||||
})
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
// Open the gateway's OAuth login window and wait for the session cookie to
|
||||
|
|
@ -5648,6 +5997,10 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
|||
|
||||
const key = connectionScopeKey(payload?.profile)
|
||||
|
||||
// A connection change for this scope invalidates any live SSH tunnel for it —
|
||||
// tear it down so the next resolve re-bootstraps against the new target.
|
||||
await teardownSshConnection(key || null)
|
||||
|
||||
if (key && key !== primaryProfileKey()) {
|
||||
// Editing a NON-primary profile's connection: don't disturb the window's
|
||||
// primary backend. Drop the profile's pooled backend so the next switch
|
||||
|
|
@ -6798,6 +7151,13 @@ app.on('before-quit', () => {
|
|||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
stopAllPoolBackends()
|
||||
|
||||
// Close SSH control masters (best-effort, fire-and-forget). The REMOTE
|
||||
// dashboards are intentionally LEFT running so a relaunch reconnects via the
|
||||
// lockfile reuse flow without re-bootstrapping (VS Code semantics).
|
||||
for (const scope of [...sshConnections.keys()]) {
|
||||
void teardownSshConnection(scope || null)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
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)
|
||||
})
|
||||
55
apps/desktop/src/global.d.ts
vendored
55
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,10 @@ export interface HermesConnection {
|
|||
isFullscreen: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
authMode?: 'oauth' | 'token'
|
||||
// Human-facing host for the statusbar connection pill. For SSH remotes this
|
||||
// is the user@host the tunnel reaches; for token/oauth remotes it's the host
|
||||
// parsed from the real backend URL. Absent in local mode.
|
||||
remoteHost?: string
|
||||
nativeOverlayWidth: number
|
||||
source?: 'env' | 'local' | 'settings'
|
||||
token: string
|
||||
|
|
@ -313,31 +319,62 @@ export interface DesktopActiveProfile {
|
|||
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
mode: 'local' | 'remote' | 'ssh'
|
||||
// The profile this config describes, or null for the global/default
|
||||
// connection. Per-profile entries let a profile point at its own backend.
|
||||
profile: null | string
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteOauthConnected?: boolean
|
||||
remoteTokenPreview?: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
remoteUrl?: string
|
||||
// SSH mode fields (present when mode === 'ssh'). No token is surfaced — the
|
||||
// dashboard session token is an internal artifact reconciled at bootstrap.
|
||||
sshHost?: string
|
||||
sshUser?: string
|
||||
sshPort?: number | null
|
||||
sshKeyPath?: string
|
||||
sshRemoteHermesPath?: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
mode: 'local' | 'remote' | 'ssh'
|
||||
// When set, the save/apply/test targets this profile's per-profile remote
|
||||
// override instead of the global connection.
|
||||
profile?: null | string
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
// SSH mode input fields.
|
||||
sshHost?: string
|
||||
sshUser?: string
|
||||
sshPort?: number | null
|
||||
sshKeyPath?: string
|
||||
sshRemoteHermesPath?: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionTestResult {
|
||||
baseUrl: string
|
||||
ok: boolean
|
||||
version: string | null
|
||||
baseUrl?: string
|
||||
ok?: boolean
|
||||
version?: string | null
|
||||
// SSH-mode test result fields.
|
||||
reachable?: boolean
|
||||
sshError?: 'unreachable' | 'auth-failed' | 'host-key-changed' | 'hermes-not-found' | 'unsupported-platform' | 'timeout' | 'unknown' | null
|
||||
error?: string | null
|
||||
remotePlatform?: string
|
||||
remoteHermesPath?: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export interface DesktopSshResolveResult {
|
||||
hostname: string | null
|
||||
user: string | null
|
||||
port: number | null
|
||||
identityFile: string | null
|
||||
}
|
||||
|
||||
export interface DesktopSshHostsResult {
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
export interface DesktopAuthProvider {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue