mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.)
107 lines
3.5 KiB
JavaScript
107 lines
3.5 KiB
JavaScript
/**
|
|
* 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)
|
|
})
|