mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): add isolated SSH transport primitives
Add OpenSSH config discovery, scoped ControlMaster/no-mux transport, durable installation ownership, serialized bootstrap coordination, and transactional remote backend lifecycle with focused Vitest coverage.
This commit is contained in:
parent
569b912d7d
commit
a340d0adc5
10 changed files with 3532 additions and 0 deletions
74
apps/desktop/electron/desktop-installation.test.ts
Normal file
74
apps/desktop/electron/desktop-installation.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
|
||||
const ID_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ID_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function withTempDir(run) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-'))
|
||||
try {
|
||||
return run(directory)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('parseInstallationId accepts only a version-4 UUID record', () => {
|
||||
assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A)
|
||||
assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '')
|
||||
assert.equal(parseInstallationId('{}'), '')
|
||||
assert.equal(parseInstallationId('{'), '')
|
||||
})
|
||||
|
||||
test('loadOrCreateInstallationId persists and reuses one installation ID', () => withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A)
|
||||
assert.equal(loadOrCreateInstallationId(filePath, () => ID_B), ID_A)
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId tightens an existing identity file', () => withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 })
|
||||
assert.equal(loadOrCreateInstallationId(filePath, () => ID_B), ID_A)
|
||||
if (process.platform !== 'win32') assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces a malformed existing record', () => withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, '{', { mode: 0o600 })
|
||||
assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A)
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces an existing symlink', () => withTempDir(directory => {
|
||||
if (process.platform === 'win32') return
|
||||
const target = path.join(directory, 'target.json')
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 })
|
||||
fs.symlinkSync(target, filePath)
|
||||
assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A)
|
||||
assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false)
|
||||
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () => withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, '{', { mode: 0o600 })
|
||||
assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A)
|
||||
assert.equal(fs.existsSync(`${filePath}.lock`), false)
|
||||
}))
|
||||
|
||||
test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => {
|
||||
const global = sshOwnershipId(ID_A, '')
|
||||
assert.match(global, /^[0-9a-f]{32}$/)
|
||||
assert.equal(global, sshOwnershipId(ID_A, ''))
|
||||
assert.notEqual(global, sshOwnershipId(ID_A, 'worker'))
|
||||
assert.ok(!global.includes(ID_A.slice(0, 8)))
|
||||
assert.throws(() => sshOwnershipId('bad', ''))
|
||||
})
|
||||
82
apps/desktop/electron/desktop-installation.ts
Normal file
82
apps/desktop/electron/desktop-installation.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
function parseInstallationId(raw) {
|
||||
try {
|
||||
const value = JSON.parse(String(raw || ''))?.installationId
|
||||
return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function readInstallationId(filePath) {
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) return ''
|
||||
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return ''
|
||||
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) fs.chmodSync(filePath, 0o600)
|
||||
return parseInstallationId(fs.readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function waitForRepair() {
|
||||
const buffer = new SharedArrayBuffer(4)
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, 25)
|
||||
}
|
||||
|
||||
function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) {
|
||||
const existing = readInstallationId(filePath)
|
||||
if (existing) return existing
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const installationId = randomUUID().toLowerCase()
|
||||
if (!INSTALLATION_ID_RE.test(installationId)) throw new Error('Could not generate a valid desktop installation ID.')
|
||||
|
||||
const repairPath = `${filePath}.repair.lock`
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
let repairFd
|
||||
try {
|
||||
repairFd = fs.openSync(repairPath, 'wx', 0o600)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'EEXIST') throw error
|
||||
const winner = readInstallationId(filePath)
|
||||
if (winner) return winner
|
||||
waitForRepair()
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const winner = readInstallationId(filePath)
|
||||
if (winner) return winner
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath)
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error('Desktop installation ID path is not a regular file.')
|
||||
if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
||||
throw new Error('Desktop installation ID is owned by another user.')
|
||||
}
|
||||
fs.unlinkSync(filePath)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
||||
return installationId
|
||||
} finally {
|
||||
if (repairFd !== undefined) fs.closeSync(repairFd)
|
||||
try { fs.unlinkSync(repairPath) } catch {}
|
||||
}
|
||||
}
|
||||
throw new Error('Could not repair the desktop installation ID.')
|
||||
}
|
||||
|
||||
function sshOwnershipId(installationId, scope) {
|
||||
if (!INSTALLATION_ID_RE.test(String(installationId || ''))) throw new Error('Desktop installation ID is invalid.')
|
||||
return crypto.createHash('sha256').update(`${installationId}\0${String(scope || '')}`).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId }
|
||||
850
apps/desktop/electron/remote-lifecycle.test.ts
Normal file
850
apps/desktop/electron/remote-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
READY_RE,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
connect,
|
||||
expandRemotePath,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
isForwardBindCollision,
|
||||
lockfilePath,
|
||||
openForward,
|
||||
ownershipDirectory,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
readLockfile,
|
||||
remotePidAlive,
|
||||
remoteSupportsSshOwnership,
|
||||
scrapeReadyPort,
|
||||
spawnRemoteDashboard,
|
||||
spawnLogPath,
|
||||
validateRemotePath,
|
||||
writeLockfile
|
||||
} from './remote-lifecycle'
|
||||
|
||||
const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef'
|
||||
const SPAWN_NONCE = '0123456789abcdef'
|
||||
|
||||
function ownedLock(over: any = {}) {
|
||||
return {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
ownershipId: OWNERSHIP_ID,
|
||||
spawnNonce: SPAWN_NONCE,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
profile: '',
|
||||
hermesPath: '~/.local/bin/hermes',
|
||||
hermesHome: '~/.hermes',
|
||||
logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),
|
||||
tokenFingerprint: fingerprintToken('stored-token'),
|
||||
startedAt: '2026-07-14T00:00:00.000Z',
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
// 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: any[] = []) {
|
||||
const calls: string[] = []
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 throws (no silent fallback) when an EXPLICIT path is not executable', async () => {
|
||||
// command -v WOULD find a different install, but an explicit path must not
|
||||
// silently fall back to it — that is the "connected to the wrong hermes" bug.
|
||||
const ssh = fakeSsh([
|
||||
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
|
||||
])
|
||||
await assert.rejects(
|
||||
() => locateHermes(ssh, '/bad/path/hermes'),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, 'hermes-not-found')
|
||||
assert.match(err.message, /\/bad\/path\/hermes/)
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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 canonicalizes an installer wrapper to its executable target', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK'],
|
||||
[/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/hermes\n']
|
||||
])
|
||||
assert.equal(await locateHermes(ssh, ''), '/home/u/.hermes/hermes-agent/venv/bin/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe misses', async () => {
|
||||
// ~/.local/bin is the non-root installer's command location (scripts/install.sh).
|
||||
const ssh = fakeSsh([
|
||||
[/command -v hermes/, ''],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
|
||||
])
|
||||
assert.equal(await locateHermes(ssh, ''), '~/.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: any) => {
|
||||
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)')
|
||||
})
|
||||
|
||||
|
||||
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: any) => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
|
||||
test('ownership paths are isolated by ownership ID and spawn nonce', () => {
|
||||
assert.equal(ownershipDirectory(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}`)
|
||||
assert.equal(lockfilePath(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/backend.lock.json`)
|
||||
assert.equal(spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.log`)
|
||||
})
|
||||
|
||||
test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => {
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID), null)
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), OWNERSHIP_ID), null)
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), OWNERSHIP_ID), null)
|
||||
const good = ownedLock({ pid: 1, port: 2 })
|
||||
assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), OWNERSHIP_ID), good)
|
||||
})
|
||||
|
||||
test('writeLockfile mkdir -ps and stamps the schema version', async () => {
|
||||
const ssh = fakeSsh([])
|
||||
await writeLockfile(ssh, OWNERSHIP_ID, ownedLock({ 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('metadata and process proof transport failures remain indeterminate', async () => {
|
||||
const failure = new Error('connection reset')
|
||||
await assert.rejects(
|
||||
() => readLockfile(fakeSsh([[/cat/, failure]]), OWNERSHIP_ID),
|
||||
(error: any) => error.kind === 'transient-transport-error'
|
||||
)
|
||||
await assert.rejects(
|
||||
() => remotePidAlive(fakeSsh([[/kill -0/, failure]]), 123),
|
||||
(error: any) => error.kind === 'transient-transport-error'
|
||||
)
|
||||
await assert.rejects(
|
||||
() => pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, failure]]), 5, SPAWN_NONCE, '/x/hermes'),
|
||||
(error: any) => error.kind === 'transient-transport-error'
|
||||
)
|
||||
})
|
||||
|
||||
test('pidIsOurDashboard requires the exact serve ownership nonce', async () => {
|
||||
const ours = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}`
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'OWNED\n']]), 5, SPAWN_NONCE, '/x/hermes'), true)
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, command => command.includes('fedcba9876543210') ? 'FOREIGN\n' : 'OWNED\n']]), 5, 'fedcba9876543210', '/x/hermes'), false)
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false)
|
||||
})
|
||||
|
||||
test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => {
|
||||
const notOurs = fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']])
|
||||
await cleanupStale(notOurs, OWNERSHIP_ID, { pid: 5, spawnNonce: SPAWN_NONCE, hermesPath: '/x/hermes', logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
|
||||
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)))
|
||||
|
||||
const ours = fakeSsh([[/print\("OWNED"/, 'OWNED\n']])
|
||||
await cleanupStale(ours, OWNERSHIP_ID, { pid: 9, spawnNonce: SPAWN_NONCE, hermesPath: '/x/hermes', logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
|
||||
assert.ok(ours.calls.some(c => /kill 9\b/.test(c)))
|
||||
assert.ok(ours.calls.some(c => /rm -f/.test(c)))
|
||||
})
|
||||
|
||||
|
||||
test('buildSpawnCommand is headless serve, detached, token not in argv', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
|
||||
assert.match(cmd, /serve --isolated/)
|
||||
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
|
||||
assert.doesNotMatch(cmd, /--skip-build|--no-open/)
|
||||
assert.doesNotMatch(cmd, /\bdashboard\b/)
|
||||
assert.match(cmd, /--profile/)
|
||||
assert.match(cmd, /work/)
|
||||
assert.match(cmd, /setsid/)
|
||||
assert.match(cmd, /<\/dev\/null/)
|
||||
assert.match(cmd, /echo \$!/)
|
||||
assert.ok(!cmd.includes('tok_secret_value'), 'token must not appear in spawn command')
|
||||
assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'token env var must not appear')
|
||||
})
|
||||
|
||||
test('buildSpawnCommand always uses serve (legacy dashboard path removed)', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
|
||||
assert.match(cmd, /serve --isolated/)
|
||||
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
|
||||
assert.doesNotMatch(cmd, /dashboard/)
|
||||
assert.doesNotMatch(cmd, /--skip-build/)
|
||||
assert.match(cmd, /setsid/)
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard returns exact ownership artifacts', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid|nohup/, '4242\n']
|
||||
])
|
||||
const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID })
|
||||
assert.equal(pid, 4242)
|
||||
assert.match(spawnNonce, /^[0-9a-f]{16}$/)
|
||||
assert.equal(logPath, spawnLogPath(OWNERSHIP_ID, spawnNonce))
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid|nohup/, '4242\n']
|
||||
])
|
||||
await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID })
|
||||
const spawn = ssh.calls.find(c => /setsid|nohup/.test(c))
|
||||
assert.match(spawn, /serve --isolated/)
|
||||
assert.doesNotMatch(spawn, /\bdashboard\b/)
|
||||
})
|
||||
|
||||
test('READY_RE accepts both serve and dashboard sentinels', () => {
|
||||
assert.equal(READY_RE.exec('HERMES_BACKEND_READY port=4321')?.[1], '4321')
|
||||
assert.equal(READY_RE.exec('HERMES_DASHBOARD_READY port=8765')?.[1], '8765')
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard rejects when no pid is returned', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid|nohup/, 'not-a-pid']
|
||||
])
|
||||
await assert.rejects(
|
||||
() => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 't', ownershipId: OWNERSHIP_ID }),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, 'spawn-failed')
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('scrapeReadyPort reads only the named spawn log', async () => {
|
||||
const logPath = spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE)
|
||||
const ssh = fakeSsh([[/cat/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']])
|
||||
const port = await scrapeReadyPort(ssh, logPath, { timeoutMs: 1000 })
|
||||
assert.equal(port, 51234)
|
||||
assert.ok(ssh.calls.every(call => !call.includes('desktop-ssh.log')))
|
||||
})
|
||||
|
||||
test('scrapeReadyPort times out and reports a dead spawn', async () => {
|
||||
// never emits a READY line
|
||||
const ssh = fakeSsh([[/cat .*\.log/, 'still starting...']])
|
||||
await assert.rejects(
|
||||
() => scrapeReadyPort(ssh, spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 60 }),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, 'ready-timeout')
|
||||
return true
|
||||
}
|
||||
)
|
||||
// dead process before announcement → spawn-failed
|
||||
await assert.rejects(
|
||||
() => scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 1000, isAlive: async () => false }),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, 'spawn-failed')
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
function connectDeps(ssh, over: any = {}) {
|
||||
return {
|
||||
ssh,
|
||||
ownershipId: OWNERSHIP_ID,
|
||||
profile: '',
|
||||
forward: async () => {},
|
||||
cancelForward: async () => {},
|
||||
pickLocalPort: async () => 50001,
|
||||
waitForHermes: async () => {},
|
||||
probeReuseProof: async () => 'authenticated-ok',
|
||||
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
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''], // token file write
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid/, '777\n'],
|
||||
[/kill -0 777/, 'ALIVE'],
|
||||
[/cat .*\.log/, '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 = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'OWNED\n']
|
||||
])
|
||||
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 hermesPath differs from the resolved path', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'FOREIGN\n'],
|
||||
[/--version/, 'Hermes Agent v0.18.2\n'],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/setsid/, '890\n'],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' }))
|
||||
assert.equal(result.reused, false, 'must respawn, not reuse the old-path dashboard')
|
||||
assert.ok(ssh.calls.some(c => /setsid/.test(c)), 'a fresh dashboard must be spawned')
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
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'],
|
||||
[/print\("OWNED"/, 'FOREIGN\n'],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/setsid/, '901\n'],
|
||||
[/kill -0 901/, 'ALIVE'],
|
||||
[/cat .*\.log/, '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: string[] = []
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''], // no lockfile
|
||||
[/HERMES_HOME/, '/home/alice/.hermes\n'],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid/, '700\n'],
|
||||
[/kill -0 700/, 'ALIVE'],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=45500\n'],
|
||||
[
|
||||
/printf '%s' '/,
|
||||
c => {
|
||||
writes.push(c)
|
||||
return ''
|
||||
}
|
||||
]
|
||||
])
|
||||
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\/alice\/\.hermes"/)
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => {
|
||||
const lock = ownedLock({ tokenFingerprint: fingerprintToken('t') })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0 333/, 'DEAD'],
|
||||
[/print\("OWNED"/, 'FOREIGN\n'],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/setsid/, '888\n'],
|
||||
[/kill -0 888/, 'ALIVE'],
|
||||
[/cat .*\.log/, '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'],
|
||||
[/print\("OWNED"/, 'FOREIGN\n'],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/setsid/, '999\n'],
|
||||
[/kill -0 999/, 'ALIVE'],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n']
|
||||
])
|
||||
const result = await connect(
|
||||
connectDeps(ssh, { reuseToken, probeReuseProof: async () => 'authenticated-stale', 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: any) => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.ok(!ssh.calls.some(c => /setsid/.test(c)))
|
||||
})
|
||||
|
||||
test('openForward retries bind collisions only', async () => {
|
||||
const ports = [41001, 41002]
|
||||
const calls: number[] = []
|
||||
const localPort = await openForward({
|
||||
pickLocalPort: async () => ports.shift(),
|
||||
forward: async port => {
|
||||
calls.push(port)
|
||||
if (calls.length === 1) throw new Error('bind: Address already in use')
|
||||
}
|
||||
}, 9119)
|
||||
assert.equal(localPort, 41002)
|
||||
assert.deepEqual(calls, [41001, 41002])
|
||||
assert.equal(isForwardBindCollision(new Error('Permission denied')), false)
|
||||
})
|
||||
|
||||
test('connect() preserves an owned backend when a reuse transport throws', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'OWNED\n']
|
||||
])
|
||||
await assert.rejects(() => connect(connectDeps(ssh, {
|
||||
reuseToken,
|
||||
forward: async () => { throw new Error('network reset') }
|
||||
})), /network reset/)
|
||||
assert.ok(!ssh.calls.some(cmd => /kill 333\b/.test(cmd)))
|
||||
})
|
||||
|
||||
|
||||
test('validateRemotePath accepts absolute POSIX paths', () => {
|
||||
assert.doesNotThrow(() => validateRemotePath('/usr/bin/hermes'))
|
||||
assert.doesNotThrow(() => validateRemotePath('/home/user/.hermes/hermes-agent/venv/bin/hermes'))
|
||||
})
|
||||
|
||||
test('validateRemotePath accepts ~/ prefix paths', () => {
|
||||
assert.doesNotThrow(() => validateRemotePath('~/bin/hermes'))
|
||||
assert.doesNotThrow(() => validateRemotePath('~/.hermes/logs/desktop-ssh.log'))
|
||||
assert.doesNotThrow(() => validateRemotePath('~'))
|
||||
})
|
||||
|
||||
test('validateRemotePath accepts paths with spaces and quotes', () => {
|
||||
assert.doesNotThrow(() => validateRemotePath('/home/user/my project/hermes'))
|
||||
assert.doesNotThrow(() => validateRemotePath("~/path with 'quotes'/file"))
|
||||
assert.doesNotThrow(() => validateRemotePath('/path with "double quotes"/file'))
|
||||
})
|
||||
|
||||
test('validateRemotePath rejects relative paths', () => {
|
||||
assert.throws(() => validateRemotePath('hermes'), /absolute|relative/i)
|
||||
assert.throws(() => validateRemotePath('./bin/hermes'), /absolute|relative/i)
|
||||
assert.throws(() => validateRemotePath('../etc/passwd'), /absolute|relative/i)
|
||||
})
|
||||
|
||||
test('validateRemotePath rejects NUL and newline', () => {
|
||||
assert.throws(() => validateRemotePath('/usr/bin/hermes\x00'), /unsafe/i)
|
||||
assert.throws(() => validateRemotePath('/usr/bin/hermes\n'), /unsafe/i)
|
||||
assert.throws(() => validateRemotePath('/usr/bin/hermes\r'), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateRemotePath preserves shell metacharacters as path data', () => {
|
||||
for (const p of ['/usr/$(whoami)/hermes', '/usr/`id`/hermes', '/usr/a;b|c&d<e>f']) {
|
||||
assert.doesNotThrow(() => validateRemotePath(p))
|
||||
assert.match(expandRemotePath(p), /^'/)
|
||||
}
|
||||
})
|
||||
|
||||
test('expandRemotePath expands ~/ to "$HOME"/', () => {
|
||||
const result = expandRemotePath('~/.hermes/logs/desktop-ssh.log')
|
||||
assert.match(result, /\$HOME/)
|
||||
assert.ok(!result.includes('eval'), 'must not use eval')
|
||||
assert.ok(!result.includes('echo'), 'must not use echo for expansion')
|
||||
})
|
||||
|
||||
test('expandRemotePath returns quoted absolute paths unchanged', () => {
|
||||
const result = expandRemotePath('/usr/local/bin/hermes')
|
||||
assert.ok(result.includes('/usr/local/bin/hermes'))
|
||||
assert.ok(!result.includes('eval'))
|
||||
})
|
||||
|
||||
test('expandRemotePath preserves spaces as data', () => {
|
||||
const result = expandRemotePath('/home/user/my project/hermes')
|
||||
assert.ok(result.includes('my project'), 'spaces must be preserved, not split')
|
||||
})
|
||||
|
||||
test('buildSpawnCommand does not embed the token in the command string', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
|
||||
assert.ok(!cmd.includes('super_secret_token_value'), 'token must not appear in the spawn command')
|
||||
assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'env var name must not appear')
|
||||
})
|
||||
|
||||
test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is provided', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', {
|
||||
tokenFilePath: `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.token`,
|
||||
logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),
|
||||
spawnNonce: SPAWN_NONCE
|
||||
})
|
||||
assert.match(cmd, /--ssh-session-token-file/)
|
||||
assert.match(cmd, /\.hermes\/desktop-ssh\//)
|
||||
})
|
||||
|
||||
test('buildSpawnCommand always uses serve, never dashboard', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
|
||||
assert.match(cmd, /serve --isolated/)
|
||||
assert.doesNotMatch(cmd, /\bdashboard\b/)
|
||||
assert.doesNotMatch(cmd, /--skip-build/)
|
||||
assert.doesNotMatch(cmd, /--no-open/)
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard removes a token file when upload reporting fails', async () => {
|
||||
const failure = new Error('channel closed')
|
||||
const ssh = fakeSsh([
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[command => /python3 -c/.test(command) && !/rm -f/.test(command), failure],
|
||||
[/rm -f/, '']
|
||||
])
|
||||
await assert.rejects(
|
||||
() => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tok', ownershipId: OWNERSHIP_ID }),
|
||||
/channel closed/
|
||||
)
|
||||
assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command)))
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () => {
|
||||
const stdinCalls: string[] = []
|
||||
const calls: string[] = []
|
||||
const ssh = {
|
||||
calls,
|
||||
async exec(cmd, opts?) {
|
||||
calls.push(cmd)
|
||||
if (opts?.stdinData) stdinCalls.push(opts.stdinData)
|
||||
if (/grep -q ssh-session-token-file/.test(cmd)) return 'YES\n'
|
||||
if (/python3 -c/.test(cmd)) return ''
|
||||
if (/setsid|nohup/.test(cmd)) return '4242\n'
|
||||
if (/printf '%s\\n'/.test(cmd)) return ''
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const { pid } = await spawnRemoteDashboard(ssh as any, {
|
||||
hermesPath: '/x/hermes', profile: '', token: 'secret_token_val', ownershipId: OWNERSHIP_ID
|
||||
})
|
||||
assert.equal(pid, 4242)
|
||||
assert.ok(stdinCalls.length > 0, 'token must be sent via stdin')
|
||||
assert.ok(stdinCalls.some(d => d === 'secret_token_val'), 'stdin must contain the token')
|
||||
for (const cmd of calls) {
|
||||
assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard upload uses exclusive-create and O_NOFOLLOW', async () => {
|
||||
const calls: string[] = []
|
||||
const ssh = {
|
||||
calls,
|
||||
async exec(cmd, opts?) {
|
||||
calls.push(cmd)
|
||||
if (/grep -q ssh-session-token-file/.test(cmd)) return 'YES\n'
|
||||
if (/python3 -c/.test(cmd)) return ''
|
||||
if (/setsid|nohup/.test(cmd)) return '4242\n'
|
||||
if (/printf '%s\\n'/.test(cmd)) return ''
|
||||
return ''
|
||||
}
|
||||
}
|
||||
await spawnRemoteDashboard(ssh as any, {
|
||||
hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID
|
||||
})
|
||||
const uploadCmd = calls.find(c => /python3 -c/.test(c))
|
||||
assert.ok(uploadCmd, 'must use python3 -c for token upload')
|
||||
assert.match(uploadCmd, /O_EXCL/, 'upload must use O_EXCL to reject existing files')
|
||||
assert.match(uploadCmd, /O_NOFOLLOW/, 'upload must use O_NOFOLLOW to reject symlinks')
|
||||
assert.match(uploadCmd, /O_WRONLY/, 'upload must open write-only')
|
||||
assert.match(uploadCmd, /dir_fd=dd/, 'upload must create relative to the opened parent directory')
|
||||
assert.match(uploadCmd, /os\.fstat\(dd\)/, 'upload must validate the opened parent directory')
|
||||
assert.ok(!uploadCmd.includes('tk'), 'token must not appear in the upload command')
|
||||
})
|
||||
|
||||
test('readLockfile rejects lock with non-integer pid', async () => {
|
||||
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 'not-a-number', port: 8080 }
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null)
|
||||
})
|
||||
|
||||
test('readLockfile rejects lock with pid <= 0', async () => {
|
||||
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: -1, port: 8080 }
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null)
|
||||
})
|
||||
|
||||
test('readLockfile rejects lock with port out of range', async () => {
|
||||
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 99999 }
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null)
|
||||
const lock2 = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 0 }
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock2)]]), OWNERSHIP_ID), null)
|
||||
})
|
||||
|
||||
test('readLockfile accepts a complete owned lock', async () => {
|
||||
const lock = ownedLock({ pid: 42, port: 51234 })
|
||||
const result = await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID)
|
||||
assert.deepEqual(result, lock)
|
||||
})
|
||||
|
||||
test('connect() reuse path does not write a token file', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'OWNED\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t }))
|
||||
assert.equal(result.reused, true)
|
||||
assert.ok(!ssh.calls.some(c => /sys\.stdin\.buffer\.read/.test(c)),
|
||||
'reuse must not upload a token file')
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/--ssh-session-token-file/, 'NO\n']
|
||||
])
|
||||
await assert.rejects(
|
||||
() => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }),
|
||||
(err: any) => {
|
||||
assert.match(err.message, /update|upgrade/i)
|
||||
assert.equal(err.kind, 'update-required')
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('readLockfile rejects a log path outside the exact ownership and spawn path', async () => {
|
||||
const lock = ownedLock({ logPath: '~/.hermes/desktop-ssh/other.log' })
|
||||
const ssh = fakeSsh([[/cat .*lock\.json/, JSON.stringify(lock)]])
|
||||
assert.equal(await readLockfile(ssh, OWNERSHIP_ID), null)
|
||||
})
|
||||
|
||||
test('cleanupStale never deletes a lock-supplied unexpected log path', async () => {
|
||||
const ssh = fakeSsh([[/print\("OWNED"/, 'OWNED\n']])
|
||||
await cleanupStale(ssh, OWNERSHIP_ID, ownedLock({ logPath: '~/.hermes/unrelated.log' }))
|
||||
assert.ok(!ssh.calls.some(command => command.includes('unrelated.log')))
|
||||
})
|
||||
|
||||
test('pidIsOurDashboard requires an exact nonce option value', async () => {
|
||||
const prefix = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}ff`
|
||||
const suffix = `/x/hermes serve --isolated --ssh-owner-nonce xx${SPAWN_NONCE}`
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false)
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false)
|
||||
})
|
||||
|
||||
test('connect removes the token file when a fresh backend fails after returning a pid', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/setsid/, '999\n'],
|
||||
[/kill -0 999/, 'DEAD']
|
||||
])
|
||||
await assert.rejects(() => connect(connectDeps(ssh)), /exited before announcing/i)
|
||||
assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command)))
|
||||
})
|
||||
|
||||
test('connect preserves an exact-owned backend when reuse proof transport fails', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'OWNED\n']
|
||||
])
|
||||
await assert.rejects(() => connect(connectDeps(ssh, {
|
||||
reuseToken,
|
||||
probeReuseProof: async () => { throw new Error('connection reset') }
|
||||
})), (error: any) => error.kind === 'transient-transport-error')
|
||||
assert.ok(!ssh.calls.some(command => /kill 333\b/.test(command)))
|
||||
assert.ok(!ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command)))
|
||||
})
|
||||
|
||||
test('connect replaces an exact-owned backend only after authenticated stale proof', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0 333/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'OWNED\n'],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/setsid/, '999\n'],
|
||||
[/kill -0 999/, 'ALIVE'],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, {
|
||||
reuseToken,
|
||||
probeReuseProof: async (_baseUrl, token, nonce) => {
|
||||
assert.equal(token, reuseToken)
|
||||
assert.equal(nonce, SPAWN_NONCE)
|
||||
return 'authenticated-stale'
|
||||
},
|
||||
adoptServedToken: async () => 'fresh'
|
||||
}))
|
||||
assert.equal(result.reused, false)
|
||||
assert.ok(ssh.calls.some(command => /kill 333\b/.test(command)))
|
||||
})
|
||||
|
||||
test('remote SSH ownership capability requires both secure bootstrap flags', async () => {
|
||||
let helpProbe = ''
|
||||
const supported = fakeSsh([[
|
||||
/serve --help/,
|
||||
command => {
|
||||
helpProbe = command
|
||||
return 'YES\n'
|
||||
}
|
||||
]])
|
||||
assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true)
|
||||
assert.match(helpProbe, /ssh-session-token-file/)
|
||||
assert.match(helpProbe, /ssh-owner-nonce/)
|
||||
|
||||
const unsupported = fakeSsh([[/serve --help/, 'NO\n']])
|
||||
assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false)
|
||||
})
|
||||
718
apps/desktop/electron/remote-lifecycle.ts
Normal file
718
apps/desktop/electron/remote-lifecycle.ts
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
/**
|
||||
* remote-lifecycle.ts
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires
|
||||
* the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
|
||||
*
|
||||
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
|
||||
* readiness the caller 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).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
|
||||
// an old running dashboard unsafe to reattach to (token handling, readiness/spawn
|
||||
// args, served-token reconciliation). A mismatch forces a clean respawn.
|
||||
const PROTOCOL_VERSION = 1
|
||||
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
|
||||
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
|
||||
|
||||
function mintToken() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
// Fingerprint a token for the lockfile — never store the raw secret on the
|
||||
// remote. SHA256, truncated.
|
||||
function fingerprintToken(token) {
|
||||
return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
function validateOwnershipId(ownershipId) {
|
||||
const value = String(ownershipId || '')
|
||||
if (!/^[0-9a-f]{32}$/.test(value)) throw new Error('SSH ownership ID is invalid.')
|
||||
return value
|
||||
}
|
||||
|
||||
function validateSpawnNonce(spawnNonce) {
|
||||
const value = String(spawnNonce || '')
|
||||
if (!/^[0-9a-f]{16}$/.test(value)) throw new Error('SSH spawn nonce is invalid.')
|
||||
return value
|
||||
}
|
||||
|
||||
function ownershipDirectory(ownershipId) {
|
||||
return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}`
|
||||
}
|
||||
|
||||
function lockfilePath(ownershipId) {
|
||||
return `${ownershipDirectory(ownershipId)}/backend.lock.json`
|
||||
}
|
||||
|
||||
function spawnLogPath(ownershipId, spawnNonce) {
|
||||
return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log`
|
||||
}
|
||||
|
||||
// shell-single-quote a value for safe interpolation into a remote command.
|
||||
function shq(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function validateRemotePath(p) {
|
||||
const s = String(p || '')
|
||||
if (!s) throw new Error('Remote path must not be empty.')
|
||||
if (/[\x00\n\r]/.test(s)) throw new Error('Unsafe remote path: contains NUL or newline.')
|
||||
if (s === '~' || s.startsWith('~/') || s.startsWith('/')) return
|
||||
throw new Error(`Remote path must be absolute or start with ~/: "${s}"`)
|
||||
}
|
||||
|
||||
function expandRemotePath(p) {
|
||||
validateRemotePath(p)
|
||||
if (p === '~') return '"$HOME"'
|
||||
if (p.startsWith('~/')) return '"$HOME"' + shq(p.slice(1))
|
||||
return shq(p)
|
||||
}
|
||||
|
||||
// Resolve the remote hermes executable. An EXPLICIT path is honored strictly
|
||||
// (throws a path-naming error if not executable — never silently falls back to a
|
||||
// different install). A BLANK path auto-detects: login-shell `command -v` (a
|
||||
// non-login `ssh host cmd` PATH misses user installs), then known install paths.
|
||||
async function locateHermes(ssh, remoteHermesPath) {
|
||||
const resolveLauncher = async (candidate: string) => {
|
||||
const script =
|
||||
'import os,shlex,sys\n' +
|
||||
`p=os.path.expanduser(${shq(candidate)})\n` +
|
||||
'out=p\n' +
|
||||
'try:\n' +
|
||||
' data=open(p,"r",encoding="utf-8",errors="ignore").read(4096)\n' +
|
||||
' for line in data.splitlines():\n' +
|
||||
' words=shlex.split(line)\n' +
|
||||
' if len(words)>1 and words[0]=="exec":\n' +
|
||||
' target=os.path.expanduser(words[1])\n' +
|
||||
' if os.path.isabs(target) and os.access(target,os.X_OK):out=target\n' +
|
||||
' break\n' +
|
||||
'except (OSError,ValueError):pass\n' +
|
||||
'print(out)'
|
||||
const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim()
|
||||
return resolved || candidate
|
||||
}
|
||||
|
||||
const isExecutable = async (candidate: string) => {
|
||||
try {
|
||||
validateRemotePath(candidate)
|
||||
const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim()
|
||||
return ok === 'OK'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteHermesPath) {
|
||||
if (await isExecutable(remoteHermesPath)) {
|
||||
return resolveLauncher(remoteHermesPath)
|
||||
}
|
||||
const err: any = new Error(
|
||||
`The Hermes path you set is not an executable on the remote host: "${remoteHermesPath}". ` +
|
||||
'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' +
|
||||
'~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.'
|
||||
)
|
||||
err.kind = 'hermes-not-found'
|
||||
throw err
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
try {
|
||||
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
|
||||
if (found) {
|
||||
candidates.push(found.split('\n').pop().trim())
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Fallback candidates when the login-shell probe misses: the installer's
|
||||
// command locations (scripts/install.sh) — per-user, root/FHS, legacy venv.
|
||||
candidates.push('~/.local/bin/hermes')
|
||||
candidates.push('/usr/local/bin/hermes')
|
||||
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (await isExecutable(candidate)) {
|
||||
return resolveLauncher(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const err: any = 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
|
||||
}
|
||||
|
||||
// Probe the resolved binary's version string (first line of `<hermes> --version`,
|
||||
// e.g. "Hermes Agent v0.18.2 ..."), or '' on failure. Surfaces WHICH hermes a
|
||||
// connection uses, so a stale/unexpected install is visible.
|
||||
async function probeHermesVersion(ssh, hermesPath) {
|
||||
try {
|
||||
const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()
|
||||
return (out.split('\n')[0] || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
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: any = 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.
|
||||
async function probeRemoteHermesHome(ssh) {
|
||||
try {
|
||||
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
|
||||
return out || '~/.hermes'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not resolve the remote Hermes home.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function readLockfile(ssh, ownershipId) {
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
let raw
|
||||
try {
|
||||
raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`)
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not read the SSH backend ownership record.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
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
|
||||
}
|
||||
const pid = parsed.pid
|
||||
const port = parsed.port
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) return null
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
|
||||
if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) return null
|
||||
if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) return null
|
||||
if (parsed.protocolVersion !== PROTOCOL_VERSION) return null
|
||||
if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) return null
|
||||
for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) {
|
||||
if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function writeLockfile(ssh, ownershipId, lock) {
|
||||
const directory = ownershipDirectory(ownershipId)
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp`
|
||||
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
|
||||
await ssh.exec(
|
||||
`umask 077 && mkdir -p ${expandRemotePath(directory)} && ` +
|
||||
`printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` +
|
||||
`mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function removeLockfile(ssh, ownershipId) {
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(lpath)}`)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
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 (cause) {
|
||||
const error: any = new Error('Could not verify the SSH backend process.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 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, spawnNonce, hermesPath = '') {
|
||||
if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) return false
|
||||
try {
|
||||
const script =
|
||||
'import os,shlex,subprocess,sys\n' +
|
||||
`pid=${Number(pid)}\n` +
|
||||
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
|
||||
`nonce=${shq(spawnNonce)}\n` +
|
||||
'try:\n' +
|
||||
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
|
||||
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
|
||||
'except OSError:\n' +
|
||||
' line=subprocess.check_output(["ps","-o","command=","-p",str(pid)],text=True).strip()\n' +
|
||||
' args=shlex.split(line)\n' +
|
||||
'ok=False\n' +
|
||||
'try:\n' +
|
||||
' serve=args.index("serve")\n' +
|
||||
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
|
||||
' direct=args[0]==expected\n' +
|
||||
' python_entry=len(args)>1 and args[1]==expected and os.path.basename(args[0]).startswith("python")\n' +
|
||||
' ok=(direct or python_entry) and "--isolated" in args[serve+1:] and args[owner+1]==nonce\n' +
|
||||
'except (ValueError,IndexError):pass\n' +
|
||||
'print("OWNED" if ok else "FOREIGN")'
|
||||
const out = await ssh.exec(`python3 -c ${shq(script)}`)
|
||||
return String(out || '').trim() === 'OWNED'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify SSH backend process ownership.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
||||
async function cleanupStale(ssh, ownershipId, lock) {
|
||||
if (lock && await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)) {
|
||||
try {
|
||||
const result = (await ssh.exec(
|
||||
`kill ${Number(lock.pid)} && ` +
|
||||
`i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` +
|
||||
`i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done`
|
||||
)).trim()
|
||||
void result
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not terminate the stale SSH backend.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : ''
|
||||
if (lock?.logPath === expectedLogPath) {
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`) } catch {}
|
||||
}
|
||||
await removeLockfile(ssh, ownershipId)
|
||||
}
|
||||
|
||||
// Detach so the backend survives the SSH channel closing: setsid (Linux)
|
||||
// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune;
|
||||
// fd-detachment is already handled by </dev/null + redirect + &).
|
||||
function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
|
||||
const hermes = expandRemotePath(hermesPath)
|
||||
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
|
||||
const logPath = expandRemotePath(opts.logPath)
|
||||
const tokenFilePath = opts.tokenFilePath
|
||||
const tokenArg = tokenFilePath ? ` --ssh-session-token-file ${expandRemotePath(tokenFilePath)}` : ''
|
||||
const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : ''
|
||||
const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}`
|
||||
const dashCmd = `env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}`
|
||||
return (
|
||||
`mkdir -p "$(dirname ${logPath})" && ` +
|
||||
`"$(command -v setsid || echo nohup)" sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function remoteSupportsSshOwnership(ssh, hermesPath) {
|
||||
const hermes = expandRemotePath(hermesPath)
|
||||
const out = await ssh.exec(
|
||||
`help="$(${hermes} serve --help 2>&1)"; ` +
|
||||
`printf '%s' "$help" | grep -q ssh-session-token-file && ` +
|
||||
`printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO`
|
||||
)
|
||||
return String(out || '').trim().endsWith('YES')
|
||||
}
|
||||
|
||||
async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const remoteLog = expandRemotePath(logPath)
|
||||
while (Date.now() < deadline) {
|
||||
assertNotAborted(signal)
|
||||
if (isAlive && !(await isAlive())) {
|
||||
const err: any = new Error('Remote dashboard process exited before announcing its port.')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
let tail
|
||||
try {
|
||||
tail = await ssh.exec(`cat ${remoteLog} 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: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
|
||||
err.kind = 'ready-timeout'
|
||||
throw err
|
||||
}
|
||||
|
||||
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownershipId }) {
|
||||
if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) {
|
||||
const err: any = new Error(
|
||||
'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' +
|
||||
'Update Hermes on the remote host to continue using Desktop SSH mode.'
|
||||
)
|
||||
err.kind = 'update-required'
|
||||
throw err
|
||||
}
|
||||
|
||||
const spawnNonce = crypto.randomBytes(8).toString('hex')
|
||||
const tokenDir = ownershipDirectory(ownershipId)
|
||||
const tokenFilePath = `${tokenDir}/${spawnNonce}.token`
|
||||
const logPath = spawnLogPath(ownershipId, spawnNonce)
|
||||
|
||||
const tokenUploadPy =
|
||||
'import os,sys,stat\n' +
|
||||
`p=os.path.expanduser(${shq(tokenFilePath)})\n` +
|
||||
'd=os.path.dirname(p)\n' +
|
||||
'n=os.path.basename(p)\n' +
|
||||
'os.makedirs(d,mode=0o700,exist_ok=True)\n' +
|
||||
'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' +
|
||||
'dd=os.open(d,df)\n' +
|
||||
'try:\n' +
|
||||
' s=os.fstat(dd)\n' +
|
||||
' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' +
|
||||
' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' +
|
||||
' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' +
|
||||
' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' +
|
||||
' now=__import__("time").time()\n' +
|
||||
' for stale in os.listdir(dd):\n' +
|
||||
' if stale.endswith(".token") and len(stale)==22:\n' +
|
||||
' try:\n' +
|
||||
' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' +
|
||||
' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' +
|
||||
' except OSError:pass\n' +
|
||||
' fd=os.open(n,fl,0o600,dir_fd=dd)\n' +
|
||||
' try:os.write(fd,sys.stdin.buffer.read())\n' +
|
||||
' except BaseException:\n' +
|
||||
' try:os.unlink(n,dir_fd=dd)\n' +
|
||||
' except OSError:pass\n' +
|
||||
' raise\n' +
|
||||
' finally:os.close(fd)\n' +
|
||||
'finally:os.close(dd)'
|
||||
try {
|
||||
await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token })
|
||||
} catch (error) {
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {}
|
||||
throw error
|
||||
}
|
||||
|
||||
let out
|
||||
try {
|
||||
out = await ssh.exec(
|
||||
buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath })
|
||||
)
|
||||
} catch (error) {
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {}
|
||||
throw error
|
||||
}
|
||||
const pid = parseInt(String(out || '').trim().split('\n').pop(), 10)
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {}
|
||||
const err: any = new Error('Failed to launch the remote dashboard (no pid returned).')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
return { pid, spawnNonce, logPath, tokenFilePath }
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotAborted(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isForwardBindCollision(error) {
|
||||
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
|
||||
}
|
||||
|
||||
async function openForward(deps, remotePort, attempts = 3) {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const localPort = await deps.pickLocalPort()
|
||||
try {
|
||||
await deps.forward(localPort, remotePort)
|
||||
return localPort
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (!isForwardBindCollision(error) || attempt === attempts - 1) throw error
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the
|
||||
* opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated
|
||||
* probeReuseProof, and adoptServedToken. Returns the connection descriptor
|
||||
* { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }.
|
||||
*/
|
||||
async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) {
|
||||
const token = await adoptServedToken(baseUrl, expectedToken, {
|
||||
childAlive: () => true,
|
||||
label
|
||||
})
|
||||
if (!(await remotePidAlive(ssh, pid))) {
|
||||
const error: any = new Error(`${label} exited while its served token was being resolved.`)
|
||||
error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend'
|
||||
throw error
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
async function connect(deps) {
|
||||
const {
|
||||
ssh,
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
ownershipId,
|
||||
forward,
|
||||
pickLocalPort,
|
||||
waitForHermes,
|
||||
probeReuseProof,
|
||||
adoptServedToken,
|
||||
rememberLog = () => {},
|
||||
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
|
||||
signal
|
||||
} = deps
|
||||
|
||||
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
|
||||
|
||||
assertNotAborted(signal)
|
||||
const platform = await probeRemotePlatform(ssh)
|
||||
log(`remote platform ${platform.os}/${platform.arch}`)
|
||||
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
||||
log(`located hermes at ${hermesPath}`)
|
||||
const hermesVersion = await probeHermesVersion(ssh, hermesPath)
|
||||
if (hermesVersion) log(`remote hermes version: ${hermesVersion}`)
|
||||
|
||||
const reuseToken = deps.reuseToken || ''
|
||||
const hermesHome = await probeRemoteHermesHome(ssh)
|
||||
const lock = await readLockfile(ssh, ownershipId)
|
||||
if (lock) {
|
||||
const pidAlive = await remotePidAlive(ssh, lock.pid)
|
||||
const owned = await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)
|
||||
const reusable = pidAlive && owned && Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === hermesPath && lock.hermesHome === hermesHome
|
||||
if (reusable) {
|
||||
assertNotAborted(signal)
|
||||
const localPort = await openForward(deps, lock.port)
|
||||
try {
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
let reuseClassification
|
||||
try {
|
||||
reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify the existing SSH backend.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
if (reuseClassification === 'authenticated-stale') {
|
||||
assertNotAborted(signal)
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
await cleanupStale(ssh, ownershipId, lock)
|
||||
} else if (reuseClassification === 'authenticated-ok') {
|
||||
const token = await adoptOwnedServedToken(
|
||||
adoptServedToken, baseUrl, reuseToken, ssh, lock.pid, 'reused remote dashboard'
|
||||
)
|
||||
assertNotAborted(signal)
|
||||
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
tokenFingerprint: fingerprintToken(token),
|
||||
remotePort: lock.port,
|
||||
localPort,
|
||||
pid: lock.pid,
|
||||
reused: true,
|
||||
platform,
|
||||
hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce: lock.spawnNonce,
|
||||
logPath: lock.logPath
|
||||
}
|
||||
} else {
|
||||
const error: any = new Error('SSH reuse proof returned an invalid classification.')
|
||||
error.kind = 'transient-transport-error'
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
assertNotAborted(signal)
|
||||
await cleanupStale(ssh, ownershipId, lock)
|
||||
}
|
||||
}
|
||||
|
||||
assertNotAborted(signal)
|
||||
const spawnToken = mintToken()
|
||||
const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {
|
||||
hermesPath,
|
||||
profile,
|
||||
token: spawnToken,
|
||||
ownershipId
|
||||
})
|
||||
log(`spawned remote dashboard pid=${pid}`)
|
||||
|
||||
const ownedSpawn = {
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
pid,
|
||||
port: 0,
|
||||
profile,
|
||||
hermesPath,
|
||||
hermesHome,
|
||||
logPath,
|
||||
tokenFingerprint: fingerprintToken(spawnToken),
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
startedAt: new Date().toISOString()
|
||||
}
|
||||
let localPort = 0
|
||||
let remotePort = 0
|
||||
try {
|
||||
remotePort = await scrapeReadyPort(ssh, logPath, {
|
||||
timeoutMs: readyTimeoutMs,
|
||||
isAlive: () => remotePidAlive(ssh, pid),
|
||||
signal
|
||||
})
|
||||
assertNotAborted(signal)
|
||||
log(`remote dashboard bound port ${remotePort}`)
|
||||
|
||||
localPort = await openForward(deps, remotePort)
|
||||
assertNotAborted(signal)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
await waitForHermes(baseUrl, spawnToken)
|
||||
assertNotAborted(signal)
|
||||
|
||||
const token = await adoptOwnedServedToken(
|
||||
adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard'
|
||||
)
|
||||
assertNotAborted(signal)
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
|
||||
assertNotAborted(signal)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
tokenFingerprint,
|
||||
remotePort,
|
||||
localPort,
|
||||
pid,
|
||||
reused: false,
|
||||
platform,
|
||||
hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
logPath
|
||||
}
|
||||
} catch (error) {
|
||||
if (localPort && remotePort) await cancelForwardSafe(deps, localPort, remotePort)
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {}
|
||||
await cleanupStale(ssh, ownershipId, ownedSpawn)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_READY_TIMEOUT_MS,
|
||||
adoptOwnedServedToken,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
READY_RE,
|
||||
REMOTE_LOCK_DIR,
|
||||
SUPPORTED_REMOTE_OS,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
connect,
|
||||
expandRemotePath,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
lockfilePath,
|
||||
ownershipDirectory,
|
||||
spawnLogPath,
|
||||
isForwardBindCollision,
|
||||
openForward,
|
||||
mintToken,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
probeHermesVersion,
|
||||
probeRemoteHermesHome,
|
||||
remoteSupportsSshOwnership,
|
||||
readLockfile,
|
||||
remotePidAlive,
|
||||
removeLockfile,
|
||||
scrapeReadyPort,
|
||||
shq,
|
||||
spawnRemoteDashboard,
|
||||
validateRemotePath,
|
||||
writeLockfile
|
||||
}
|
||||
149
apps/desktop/electron/ssh-bootstrap-coordinator.test.ts
Normal file
149
apps/desktop/electron/ssh-bootstrap-coordinator.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
|
||||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((ok, fail) => {
|
||||
resolve = ok
|
||||
reject = fail
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' }
|
||||
|
||||
test('sshConfigFingerprint covers scope and every connection field', () => {
|
||||
const base = sshConfigFingerprint('', config)
|
||||
assert.equal(base, sshConfigFingerprint('', { ...config }))
|
||||
for (const [field, value] of Object.entries({ host: 'other', user: 'bob', port: 2222, keyPath: '/other', remoteHermesPath: '/other-hermes', effectiveConfigFingerprint: 'changed-config' })) {
|
||||
assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value }))
|
||||
}
|
||||
assert.notEqual(base, sshConfigFingerprint('profile', config))
|
||||
})
|
||||
|
||||
test('same scope and fingerprint share one bootstrap', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gate = deferred()
|
||||
let runs = 0
|
||||
const first = coordinator.start('', 'same', async () => {
|
||||
runs++
|
||||
return gate.promise
|
||||
})
|
||||
const second = coordinator.start('', 'same', async () => {
|
||||
runs++
|
||||
return 'wrong'
|
||||
})
|
||||
assert.equal(first, second)
|
||||
gate.resolve('done')
|
||||
assert.equal(await second, 'done')
|
||||
assert.equal(runs, 1)
|
||||
})
|
||||
|
||||
test('changed fingerprint waits for old rollback before starting', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
let oldLease
|
||||
const oldPromise = coordinator.start('', 'old', async lease => {
|
||||
oldLease = lease
|
||||
events.push('old-start')
|
||||
await gate.promise
|
||||
events.push('old-rollback')
|
||||
lease.assertCurrent()
|
||||
})
|
||||
await Promise.resolve()
|
||||
const newPromise = coordinator.start('', 'new', async lease => {
|
||||
events.push('new-start')
|
||||
lease.assertCurrent()
|
||||
return 'new'
|
||||
})
|
||||
assert.equal(oldLease.signal.aborted, true)
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(events, ['old-start'])
|
||||
gate.resolve()
|
||||
await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded')
|
||||
assert.equal(await newPromise, 'new')
|
||||
assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start'])
|
||||
})
|
||||
|
||||
test('forceCleanupAll runs registered pending resource cleanup', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gate = deferred()
|
||||
let cleaned = 0
|
||||
const promise = coordinator.start('', 'x', async lease => {
|
||||
lease.onForceCleanup(async () => { cleaned++ })
|
||||
await gate.promise
|
||||
})
|
||||
await Promise.resolve()
|
||||
await coordinator.forceCleanupAll()
|
||||
assert.equal(cleaned, 1)
|
||||
gate.resolve()
|
||||
await promise
|
||||
})
|
||||
|
||||
test('cancelAll invalidates every pending scope and exposes promises for quit', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gates = [deferred(), deferred()]
|
||||
const promises = gates.map((gate, index) => coordinator.start(String(index), 'x', async lease => {
|
||||
await gate.promise
|
||||
lease.assertCurrent()
|
||||
}))
|
||||
assert.equal(coordinator.promises().length, 2)
|
||||
coordinator.cancelAll()
|
||||
gates.forEach(gate => gate.resolve())
|
||||
const results = await Promise.allSettled(promises)
|
||||
assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded'))
|
||||
})
|
||||
|
||||
test('cancelAndWait drains only the requested scope', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const firstGate = deferred()
|
||||
const secondGate = deferred()
|
||||
const first = coordinator.start('first', 'x', async lease => {
|
||||
await firstGate.promise
|
||||
lease.assertCurrent()
|
||||
})
|
||||
const second = coordinator.start('second', 'x', async lease => {
|
||||
await secondGate.promise
|
||||
lease.assertCurrent()
|
||||
return 'second'
|
||||
})
|
||||
await Promise.resolve()
|
||||
let drained = false
|
||||
const drain = coordinator.cancelAndWait('first').then(() => { drained = true })
|
||||
await Promise.resolve()
|
||||
assert.equal(drained, false)
|
||||
firstGate.resolve()
|
||||
await drain
|
||||
await assert.rejects(first, (error: any) => error.kind === 'superseded')
|
||||
assert.equal(coordinator.pending.has('second'), true)
|
||||
secondGate.resolve()
|
||||
assert.equal(await second, 'second')
|
||||
})
|
||||
|
||||
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const oldGate = deferred()
|
||||
const events: string[] = []
|
||||
const old = coordinator.start('scope', 'old', async lease => {
|
||||
events.push('old-start')
|
||||
await oldGate.promise
|
||||
lease.assertCurrent()
|
||||
})
|
||||
await Promise.resolve()
|
||||
const drain = coordinator.cancelAndWait('scope')
|
||||
const next = coordinator.start('scope', 'new', async () => {
|
||||
events.push('new-start')
|
||||
return 'new'
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(events, ['old-start'])
|
||||
oldGate.resolve()
|
||||
await drain
|
||||
await assert.rejects(old, (error: any) => error.kind === 'superseded')
|
||||
assert.equal(await next, 'new')
|
||||
assert.deepEqual(events, ['old-start', 'new-start'])
|
||||
})
|
||||
89
apps/desktop/electron/ssh-bootstrap-coordinator.ts
Normal file
89
apps/desktop/electron/ssh-bootstrap-coordinator.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import crypto from 'node:crypto'
|
||||
|
||||
function sshConfigFingerprint(scope, config) {
|
||||
const parts = [scope, config.host, config.user, config.port, config.keyPath, config.remoteHermesPath, config.effectiveConfigFingerprint]
|
||||
return crypto.createHash('sha256').update(JSON.stringify(parts.map(value => value ?? ''))).digest('hex')
|
||||
}
|
||||
|
||||
function createBootstrapCoordinator() {
|
||||
const active = new Set<any>()
|
||||
const pending = new Map<string, any>()
|
||||
const generations = new Map<string, number>()
|
||||
const drains = new Map<string, Promise<void>>()
|
||||
|
||||
function start(scope, fingerprint, run) {
|
||||
const current = pending.get(scope)
|
||||
if (current?.fingerprint === fingerprint) return current.promise
|
||||
current?.controller.abort()
|
||||
|
||||
const generation = (generations.get(scope) || 0) + 1
|
||||
generations.set(scope, generation)
|
||||
const controller = new AbortController()
|
||||
const forceCleanups = new Set<() => any>()
|
||||
const lease = {
|
||||
signal: controller.signal,
|
||||
onForceCleanup(cleanup) {
|
||||
forceCleanups.add(cleanup)
|
||||
return () => forceCleanups.delete(cleanup)
|
||||
},
|
||||
isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation,
|
||||
assertCurrent() {
|
||||
if (!this.isCurrent()) {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
const drain = drains.get(scope) || Promise.resolve()
|
||||
const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain
|
||||
const entry: any = { controller, fingerprint, forceCleanups, generation, promise: null, scope }
|
||||
const promise = predecessor.then(() => {
|
||||
lease.assertCurrent()
|
||||
return run(lease)
|
||||
}).finally(() => {
|
||||
forceCleanups.clear()
|
||||
active.delete(entry)
|
||||
if (pending.get(scope)?.generation === generation) pending.delete(scope)
|
||||
})
|
||||
entry.promise = promise
|
||||
active.add(entry)
|
||||
pending.set(scope, entry)
|
||||
return promise
|
||||
}
|
||||
|
||||
function cancel(scope) {
|
||||
pending.get(scope)?.controller.abort()
|
||||
}
|
||||
|
||||
async function cancelAndWait(scope) {
|
||||
let release
|
||||
const barrier = new Promise<void>(resolve => { release = resolve })
|
||||
drains.set(scope, barrier)
|
||||
const entries = [...active].filter(entry => entry.scope === scope)
|
||||
for (const entry of entries) entry.controller.abort()
|
||||
try {
|
||||
await Promise.allSettled(entries.map(entry => entry.promise))
|
||||
} finally {
|
||||
if (drains.get(scope) === barrier) drains.delete(scope)
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
for (const entry of active) entry.controller.abort()
|
||||
}
|
||||
|
||||
async function forceCleanupAll() {
|
||||
const cleanups = [...active].flatMap(entry => [...entry.forceCleanups])
|
||||
await Promise.allSettled(cleanups.map(cleanup => cleanup()))
|
||||
}
|
||||
|
||||
function promises() {
|
||||
return [...active].map(entry => entry.promise)
|
||||
}
|
||||
|
||||
return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, start }
|
||||
}
|
||||
|
||||
export { createBootstrapCoordinator, sshConfigFingerprint }
|
||||
95
apps/desktop/electron/ssh-config.test.ts
Normal file
95
apps/desktop/electron/ssh-config.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config'
|
||||
|
||||
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
|
||||
const cfg = [
|
||||
'Host devbox',
|
||||
' 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), ['devbox', '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 devbox',
|
||||
'hostname 10.0.0.5',
|
||||
'user alice',
|
||||
'port 2222',
|
||||
'identityfile ~/.ssh/id_ed25519',
|
||||
'forwardagent no'
|
||||
].join('\n')
|
||||
assert.deepEqual(parseSshGOutput(out), {
|
||||
hostname: '10.0.0.5',
|
||||
user: 'alice',
|
||||
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)
|
||||
})
|
||||
119
apps/desktop/electron/ssh-config.ts
Normal file
119
apps/desktop/electron/ssh-config.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* ssh-config.ts
|
||||
*
|
||||
* Pure, electron-free helpers for reading the user's OpenSSH client config:
|
||||
* `Host` aliases for the settings UI's suggestions, `Include` traversal
|
||||
* (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's
|
||||
* unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
function parseSshConfigHosts(text) {
|
||||
const hosts: string[] = []
|
||||
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
|
||||
}
|
||||
|
||||
function parseSshConfigIncludes(text) {
|
||||
const includes: string[] = []
|
||||
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
|
||||
}
|
||||
|
||||
function collectSshConfigHosts(rootPath = '', deps: any = {}) {
|
||||
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: string[] = []
|
||||
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)
|
||||
const expanded = deps.globSync ? deps.globSync(target) : [target]
|
||||
for (const p of expanded) {
|
||||
walk(p, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function parseSshGOutput(text) {
|
||||
const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = {
|
||||
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
|
||||
}
|
||||
|
||||
export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput }
|
||||
692
apps/desktop/electron/ssh-connection.test.ts
Normal file
692
apps/desktop/electron/ssh-connection.test.ts
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
controlSocketPath,
|
||||
createSshProbeConnection,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
stopTunnelChild,
|
||||
sshErrorMessage,
|
||||
target,
|
||||
validateSshTarget
|
||||
} from './ssh-connection'
|
||||
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
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 it over 104.
|
||||
const p = controlSocketPath('hermes', 'remote-build-server', 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')
|
||||
})
|
||||
|
||||
|
||||
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, /ExitOnForwardFailure=yes/)
|
||||
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'))
|
||||
})
|
||||
|
||||
|
||||
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/)
|
||||
})
|
||||
|
||||
|
||||
// A fake child process that emits a scripted result on next tick.
|
||||
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) {
|
||||
const child: any = 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: any[] = []
|
||||
let i = 0
|
||||
const fn: any = (_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: string[] = []
|
||||
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: string[] = []
|
||||
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: any) => {
|
||||
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: any) => {
|
||||
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: any) => {
|
||||
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: string[] = []
|
||||
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]')))
|
||||
})
|
||||
|
||||
|
||||
test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => {
|
||||
const spawnFn = scriptedSpawn({ code: 0 })
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await conn.open()
|
||||
for (const args of spawnFn.calls) {
|
||||
assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => {
|
||||
const spawnFn = scriptedSpawn({ code: 0 })
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await conn.open()
|
||||
assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned')
|
||||
assert.ok(spawnFn.calls.some(args => args[args.length - 1] === 'true'), 'liveness/openness via one-shot exec')
|
||||
})
|
||||
|
||||
test('SSH probe never creates or closes a ControlMaster', async () => {
|
||||
const spawnFn = scriptedSpawn({ code: 0 })
|
||||
const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn })
|
||||
await conn.open()
|
||||
await conn.close()
|
||||
const args = spawnFn.calls.flat()
|
||||
assert.ok(!args.includes('-M'))
|
||||
assert.ok(!args.includes('-O'))
|
||||
assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value)))
|
||||
})
|
||||
|
||||
test('no-mux: open() classifies auth failure', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed')
|
||||
})
|
||||
|
||||
test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => {
|
||||
// Real listener stands in for the tunnel's local end so waitForLocalPort sees it.
|
||||
const net = await import('node:net')
|
||||
const srv = net.createServer()
|
||||
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()))
|
||||
const localPort = (srv.address() as any).port
|
||||
const tunnels: any[] = []
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
child.kill = () => {
|
||||
child._killed = true
|
||||
child.exitCode = 0
|
||||
process.nextTick(() => child.emit('exit', 0))
|
||||
return true
|
||||
}
|
||||
if (args.includes('-N')) {
|
||||
tunnels.push({ args, child })
|
||||
process.nextTick(() => child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)))
|
||||
} else process.nextTick(() => child.emit('close', 0))
|
||||
if (!args.includes('-N')) {
|
||||
child.stdout = new EventEmitter()
|
||||
process.nextTick(() => child.emit('close', 0))
|
||||
}
|
||||
return child
|
||||
}
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await conn.forward(localPort, 9119)
|
||||
assert.equal(tunnels.length, 1, 'one persistent tunnel child')
|
||||
assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec')
|
||||
assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a)))
|
||||
|
||||
await conn.cancelForward(localPort, 9119)
|
||||
assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child')
|
||||
|
||||
conn._opened = true
|
||||
await conn.close() // no-mux close never runs ssh -O exit; must not throw
|
||||
srv.close()
|
||||
})
|
||||
|
||||
test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => {
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
child.kill = () => {}
|
||||
if (args.includes('-N')) {
|
||||
process.nextTick(() => {
|
||||
child.stderr.emit('data', Buffer.from('Permission denied (publickey).'))
|
||||
child.exitCode = 255
|
||||
})
|
||||
}
|
||||
return child
|
||||
}
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 })
|
||||
await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed')
|
||||
})
|
||||
|
||||
test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => {
|
||||
const net = await import('node:net')
|
||||
const srv = net.createServer()
|
||||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||||
const localPort = (srv.address() as any).port
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
child.kill = () => {}
|
||||
if (args.includes('-N')) {
|
||||
setTimeout(() => {
|
||||
child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`))
|
||||
child.exitCode = 255
|
||||
child.emit('exit', 255)
|
||||
}, 20)
|
||||
}
|
||||
return child
|
||||
}
|
||||
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 })
|
||||
await assert.rejects(conn.forward(localPort, 9119), /address already in use/i)
|
||||
srv.close()
|
||||
})
|
||||
|
||||
test('no-mux: tunnel death after readiness makes the connection unhealthy', async () => {
|
||||
const net = await import('node:net')
|
||||
const srv = net.createServer()
|
||||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||||
const localPort = (srv.address() as any).port
|
||||
let tunnel
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
child.kill = () => {}
|
||||
if (args.includes('-N')) {
|
||||
tunnel = child
|
||||
process.nextTick(() => child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)))
|
||||
} else process.nextTick(() => child.emit('close', 0))
|
||||
return child
|
||||
}
|
||||
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false })
|
||||
await conn.open()
|
||||
await conn.forward(localPort, 9119)
|
||||
tunnel.emit('exit', 255)
|
||||
assert.equal(await conn.isAlive(), false)
|
||||
srv.close()
|
||||
})
|
||||
|
||||
|
||||
test('validateSshTarget rejects a host starting with a dash (option injection)', () => {
|
||||
assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects control characters in host', () => {
|
||||
assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects control characters in user', () => {
|
||||
assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects ports outside 1-65535', () => {
|
||||
assert.throws(() => validateSshTarget('box', '', 0), /port/i)
|
||||
assert.throws(() => validateSshTarget('box', '', 65536), /port/i)
|
||||
assert.throws(() => validateSshTarget('box', '', -1), /port/i)
|
||||
assert.throws(() => validateSshTarget('box', '', NaN), /port/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget accepts valid targets', () => {
|
||||
assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22))
|
||||
assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222))
|
||||
assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22))
|
||||
})
|
||||
|
||||
test('SshConnection constructor rejects hostile host/user/port', () => {
|
||||
assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i)
|
||||
assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i)
|
||||
assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i)
|
||||
})
|
||||
|
||||
test('buildExecArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildExecArgs(conn, 'uname -s', 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination')
|
||||
assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination')
|
||||
})
|
||||
|
||||
test('buildMasterArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildMasterArgs(conn, 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildControlArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildControlArgs(conn, 'check', [], 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box')
|
||||
})
|
||||
|
||||
test('hostArgs rejects a keyPath with control characters', () => {
|
||||
assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i)
|
||||
})
|
||||
|
||||
test('hostArgs rejects a keyPath starting with a dash', () => {
|
||||
assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i)
|
||||
})
|
||||
|
||||
test('hostArgs accepts valid key paths', () => {
|
||||
assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519'])
|
||||
assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa'])
|
||||
})
|
||||
|
||||
test('runSsh delivers stdinData to the child and does not log it', async () => {
|
||||
let stdinWritten = ''
|
||||
const spawnFn: any = (_cmd, _args, opts) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.kill = () => {}
|
||||
child.stdin = {
|
||||
end(data) { stdinWritten = String(data) }
|
||||
}
|
||||
assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided')
|
||||
process.nextTick(() => child.emit('close', 0))
|
||||
return child
|
||||
}
|
||||
await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' })
|
||||
assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin')
|
||||
})
|
||||
|
||||
test('open() rejects a control-dir that is a symlink', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
|
||||
const real = path.join(tmp, 'real')
|
||||
const link = path.join(tmp, 'link')
|
||||
fs.mkdirSync(real, { mode: 0o700 })
|
||||
fs.symlinkSync(real, link)
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link })
|
||||
await assert.rejects(conn.open(), /symlink|unsafe/i)
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('open() enforces 0700 on an existing control dir with lax permissions', async () => {
|
||||
if (process.platform === 'win32') return
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
|
||||
const dir = path.join(tmp, 'ctrl')
|
||||
fs.mkdirSync(dir, { mode: 0o755 })
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
await conn.open()
|
||||
const stat = fs.statSync(dir)
|
||||
assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700')
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('control socket identity separates installation scope and key identity', () => {
|
||||
const base = controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/id'
|
||||
})
|
||||
assert.equal(base, controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/./id'
|
||||
}))
|
||||
assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'worker',
|
||||
keyPath: '/keys/id'
|
||||
}))
|
||||
assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-b',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/id'
|
||||
}))
|
||||
assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/other'
|
||||
}))
|
||||
assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/id',
|
||||
effectiveConfigFingerprint: 'changed-config'
|
||||
}))
|
||||
})
|
||||
|
||||
test('closing one scope addresses only that scope control master', async () => {
|
||||
const firstSpawn = scriptedSpawn({ code: 0 })
|
||||
const secondSpawn = scriptedSpawn({ code: 0 })
|
||||
const first = new SshConnection({ host: 'box', user: 'me' }, {
|
||||
spawnFn: firstSpawn,
|
||||
controlDir: '/tmp/d',
|
||||
ownershipId: 'installation',
|
||||
scope: 'first'
|
||||
})
|
||||
const second = new SshConnection({ host: 'box', user: 'me' }, {
|
||||
spawnFn: secondSpawn,
|
||||
controlDir: '/tmp/d',
|
||||
ownershipId: 'installation',
|
||||
scope: 'second'
|
||||
})
|
||||
first._opened = true
|
||||
second._opened = true
|
||||
await first.close()
|
||||
assert.notEqual(first.controlPath, second.controlPath)
|
||||
assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`))
|
||||
assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`))
|
||||
assert.equal(second._opened, true)
|
||||
})
|
||||
|
||||
test('failed ControlMaster close remains retryable', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }, { code: 0 }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
conn._opened = true
|
||||
await conn.close()
|
||||
assert.equal(conn._opened, true)
|
||||
await conn.close()
|
||||
assert.equal(conn._opened, false)
|
||||
assert.equal(spawnFn.calls.length, 2)
|
||||
})
|
||||
|
||||
test('stopTunnelChild waits for process exit', async () => {
|
||||
const child: any = new EventEmitter()
|
||||
child.exitCode = null
|
||||
child.kill = () => {
|
||||
process.nextTick(() => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0)
|
||||
})
|
||||
return true
|
||||
}
|
||||
let stopped = false
|
||||
const stopping = stopTunnelChild(child).then(() => { stopped = true })
|
||||
assert.equal(stopped, false)
|
||||
await stopping
|
||||
assert.equal(stopped, true)
|
||||
})
|
||||
664
apps/desktop/electron/ssh-connection.ts
Normal file
664
apps/desktop/electron/ssh-connection.ts
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
/**
|
||||
* ssh-connection.ts
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* No `import 'electron'` so it is unit-testable without Electron. main.ts
|
||||
* 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.
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
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
|
||||
|
||||
const _CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/
|
||||
|
||||
function validateSshTarget(host, user, port) {
|
||||
if (!host || typeof host !== 'string') {
|
||||
throw new Error('Unsafe SSH target: host is required.')
|
||||
}
|
||||
if (host.startsWith('-')) {
|
||||
throw new Error(`Unsafe SSH target: host must not start with a dash ("${host}").`)
|
||||
}
|
||||
if (_CONTROL_CHAR_RE.test(host)) {
|
||||
throw new Error('Unsafe SSH target: host contains control characters.')
|
||||
}
|
||||
if (user && _CONTROL_CHAR_RE.test(user)) {
|
||||
throw new Error('Unsafe SSH target: user contains control characters.')
|
||||
}
|
||||
if (user && user.startsWith('-')) {
|
||||
throw new Error(`Unsafe SSH target: user must not start with a dash ("${user}").`)
|
||||
}
|
||||
const p = Number(port)
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
||||
throw new Error(`Unsafe SSH port: ${port} (must be 1-65535).`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateKeyPath(keyPath) {
|
||||
if (!keyPath) return
|
||||
if (_CONTROL_CHAR_RE.test(keyPath)) {
|
||||
throw new Error('Unsafe SSH key path: contains control characters.')
|
||||
}
|
||||
if (keyPath.startsWith('-')) {
|
||||
throw new Error(`Unsafe SSH key path: must not start with a dash ("${keyPath}").`)
|
||||
}
|
||||
}
|
||||
|
||||
// Token / secret redaction
|
||||
|
||||
const _REDACTIONS: Array<[RegExp, string]> = [
|
||||
[/(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>` while establishing
|
||||
// the master — so a path that itself fits 104 still overflows at bind time. We
|
||||
// root under a short per-user base (`~/.hermes/desktop-ssh`) so even worst case
|
||||
// (~72 bytes on macOS) stays clear. Windows has no AF_UNIX sun_path limit.
|
||||
function controlSocketPath(user, host, port, baseDir?, identity: any = {}) {
|
||||
const dir = baseDir || defaultControlDir()
|
||||
const keyPathIdentity = path.normalize(String(identity.keyPath || ''))
|
||||
const parts = [identity.ownershipId || '', identity.scope || '', user || '', host, Number(port), keyPathIdentity, identity.effectiveConfigFingerprint || '']
|
||||
const id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)
|
||||
return path.join(dir, `${id}.sock`)
|
||||
}
|
||||
|
||||
function defaultControlDir() {
|
||||
// POSIX: a SHORT, PER-USER base stays under the socket limit AND avoids a
|
||||
// world-shared /tmp dir (no 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)
|
||||
|
||||
// Mux (POSIX): ControlMaster options so exec/forward share one authenticated
|
||||
// connection. No-mux (Windows OpenSSH never implemented mux sockets): plain
|
||||
// per-invocation options — each ssh call authenticates on its own.
|
||||
function baseSshOptions(controlPath, connectTimeoutMs?) {
|
||||
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
|
||||
const mux = controlPath
|
||||
? ['-o', `ControlPath=${controlPath}`, '-o', 'ControlMaster=auto', '-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`]
|
||||
: []
|
||||
return [
|
||||
...mux,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
'-o', 'ExitOnForwardFailure=yes',
|
||||
'-o', `ConnectTimeout=${connectSecs}`
|
||||
]
|
||||
}
|
||||
|
||||
// Non-default port and explicit identity file, shared by exec/master/forward.
|
||||
function hostArgs({ port, keyPath }: { port?: number | string; keyPath?: string } = {}) {
|
||||
const args: string[] = []
|
||||
if (port && Number(port) !== 22) {
|
||||
args.push('-p', String(port))
|
||||
}
|
||||
if (keyPath) {
|
||||
validateKeyPath(keyPath)
|
||||
args.push('-i', keyPath)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function target(user, host) {
|
||||
return user ? `${user}@${host}` : host
|
||||
}
|
||||
|
||||
function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) {
|
||||
return [...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host), remoteCommand]
|
||||
}
|
||||
|
||||
function buildControlArgs(conn, op, extra: string[] = [], connectTimeoutMs?) {
|
||||
return ['-O', op, ...extra, ...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host)]
|
||||
}
|
||||
|
||||
// Open the master explicitly: `-M -N -f` backgrounds ssh 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 (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.
|
||||
//
|
||||
// NOTE(remote-terminal): interim until the dashboard /api/terminal WebSocket
|
||||
// lands (specs/desktop-remote-terminal.md); 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
|
||||
}
|
||||
|
||||
// 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'
|
||||
}
|
||||
|
||||
// Order matters: the host-key-change banner also contains "WARNING"/"Offending",
|
||||
// so 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', stdinData }: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const useStdinPipe = stdinData != null || stdin !== 'ignore'
|
||||
let child
|
||||
try {
|
||||
child = spawnFn('ssh', args, { stdio: [useStdinPipe ? 'pipe' : 'ignore', 'pipe', 'pipe'] })
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (stdinData != null && child.stdin) {
|
||||
child.stdin.end(stdinData)
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
const err: any = 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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function stopTunnelChild(child, timeoutMs = 5_000) {
|
||||
if (!child || child.exitCode != null || child.signalCode != null) return Promise.resolve()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (error?: unknown) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.off?.('exit', onExit)
|
||||
child.off?.('error', onError)
|
||||
error ? reject(error) : resolve()
|
||||
}
|
||||
const onExit = () => finish()
|
||||
const onError = error => finish(error)
|
||||
const timer = setTimeout(() => finish(new Error('SSH tunnel did not exit after termination.')), timeoutMs)
|
||||
child.once('exit', onExit)
|
||||
child.once('error', onError)
|
||||
try {
|
||||
if (!child.kill()) finish(new Error('SSH tunnel termination was refused.'))
|
||||
} catch (error) {
|
||||
finish(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SshConnection — the public manager
|
||||
|
||||
class SshConnection {
|
||||
host: string
|
||||
user: string
|
||||
port: number
|
||||
keyPath: string
|
||||
controlPath: string
|
||||
_spawnFn: any
|
||||
_log: (msg: string) => void
|
||||
_connectTimeoutMs: number
|
||||
_execTimeoutMs: number
|
||||
_forwardTimeoutMs: number
|
||||
_opened: boolean
|
||||
_mux: boolean
|
||||
_tunnels: Map<string, any>
|
||||
|
||||
constructor(cfg, opts: any = {}) {
|
||||
if (!cfg || !cfg.host) {
|
||||
throw new Error('SshConnection requires a host.')
|
||||
}
|
||||
const port = cfg.port ? Number(cfg.port) : 22
|
||||
validateSshTarget(cfg.host, cfg.user || '', port)
|
||||
if (cfg.keyPath) validateKeyPath(cfg.keyPath)
|
||||
this.host = cfg.host
|
||||
this.user = cfg.user || ''
|
||||
this.port = port
|
||||
this.keyPath = cfg.keyPath || ''
|
||||
// Windows OpenSSH has no ControlMaster (mux sockets were never implemented
|
||||
// on Win32) — fall back to one ssh invocation per operation and a
|
||||
// persistent `ssh -N -L` child per tunnel. Empty controlPath routes the
|
||||
// pure builders onto their no-mux form.
|
||||
this._mux = opts.mux ?? process.platform !== 'win32'
|
||||
this.controlPath = this._mux
|
||||
? controlSocketPath(this.user, this.host, this.port, opts.controlDir, {
|
||||
keyPath: this.keyPath,
|
||||
ownershipId: opts.ownershipId,
|
||||
scope: opts.scope,
|
||||
effectiveConfigFingerprint: opts.effectiveConfigFingerprint
|
||||
})
|
||||
: ''
|
||||
this._tunnels = new Map()
|
||||
|
||||
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}`))
|
||||
}
|
||||
|
||||
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
|
||||
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
|
||||
const err: any = 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: any = new Error(sshErrorMessage(kind, this, stderr))
|
||||
err.kind = kind
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the connection. Mux: start the persistent ControlMaster (idempotent —
|
||||
// a live master is a no-op). No-mux: there is no master; validate auth +
|
||||
// reachability with a one-shot `ssh true` so failures classify identically.
|
||||
async open() {
|
||||
if (await this.isAlive()) {
|
||||
this._opened = true
|
||||
return
|
||||
}
|
||||
if (!this._mux) {
|
||||
this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(buildExecArgs(this, 'true', this._connectTimeoutMs), {
|
||||
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('connection verified (no-mux; per-operation ssh)')
|
||||
return
|
||||
}
|
||||
const controlDir = path.dirname(this.controlPath)
|
||||
try {
|
||||
fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })
|
||||
} catch {}
|
||||
if (process.platform !== 'win32') {
|
||||
const st = fs.lstatSync(controlDir)
|
||||
if (st.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)
|
||||
}
|
||||
if (st.uid !== process.getuid!()) {
|
||||
throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)
|
||||
}
|
||||
if ((st.mode & 0o777) !== 0o700) {
|
||||
fs.chmodSync(controlDir, 0o700)
|
||||
}
|
||||
}
|
||||
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')
|
||||
}
|
||||
|
||||
// Liveness. Mux: `-O check` against the master socket. No-mux: a cheap
|
||||
// one-shot exec — "alive" means "we can still authenticate and run".
|
||||
async isAlive() {
|
||||
if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) return false
|
||||
const args = this._mux
|
||||
? buildControlArgs(this, 'check', [], this._connectTimeoutMs)
|
||||
: buildExecArgs(this, 'true', this._connectTimeoutMs)
|
||||
try {
|
||||
const result: any = 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 stdout;
|
||||
// rejects with a classified error on non-zero exit or timeout.
|
||||
async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) {
|
||||
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, {
|
||||
timeoutMs: timeoutMs ?? this._execTimeoutMs,
|
||||
spawnFn: this._spawnFn,
|
||||
...(stdinData != null ? { stdinData } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
// Establish a local→remote forward. Mux: `-O forward` against the master.
|
||||
// No-mux: spawn a persistent `ssh -N -L` child that IS the tunnel; ready when
|
||||
// the local port accepts. The child dying = tunnel down (isAlive of the
|
||||
// backend catches it upstream).
|
||||
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
|
||||
if (!this._mux) {
|
||||
const args = [...baseSshOptions('', this._connectTimeoutMs), ...hostArgs(this), '-v', '-N', '-L', spec, '--', target(this.user, this.host)]
|
||||
const child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const tunnel = { child, alive: true }
|
||||
this._tunnels.set(spec, tunnel)
|
||||
let stderr = ''
|
||||
let readyConfirmed = false
|
||||
let readyResolve
|
||||
let readyReject
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
readyResolve = resolve
|
||||
readyReject = reject
|
||||
})
|
||||
const readyPattern = new RegExp(`Local forwarding listening on .* port ${localPort}\\b`)
|
||||
child.stderr?.on('data', d => {
|
||||
if (readyConfirmed) return
|
||||
stderr = `${stderr}${String(d)}`.slice(-16_384)
|
||||
if (readyPattern.test(stderr)) {
|
||||
readyConfirmed = true
|
||||
readyResolve()
|
||||
}
|
||||
})
|
||||
child.on('error', error => {
|
||||
tunnel.alive = false
|
||||
readyReject(error)
|
||||
})
|
||||
child.on('exit', code => {
|
||||
tunnel.alive = false
|
||||
readyReject(new Error(`tunnel process exited with code ${code}`))
|
||||
})
|
||||
child.on('close', code => {
|
||||
tunnel.alive = false
|
||||
readyReject(new Error(`tunnel process closed with code ${code}`))
|
||||
})
|
||||
let readyTimeout
|
||||
try {
|
||||
await Promise.race([
|
||||
ready,
|
||||
new Promise((_, reject) => {
|
||||
readyTimeout = setTimeout(() => reject(new Error('tunnel did not confirm local forwarding')), this._forwardTimeoutMs)
|
||||
})
|
||||
])
|
||||
} catch (error: any) {
|
||||
try {
|
||||
await stopTunnelChild(child)
|
||||
this._tunnels.delete(spec)
|
||||
} catch (stopError) {
|
||||
throw this._fail(stopError, SSH_ERROR.UNKNOWN)
|
||||
}
|
||||
throw this._fail(stderr || error, SSH_ERROR.UNKNOWN)
|
||||
} finally {
|
||||
clearTimeout(readyTimeout)
|
||||
}
|
||||
return
|
||||
}
|
||||
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
|
||||
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 (close tears everything down anyway).
|
||||
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
if (!this._mux) {
|
||||
const tunnel = this._tunnels.get(spec)
|
||||
if (tunnel) {
|
||||
await stopTunnelChild(tunnel.child)
|
||||
this._tunnels.delete(spec)
|
||||
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
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: any) {
|
||||
this._logLine(`cancelForward failed (ignored): ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down. Mux: exit the master (drops every forward with it). No-mux:
|
||||
// kill the tunnel children. Best-effort; never throws.
|
||||
async close() {
|
||||
if (!this._opened) return
|
||||
if (!this._mux) {
|
||||
for (const [spec, tunnel] of this._tunnels) {
|
||||
await stopTunnelChild(tunnel.child)
|
||||
this._tunnels.delete(spec)
|
||||
}
|
||||
this._opened = false
|
||||
this._logLine('connection closed (no-mux tunnels killed)')
|
||||
return
|
||||
}
|
||||
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
|
||||
try {
|
||||
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
if (result.code !== 0) throw this._fail(result.stderr)
|
||||
this._logLine('control master closed')
|
||||
this._opened = false
|
||||
} catch (error: any) {
|
||||
this._logLine(`close failed (retryable): ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Free local port for the tunnel's local end. Bind 127.0.0.1:0, read the
|
||||
// kernel-assigned port, release. The benign TOCTOU window (release → forward
|
||||
// grabs it) 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() as net.AddressInfo
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createSshProbeConnection(config, options: any = {}) {
|
||||
return new SshConnection(config, { ...options, mux: false })
|
||||
}
|
||||
|
||||
export {
|
||||
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,
|
||||
createSshProbeConnection,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
stopTunnelChild,
|
||||
sshErrorMessage,
|
||||
target,
|
||||
validateKeyPath,
|
||||
validateSshTarget
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue