mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts).
1017 lines
33 KiB
TypeScript
1017 lines
33 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import {
|
|
buildSpawnCommand,
|
|
cleanupStale,
|
|
connect,
|
|
expandRemotePath,
|
|
fingerprintToken,
|
|
isForwardBindCollision,
|
|
locateHermes,
|
|
LOCKFILE_SCHEMA_VERSION,
|
|
lockfilePath,
|
|
openForward,
|
|
ownershipDirectory,
|
|
pidIsOurDashboard,
|
|
probeRemotePlatform,
|
|
PROTOCOL_VERSION,
|
|
readLockfile,
|
|
READY_RE,
|
|
remotePidAlive,
|
|
remoteSupportsSshOwnership,
|
|
scrapeReadyPort,
|
|
spawnLogPath,
|
|
spawnRemoteDashboard,
|
|
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)
|
|
assert.ok(
|
|
!ssh.calls.some(command => command.includes('pid=333') && command.includes('print("OWNED"')),
|
|
'a dead pid has no process identity to verify'
|
|
)
|
|
})
|
|
|
|
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)
|
|
})
|