mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
style(desktop): satisfy merged eslint/prettier config
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).
This commit is contained in:
parent
578374675e
commit
faa3bce6dc
39 changed files with 2395 additions and 659 deletions
|
|
@ -4,45 +4,63 @@ import { applyConnectionChange, commitConnectionFailure, resolveTerminalConnecti
|
|||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(done => { resolve = done })
|
||||
|
||||
const promise = new Promise<void>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('applyConnectionChange', () => {
|
||||
it.each([
|
||||
['SSH A to SSH B'],
|
||||
['SSH to Cloud'],
|
||||
['Cloud to SSH']
|
||||
])('serializes %s behind bootstrap rollback before teardown and apply', async () => {
|
||||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
const run = applyConnectionChange({
|
||||
cancelAndWait: async () => { events.push('cancel'); await gate.promise; events.push('drained') },
|
||||
isPrimary: true,
|
||||
scope: '',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: vi.fn(),
|
||||
teardownPrimary: async () => { events.push('primary') },
|
||||
teardownSsh: async () => { events.push('ssh') }
|
||||
})
|
||||
it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])(
|
||||
'serializes %s behind bootstrap rollback before teardown and apply',
|
||||
async () => {
|
||||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['cancel'])
|
||||
gate.resolve()
|
||||
await run
|
||||
expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])
|
||||
})
|
||||
const run = applyConnectionChange({
|
||||
cancelAndWait: async () => {
|
||||
events.push('cancel')
|
||||
await gate.promise
|
||||
events.push('drained')
|
||||
},
|
||||
isPrimary: true,
|
||||
scope: '',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: vi.fn(),
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async () => {
|
||||
events.push('ssh')
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['cancel'])
|
||||
gate.resolve()
|
||||
await run
|
||||
expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])
|
||||
}
|
||||
)
|
||||
|
||||
it('tears down only a non-primary scope without applying the primary connection', async () => {
|
||||
const events: string[] = []
|
||||
await applyConnectionChange({
|
||||
cancelAndWait: async scope => { events.push(`cancel:${scope}`) },
|
||||
cancelAndWait: async scope => {
|
||||
events.push(`cancel:${scope}`)
|
||||
},
|
||||
isPrimary: false,
|
||||
scope: 'worker',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: scope => events.push(`pool:${scope}`),
|
||||
teardownPrimary: async () => { events.push('primary') },
|
||||
teardownSsh: async scope => { events.push(`ssh:${scope}`) }
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async scope => {
|
||||
events.push(`ssh:${scope}`)
|
||||
}
|
||||
})
|
||||
expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker'])
|
||||
})
|
||||
|
|
@ -59,7 +77,12 @@ describe('resolveTerminalConnection', () => {
|
|||
})
|
||||
|
||||
it('does not start a local terminal while configured SSH remains unavailable', async () => {
|
||||
await expect(resolveTerminalConnection(() => 'pending', async () => undefined)).rejects.toThrow('not ready')
|
||||
await expect(
|
||||
resolveTerminalConnection(
|
||||
() => 'pending',
|
||||
async () => undefined
|
||||
)
|
||||
).rejects.toThrow('not ready')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -9,26 +9,41 @@ async function applyConnectionChange({
|
|||
}) {
|
||||
await cancelAndWait(scope)
|
||||
await teardownSsh(scope)
|
||||
|
||||
if (!isPrimary) {
|
||||
stopPool(scope)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await teardownPrimary()
|
||||
sendApplied()
|
||||
}
|
||||
|
||||
function commitConnectionFailure(current, starting, commit) {
|
||||
if (current !== starting) return false
|
||||
if (current !== starting) {
|
||||
return false
|
||||
}
|
||||
|
||||
commit()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveTerminalConnection(getTarget, ensureBackend) {
|
||||
let target = getTarget()
|
||||
if (target !== 'pending') return target
|
||||
|
||||
if (target !== 'pending') {
|
||||
return target
|
||||
}
|
||||
|
||||
await ensureBackend()
|
||||
target = getTarget()
|
||||
if (target === 'pending') throw new Error('Remote connection is not ready yet. Try again in a moment.')
|
||||
|
||||
if (target === 'pending') {
|
||||
throw new Error('Remote connection is not ready yet. Try again in a moment.')
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,19 +23,19 @@ import {
|
|||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
localProfileEntry,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
savedProfileSsh,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
savedProfileSsh,
|
||||
tokenPreview
|
||||
} from './connection-config'
|
||||
|
||||
|
|
@ -134,22 +134,31 @@ test('SSH remains separate from URL-shaped remote modes', () => {
|
|||
const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'coder'), null)
|
||||
assert.deepEqual(profileSshOverride(config, 'coder'), {
|
||||
mode: 'ssh', host: 'box', user: 'alice', port: 2222, keyPath: '/key'
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
user: 'alice',
|
||||
port: 2222,
|
||||
keyPath: '/key'
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeSshConfig handles IPv6 and strict port bounds', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '::1', port: 22 }), {
|
||||
mode: 'ssh', host: '::1'
|
||||
mode: 'ssh',
|
||||
host: '::1'
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '[::1]:2222' }), {
|
||||
mode: 'ssh', host: '::1', port: 2222
|
||||
mode: 'ssh',
|
||||
host: '::1',
|
||||
port: 2222
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: '2222junk' }), {
|
||||
mode: 'ssh', host: 'box'
|
||||
mode: 'ssh',
|
||||
host: 'box'
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 65536 }), {
|
||||
mode: 'ssh', host: 'box'
|
||||
mode: 'ssh',
|
||||
host: 'box'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -157,7 +166,8 @@ test('localProfileEntry preserves inactive SSH drafts but drops Cloud state', ()
|
|||
const ssh = { mode: 'ssh', host: 'box', user: 'alice', remoteHermesPath: '/hermes' }
|
||||
assert.deepEqual(localProfileEntry(ssh), { mode: 'local', savedSsh: ssh })
|
||||
assert.deepEqual(localProfileEntry({ mode: 'local', savedSsh: ssh }), {
|
||||
mode: 'local', savedSsh: ssh
|
||||
mode: 'local',
|
||||
savedSsh: ssh
|
||||
})
|
||||
assert.equal(localProfileEntry({ mode: 'cloud', url: 'https://agent' }), null)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -171,52 +171,91 @@ function modeIsRemoteLike(mode) {
|
|||
}
|
||||
|
||||
function normalizeSshConfig(entry) {
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') return null
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
|
||||
return null
|
||||
}
|
||||
|
||||
let host = String(entry.host || '').trim()
|
||||
if (!host) return null
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsedUser
|
||||
let parsedPort
|
||||
const at = host.indexOf('@')
|
||||
|
||||
if (at > 0) {
|
||||
parsedUser = host.slice(0, at)
|
||||
host = host.slice(at + 1)
|
||||
}
|
||||
|
||||
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(host)
|
||||
|
||||
if (bracketed) {
|
||||
host = bracketed[1]
|
||||
if (bracketed[2]) parsedPort = Number(bracketed[2])
|
||||
|
||||
if (bracketed[2]) {
|
||||
parsedPort = Number(bracketed[2])
|
||||
}
|
||||
} else if ((host.match(/:/g) || []).length === 1) {
|
||||
const [name, rawPort] = host.split(':')
|
||||
|
||||
if (/^\d+$/.test(rawPort)) {
|
||||
host = name
|
||||
parsedPort = Number(rawPort)
|
||||
}
|
||||
}
|
||||
if (!host) return null
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
const out: any = { mode: 'ssh', host }
|
||||
const user = String(entry.user || '').trim() || parsedUser || ''
|
||||
if (user) out.user = user
|
||||
|
||||
if (user) {
|
||||
out.user = user
|
||||
}
|
||||
|
||||
const rawExplicitPort = String(entry.port ?? '').trim()
|
||||
const explicitPort = /^\d+$/.test(rawExplicitPort) ? Number(rawExplicitPort) : null
|
||||
const port = explicitPort ?? parsedPort
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) out.port = port
|
||||
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) {
|
||||
out.port = port
|
||||
}
|
||||
|
||||
const keyPath = String(entry.keyPath || '').trim()
|
||||
if (keyPath) out.keyPath = keyPath
|
||||
|
||||
if (keyPath) {
|
||||
out.keyPath = keyPath
|
||||
}
|
||||
|
||||
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
|
||||
if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath
|
||||
|
||||
if (remoteHermesPath) {
|
||||
out.remoteHermesPath = remoteHermesPath
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function profileSshOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
|
||||
return normalizeSshConfig(entry)
|
||||
}
|
||||
|
||||
function savedProfileSsh(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
if (!entry || entry.mode !== 'local') return null
|
||||
|
||||
if (!entry || entry.mode !== 'local') {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeSshConfig(entry.savedSsh)
|
||||
}
|
||||
|
||||
|
|
@ -226,15 +265,24 @@ function profileHasRemoteConnection(config, profile) {
|
|||
|
||||
function localProfileEntry(existing) {
|
||||
const ssh = normalizeSshConfig(existing) || normalizeSshConfig(existing?.savedSsh)
|
||||
|
||||
return ssh ? { mode: 'local', savedSsh: ssh } : null
|
||||
}
|
||||
|
||||
function hostLabelFromBaseUrl(baseUrl) {
|
||||
const raw = String(baseUrl || '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
if (!parsed.hostname) return null
|
||||
|
||||
if (!parsed.hostname) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed.port && parsed.port !== '80' && parsed.port !== '443'
|
||||
? `${parsed.hostname}:${parsed.port}`
|
||||
: parsed.hostname
|
||||
|
|
@ -411,19 +459,19 @@ export {
|
|||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
localProfileEntry,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
savedProfileSsh,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
savedProfileSsh,
|
||||
tokenPreview
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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'
|
||||
|
|
@ -11,6 +12,7 @@ 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 {
|
||||
|
|
@ -25,44 +27,73 @@ test('parseInstallationId accepts only a version-4 UUID record', () => {
|
|||
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 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 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
|
||||
)
|
||||
|
||||
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)
|
||||
}))
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}
|
||||
}))
|
||||
|
||||
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 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 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('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, '')
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]
|
|||
function parseInstallationId(raw) {
|
||||
try {
|
||||
const value = JSON.parse(String(raw || ''))?.installationId
|
||||
|
||||
return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : ''
|
||||
} catch {
|
||||
return ''
|
||||
|
|
@ -16,9 +17,19 @@ function parseInstallationId(raw) {
|
|||
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)
|
||||
|
||||
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 ''
|
||||
|
|
@ -32,51 +43,95 @@ function waitForRepair() {
|
|||
|
||||
function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) {
|
||||
const existing = readInstallationId(filePath)
|
||||
if (existing) return existing
|
||||
|
||||
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.')
|
||||
|
||||
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
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
|
||||
const winner = readInstallationId(filePath)
|
||||
if (winner) return winner
|
||||
|
||||
if (winner) {
|
||||
return winner
|
||||
}
|
||||
|
||||
waitForRepair()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const winner = readInstallationId(filePath)
|
||||
if (winner) return winner
|
||||
|
||||
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.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
|
||||
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 {}
|
||||
if (repairFd !== undefined) {
|
||||
fs.closeSync(repairFd)
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(repairPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -48,27 +48,22 @@ import {
|
|||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
localProfileEntry,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
savedProfileSsh,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
savedProfileSsh,
|
||||
tokenPreview
|
||||
} from './connection-config'
|
||||
import { adoptServedDashboardToken } from './dashboard-token'
|
||||
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
import { SshConnection, buildInteractiveSshArgs, createSshProbeConnection, pickLocalPort, redactSecrets } from './ssh-connection'
|
||||
import * as remoteLifecycle from './remote-lifecycle'
|
||||
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
|
||||
import { buildWindowsInteractiveCommand, connectWindowsRemote, detectRemotePlatform, helper } from './windows-remote-lifecycle'
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
|
|
@ -119,6 +114,7 @@ import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle
|
|||
import { ensureMainWindow } from './main-window-lifecycle'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import * as remoteLifecycle from './remote-lifecycle'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
|
|
@ -127,6 +123,15 @@ import {
|
|||
SESSION_WINDOW_MIN_WIDTH
|
||||
} from './session-windows'
|
||||
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
|
||||
import {
|
||||
buildInteractiveSshArgs,
|
||||
createSshProbeConnection,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
SshConnection
|
||||
} from './ssh-connection'
|
||||
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker'
|
||||
|
|
@ -157,6 +162,12 @@ import {
|
|||
getVenvSitePackagesEntries,
|
||||
resolveVenvHermesCommand
|
||||
} from './windows-hermes-path'
|
||||
import {
|
||||
buildWindowsInteractiveCommand,
|
||||
connectWindowsRemote,
|
||||
detectRemotePlatform,
|
||||
helper
|
||||
} from './windows-remote-lifecycle'
|
||||
import {
|
||||
alreadyHasNoSandbox,
|
||||
buildNoSandboxRelaunchArgs,
|
||||
|
|
@ -4607,6 +4618,7 @@ async function waitForHermes(baseUrl, token, signal?) {
|
|||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`${baseUrl}/api/status`, token)
|
||||
|
||||
|
|
@ -4615,12 +4627,16 @@ async function waitForHermes(baseUrl, token, signal?) {
|
|||
lastError = error
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 500)
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer)
|
||||
const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
aborted.kind = 'superseded'
|
||||
reject(aborted)
|
||||
}, { once: true })
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
aborted.kind = 'superseded'
|
||||
reject(aborted)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -4948,7 +4964,9 @@ function setAndPersistZoomLevel(window, zoomLevel) {
|
|||
// downgrade or JSON read failure still finds a sane value).
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
|
||||
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {
|
||||
void 0
|
||||
}`
|
||||
)
|
||||
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
|
||||
}
|
||||
|
|
@ -6056,10 +6074,15 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
|
|||
|
||||
if (entry.mode === 'ssh') {
|
||||
const ssh = normalizeSshConfig(entry)
|
||||
|
||||
if (ssh) {
|
||||
if (entry.token && typeof entry.token === 'object') ssh.token = entry.token
|
||||
if (entry.token && typeof entry.token === 'object') {
|
||||
ssh.token = entry.token
|
||||
}
|
||||
|
||||
out[name] = ssh
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -6076,7 +6099,10 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
|
|||
|
||||
if (cleaned.mode === 'local') {
|
||||
const savedSsh = normalizeSshConfig(entry.savedSsh)
|
||||
if (savedSsh) cleaned.savedSsh = savedSsh
|
||||
|
||||
if (savedSsh) {
|
||||
cleaned.savedSsh = savedSsh
|
||||
}
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
|
@ -6205,9 +6231,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
|||
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
const savedMode = key ? scoped?.mode : config.mode
|
||||
const ssh = savedMode === 'ssh' ? normalizeSshConfig(block) : null
|
||||
const savedSsh = savedMode === 'local'
|
||||
? key ? savedProfileSsh(config, key) : normalizeSshConfig(block)
|
||||
: null
|
||||
|
||||
const savedSsh = savedMode === 'local' ? (key ? savedProfileSsh(config, key) : normalizeSshConfig(block)) : null
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
|
||||
|
|
@ -6314,14 +6340,17 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
|
|||
|
||||
if (mode === 'ssh') {
|
||||
const sshBlock = buildSshBlock(input, savedProfileSsh(existing, key) || rawExistingBlock)
|
||||
|
||||
if (key) {
|
||||
const profiles = { ...(existing.profiles || {}), [key]: sshBlock }
|
||||
|
||||
return {
|
||||
mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local',
|
||||
remote: existing.remote || {},
|
||||
profiles
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} }
|
||||
}
|
||||
|
||||
|
|
@ -6335,8 +6364,12 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
|
|||
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) }
|
||||
} else {
|
||||
const localEntry = localProfileEntry(rawExistingBlock)
|
||||
if (localEntry) profiles[key] = localEntry
|
||||
else delete profiles[key]
|
||||
|
||||
if (localEntry) {
|
||||
profiles[key] = localEntry
|
||||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -6371,14 +6404,17 @@ function buildSshBlock(input: any, existingBlock: any = {}) {
|
|||
keyPath: input.sshKeyPath ?? existingBlock.keyPath,
|
||||
remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath
|
||||
})
|
||||
|
||||
if (!merged) {
|
||||
throw new Error('SSH host is required.')
|
||||
}
|
||||
|
||||
// Carry forward an already-adopted dashboard token unless the host changed
|
||||
// (a different host invalidates the old dashboard's token).
|
||||
if (existingBlock.token && existingBlock.host === merged.host) {
|
||||
merged.token = existingBlock.token
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
|
|
@ -6387,7 +6423,15 @@ function buildSshBlock(input: any, existingBlock: any = {}) {
|
|||
// and is shared by the per-profile, env, and global resolution paths. `token`
|
||||
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
|
||||
// for diagnostics ('profile' | 'env' | 'settings').
|
||||
async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost?, remoteKind = 'url', remoteIdentity?) {
|
||||
async function buildRemoteConnection(
|
||||
rawUrl,
|
||||
authMode,
|
||||
token,
|
||||
source,
|
||||
remoteHost?,
|
||||
remoteKind = 'url',
|
||||
remoteIdentity?
|
||||
) {
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
// For token/oauth remotes the meaningful host is the real backend URL; for
|
||||
// SSH remotes the caller passes the entered/resolved host explicitly (the
|
||||
|
|
@ -6483,11 +6527,15 @@ function sshRememberLog(chunk) {
|
|||
async function sshProbeReuseProof(baseUrl, token, spawnNonce) {
|
||||
try {
|
||||
const proof: any = await fetchJson(`${baseUrl}/api/ssh/ownership`, token)
|
||||
|
||||
return proof?.ok === true && proof.sshOwnerNonce === spawnNonce && proof.protocolVersion === 1
|
||||
? 'authenticated-ok'
|
||||
: 'authenticated-stale'
|
||||
} catch (error: any) {
|
||||
if (/^(401|403|404):/.test(String(error?.message || ''))) return 'authenticated-stale'
|
||||
if (/^(401|403|404):/.test(String(error?.message || ''))) {
|
||||
return 'authenticated-stale'
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -6495,13 +6543,19 @@ async function sshProbeReuseProof(baseUrl, token, spawnNonce) {
|
|||
async function teardownSshConnection(profile) {
|
||||
const scope = sshScopeKey(profile)
|
||||
const state = sshConnections.get(scope)
|
||||
if (!state) return
|
||||
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
|
||||
sshConnections.delete(scope)
|
||||
|
||||
for (const [id, info] of [...terminalSessions.entries()]) {
|
||||
if (info.sshScope === scope) {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.localPort && state.remotePort) {
|
||||
await state.ssh.cancelForward(state.localPort, state.remotePort)
|
||||
|
|
@ -6509,6 +6563,7 @@ async function teardownSshConnection(profile) {
|
|||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
try {
|
||||
await state.ssh.close()
|
||||
} catch {
|
||||
|
|
@ -6527,30 +6582,46 @@ function activeSshTerminalTarget() {
|
|||
if (profileSshOverride(config, profile)) {
|
||||
const scope = sshScopeKey(profile)
|
||||
const state = sshConnections.get(scope)
|
||||
|
||||
return state && state.ssh ? { ssh: state.ssh, scope } : 'pending'
|
||||
}
|
||||
|
||||
if (profileRemoteOverride(config, profile)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (config.mode === 'ssh') {
|
||||
const state = sshConnections.get('')
|
||||
|
||||
return state && state.ssh ? { ssh: state.ssh, scope: '' } : 'pending'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function effectiveSshConfigFingerprint(sshConfig) {
|
||||
const ssh = process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh'
|
||||
const ssh =
|
||||
process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh'
|
||||
|
||||
const args = ['-G']
|
||||
if (sshConfig.port) args.push('-p', String(sshConfig.port))
|
||||
if (sshConfig.keyPath) args.push('-i', sshConfig.keyPath)
|
||||
|
||||
if (sshConfig.port) {
|
||||
args.push('-p', String(sshConfig.port))
|
||||
}
|
||||
|
||||
if (sshConfig.keyPath) {
|
||||
args.push('-i', sshConfig.keyPath)
|
||||
}
|
||||
|
||||
args.push('--', sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host)
|
||||
const output = execFileSync(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true })
|
||||
|
||||
return crypto.createHash('sha256').update(output).digest('hex')
|
||||
}
|
||||
|
||||
|
|
@ -6559,6 +6630,7 @@ async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {
|
|||
const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig)
|
||||
const resolvedConfig = { ...sshConfig, effectiveConfigFingerprint }
|
||||
const fingerprint = sshConfigFingerprint(scope, resolvedConfig)
|
||||
|
||||
return sshBootstrapCoordinator.start(scope, fingerprint, lease =>
|
||||
bootstrapSshConnectionInner(profile, resolvedConfig, reuseToken, source, fingerprint, lease)
|
||||
)
|
||||
|
|
@ -6568,18 +6640,28 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
const scope = sshScopeKey(profile)
|
||||
const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
|
||||
const existing = sshConnections.get(scope)
|
||||
if (existing && existing.fingerprint !== fingerprint) await teardownSshConnection(profile)
|
||||
|
||||
if (existing && existing.fingerprint !== fingerprint) {
|
||||
await teardownSshConnection(profile)
|
||||
}
|
||||
|
||||
let ssh = sshConnections.get(scope)?.ssh
|
||||
|
||||
if (ssh && !(await ssh.isAlive())) {
|
||||
try {
|
||||
await ssh.close()
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
ssh = null
|
||||
sshConnections.delete(scope)
|
||||
}
|
||||
|
||||
const created = !ssh
|
||||
|
||||
let removeForceCleanup = () => {}
|
||||
|
||||
if (created) {
|
||||
ssh = new SshConnection(
|
||||
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
|
||||
|
|
@ -6595,6 +6677,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
}
|
||||
|
||||
let result
|
||||
|
||||
try {
|
||||
const platform = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '')
|
||||
const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect
|
||||
|
|
@ -6615,8 +6698,13 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
})
|
||||
} catch (error: any) {
|
||||
if (created) {
|
||||
try { await ssh.close() } catch {}
|
||||
try {
|
||||
await ssh.close()
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(error.message) as any
|
||||
err.sshError = error.kind || 'unknown'
|
||||
err.isSshBootstrap = true
|
||||
|
|
@ -6629,7 +6717,10 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
try {
|
||||
await ssh.cancelForward(result.localPort, result.remotePort)
|
||||
await ssh.close()
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
|
|
@ -6655,8 +6746,15 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
)
|
||||
|
||||
const connection = await buildRemoteConnection(
|
||||
result.baseUrl, 'token', result.token, source, hostLabel, 'ssh', result.ownershipId
|
||||
result.baseUrl,
|
||||
'token',
|
||||
result.token,
|
||||
source,
|
||||
hostLabel,
|
||||
'ssh',
|
||||
result.ownershipId
|
||||
)
|
||||
|
||||
return { ...connection, remoteHermesVersion: result.hermesVersion || '' }
|
||||
}
|
||||
|
||||
|
|
@ -6664,8 +6762,10 @@ function persistSshConnectionToken(profile, source, token) {
|
|||
try {
|
||||
const config = readDesktopConnectionConfig()
|
||||
const encrypted = encryptDesktopSecret(token)
|
||||
|
||||
if (source === 'profile') {
|
||||
const key = connectionScopeKey(profile)
|
||||
|
||||
if (key && config.profiles?.[key]?.mode === 'ssh') {
|
||||
config.profiles[key].token = encrypted
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
|
@ -6693,8 +6793,10 @@ async function resolveRemoteBackend(profile) {
|
|||
// over the env override so an explicitly-configured profile always
|
||||
// reaches its intended backend.
|
||||
const sshOverride = profileSshOverride(config, profile)
|
||||
|
||||
if (sshOverride) {
|
||||
const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token)
|
||||
|
||||
return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')
|
||||
}
|
||||
|
||||
|
|
@ -6704,7 +6806,12 @@ async function resolveRemoteBackend(profile) {
|
|||
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
|
||||
|
||||
return buildRemoteConnection(
|
||||
override.url, override.authMode, token, 'profile', undefined, config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url'
|
||||
override.url,
|
||||
override.authMode,
|
||||
token,
|
||||
'profile',
|
||||
undefined,
|
||||
config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -6726,8 +6833,13 @@ async function resolveRemoteBackend(profile) {
|
|||
// 3. Global remote.
|
||||
if (config.mode === 'ssh') {
|
||||
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
|
||||
if (!ssh) throw new Error('SSH remote mode is selected but no host is configured.')
|
||||
|
||||
if (!ssh) {
|
||||
throw new Error('SSH remote mode is selected but no host is configured.')
|
||||
}
|
||||
|
||||
const reuseToken = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
return bootstrapSshConnection(null, ssh, reuseToken, 'settings')
|
||||
}
|
||||
|
||||
|
|
@ -6740,7 +6852,12 @@ async function resolveRemoteBackend(profile) {
|
|||
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
return buildRemoteConnection(
|
||||
config.remote?.url, authMode, token, 'settings', undefined, config.mode === 'cloud' ? 'cloud' : 'url'
|
||||
config.remote?.url,
|
||||
authMode,
|
||||
token,
|
||||
'settings',
|
||||
undefined,
|
||||
config.mode === 'cloud' ? 'cloud' : 'url'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -6768,6 +6885,7 @@ function globalRemoteActive() {
|
|||
}
|
||||
|
||||
const mode = readDesktopConnectionConfig().mode
|
||||
|
||||
return modeIsRemoteLike(mode) || mode === 'ssh'
|
||||
}
|
||||
|
||||
|
|
@ -6865,19 +6983,29 @@ async function probeRemoteAuthMode(rawUrl) {
|
|||
async function testDesktopConnectionConfig(input: any = {}) {
|
||||
if (input.mode === 'ssh') {
|
||||
const sshConfig = normalizeSshConfig({
|
||||
mode: 'ssh', host: input.sshHost, user: input.sshUser, port: input.sshPort,
|
||||
keyPath: input.sshKeyPath, remoteHermesPath: input.sshRemoteHermesPath
|
||||
mode: 'ssh',
|
||||
host: input.sshHost,
|
||||
user: input.sshUser,
|
||||
port: input.sshPort,
|
||||
keyPath: input.sshKeyPath,
|
||||
remoteHermesPath: input.sshRemoteHermesPath
|
||||
})
|
||||
if (!sshConfig) return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }
|
||||
|
||||
if (!sshConfig) {
|
||||
return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }
|
||||
}
|
||||
|
||||
const ssh = createSshProbeConnection(
|
||||
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
|
||||
{ rememberLog: sshRememberLog }
|
||||
)
|
||||
|
||||
try {
|
||||
// One bounded retry on TIMEOUT only: a cold Windows backend's first
|
||||
// PowerShell exec can exceed the budget (observed live), and a timeout is
|
||||
// indeterminate — unlike auth/host-key/unreachable, which are verdicts.
|
||||
let attempt = 0
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await ssh.open()
|
||||
|
|
@ -6885,6 +7013,7 @@ async function testDesktopConnectionConfig(input: any = {}) {
|
|||
let hermesPath
|
||||
let hermesVersion
|
||||
let supported
|
||||
|
||||
if (platform.os === 'Windows') {
|
||||
const runtime = platform
|
||||
hermesPath = runtime.hermesPath
|
||||
|
|
@ -6896,28 +7025,43 @@ async function testDesktopConnectionConfig(input: any = {}) {
|
|||
hermesVersion = await remoteLifecycle.probeHermesVersion(ssh, hermesPath)
|
||||
supported = await remoteLifecycle.remoteSupportsSshOwnership(ssh, hermesPath)
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return { reachable: false, sshError: 'update-required', error: 'Update Hermes on the remote host before connecting with Desktop SSH.' }
|
||||
return {
|
||||
reachable: false,
|
||||
sshError: 'update-required',
|
||||
error: 'Update Hermes on the remote host before connecting with Desktop SSH.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reachable: true, sshError: null, error: null,
|
||||
reachable: true,
|
||||
sshError: null,
|
||||
error: null,
|
||||
remotePlatform: `${platform.os}/${platform.arch}`,
|
||||
remoteHermesPath: hermesPath, remoteHermesVersion: hermesVersion,
|
||||
remoteHermesPath: hermesPath,
|
||||
remoteHermesVersion: hermesVersion,
|
||||
host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.kind === 'timeout' && attempt === 0) {
|
||||
attempt += 1
|
||||
sshRememberLog('[ssh] test probe timed out once; retrying')
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { reachable: false, sshError: error.kind || 'unknown', error: error.message }
|
||||
} finally {
|
||||
try { await ssh.close() } catch {}
|
||||
try {
|
||||
await ssh.close()
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8183,6 +8327,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => {
|
|||
try {
|
||||
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 10_000 })
|
||||
revalidateTimeoutStreak = 0
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
} catch (error: any) {
|
||||
// A timeout is indeterminate (slow VM, idle tunnel re-establishing) — not
|
||||
|
|
@ -8190,20 +8335,26 @@ ipcMain.handle('hermes:connection:revalidate', async () => {
|
|||
// bounded streak of timeouts, so one slow answer can't destroy a healthy
|
||||
// transport and cancel in-flight bootstraps.
|
||||
const refused = /ECONNREFUSED/i.test(String(error?.code || error?.cause?.code || error?.message || ''))
|
||||
|
||||
if (!refused) {
|
||||
revalidateTimeoutStreak += 1
|
||||
|
||||
if (revalidateTimeoutStreak < 3) {
|
||||
rememberLog(`Remote liveness probe indeterminate (${revalidateTimeoutStreak}/3); keeping connection.`)
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
}
|
||||
|
||||
revalidateTimeoutStreak = 0
|
||||
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
|
||||
|
||||
if (conn.remoteKind === 'ssh') {
|
||||
const profile = primaryProfileKey()
|
||||
await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile))
|
||||
await teardownSshConnection(profile)
|
||||
}
|
||||
|
||||
resetHermesConnection()
|
||||
|
||||
return { ok: true, rebuilt: true }
|
||||
|
|
@ -8438,28 +8589,44 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
|||
ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() }))
|
||||
ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => {
|
||||
const value = String(host || '').trim()
|
||||
if (!value) throw new Error('SSH host is required.')
|
||||
const ssh = process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh'
|
||||
|
||||
if (!value) {
|
||||
throw new Error('SSH host is required.')
|
||||
}
|
||||
|
||||
const ssh =
|
||||
process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh'
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(ssh, ['-G', '--', value], hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'] }))
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
reject(new Error('SSH config resolution timed out.'))
|
||||
}, 10_000)
|
||||
child.stdout.on('data', chunk => { stdout += String(chunk) })
|
||||
child.stderr.on('data', chunk => { stderr += String(chunk) })
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += String(chunk)
|
||||
})
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
child.once('error', error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.once('close', code => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) reject(new Error(stderr.trim() || 'Could not resolve SSH host.'))
|
||||
else resolve(parseSshGOutput(stdout))
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || 'Could not resolve SSH host.'))
|
||||
} else {
|
||||
resolve(parseSshGOutput(stdout))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -9565,9 +9732,12 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
|||
const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey()))
|
||||
const remote = Boolean(sshTarget)
|
||||
const remoteState = remote ? sshConnections.get(sshTarget.scope) : null
|
||||
const remoteCommand = remoteState?.remotePlatform === 'Windows'
|
||||
? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())
|
||||
: undefined
|
||||
|
||||
const remoteCommand =
|
||||
remoteState?.remotePlatform === 'Windows'
|
||||
? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())
|
||||
: undefined
|
||||
|
||||
const ptyProcess = remote
|
||||
? nodePty.spawn(
|
||||
process.platform === 'win32'
|
||||
|
|
@ -10132,10 +10302,12 @@ app.on('before-quit', event => {
|
|||
event.preventDefault()
|
||||
sshBootstrapCoordinator.cancelAll()
|
||||
const scopes = [...sshConnections.keys()]
|
||||
|
||||
const pending = Promise.allSettled([
|
||||
...scopes.map(scope => teardownSshConnection(scope || null)),
|
||||
...sshBootstrapCoordinator.promises()
|
||||
])
|
||||
|
||||
void Promise.race([pending, new Promise(resolve => setTimeout(resolve, 4_000))]).then(async () => {
|
||||
await sshBootstrapCoordinator.forceCleanupAll()
|
||||
sshQuitTeardownDone = true
|
||||
|
|
|
|||
|
|
@ -1,28 +1,29 @@
|
|||
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,
|
||||
locateHermes,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
lockfilePath,
|
||||
openForward,
|
||||
ownershipDirectory,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
PROTOCOL_VERSION,
|
||||
readLockfile,
|
||||
READY_RE,
|
||||
remotePidAlive,
|
||||
remoteSupportsSshOwnership,
|
||||
scrapeReadyPort,
|
||||
spawnRemoteDashboard,
|
||||
spawnLogPath,
|
||||
spawnRemoteDashboard,
|
||||
validateRemotePath,
|
||||
writeLockfile
|
||||
} from './remote-lifecycle'
|
||||
|
|
@ -52,24 +53,31 @@ function ownedLock(over: any = {}) {
|
|||
// [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
|
||||
|
||||
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')
|
||||
|
|
@ -82,11 +90,13 @@ test('locateHermes throws (no silent fallback) when an EXPLICIT path is not exec
|
|||
[/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
|
||||
}
|
||||
)
|
||||
|
|
@ -97,6 +107,7 @@ test('locateHermes falls back to the login-shell command -v probe', async () =>
|
|||
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
|
||||
])
|
||||
|
||||
assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes')
|
||||
})
|
||||
|
||||
|
|
@ -106,6 +117,7 @@ test('locateHermes canonicalizes an installer wrapper to its executable target',
|
|||
[/\[ -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')
|
||||
})
|
||||
|
||||
|
|
@ -115,6 +127,7 @@ test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe
|
|||
[/command -v hermes/, ''],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
|
||||
])
|
||||
|
||||
assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes')
|
||||
})
|
||||
|
||||
|
|
@ -130,6 +143,7 @@ test('locateHermes throws a hermes-not-found error with an install hint', async
|
|||
(err: any) => {
|
||||
assert.equal(err.kind, 'hermes-not-found')
|
||||
assert.match(err.message, /install/i)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -140,10 +154,13 @@ test('locateHermes uses a login shell for the command -v probe', async () => {
|
|||
[/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)')
|
||||
})
|
||||
|
||||
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']])), {
|
||||
|
|
@ -161,13 +178,12 @@ test('probeRemotePlatform rejects unsupported remote platforms', async () => {
|
|||
() => 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`)
|
||||
|
|
@ -215,23 +231,40 @@ test('metadata and process proof transport failures remain indeterminate', async
|
|||
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"/, 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) })
|
||||
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) })
|
||||
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/)
|
||||
|
|
@ -263,7 +296,14 @@ test('spawnRemoteDashboard returns exact ownership artifacts', async () => {
|
|||
[/printf '%s\\n'/, ''],
|
||||
[/setsid|nohup/, '4242\n']
|
||||
])
|
||||
const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID })
|
||||
|
||||
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))
|
||||
|
|
@ -276,6 +316,7 @@ test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)',
|
|||
[/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/)
|
||||
|
|
@ -294,10 +335,12 @@ test('spawnRemoteDashboard rejects when no pid is returned', async () => {
|
|||
[/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
|
||||
}
|
||||
)
|
||||
|
|
@ -318,20 +361,25 @@ test('scrapeReadyPort times out and reports a dead spawn', async () => {
|
|||
() => 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 }),
|
||||
() =>
|
||||
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,
|
||||
|
|
@ -361,6 +409,7 @@ test('connect() spawns fresh when there is no lockfile, adopts the served token'
|
|||
[/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)
|
||||
|
|
@ -374,6 +423,7 @@ test('connect() spawns fresh when there is no lockfile, adopts 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'],
|
||||
|
|
@ -381,6 +431,7 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async
|
|||
[/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)
|
||||
|
|
@ -392,6 +443,7 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async
|
|||
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'],
|
||||
|
|
@ -404,13 +456,21 @@ test('connect() respawns when the lockfile hermesPath differs from the resolved
|
|||
[/setsid/, '890\n'],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' }))
|
||||
|
||||
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')
|
||||
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,
|
||||
|
|
@ -418,6 +478,7 @@ test('connect() respawns when the lockfile protocolVersion is incompatible', asy
|
|||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
|
|
@ -430,6 +491,7 @@ test('connect() respawns when the lockfile protocolVersion is incompatible', asy
|
|||
[/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)
|
||||
|
|
@ -437,6 +499,7 @@ test('connect() respawns when the lockfile protocolVersion is incompatible', asy
|
|||
|
||||
test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {
|
||||
const writes: string[] = []
|
||||
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
|
|
@ -452,10 +515,12 @@ test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfil
|
|||
/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}`))
|
||||
|
|
@ -464,6 +529,7 @@ test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfil
|
|||
|
||||
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'],
|
||||
|
|
@ -476,16 +542,20 @@ test('connect() respawns when the lockfile pid is dead (killed dashboard)', asyn
|
|||
[/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')
|
||||
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,
|
||||
|
|
@ -493,6 +563,7 @@ test('connect() respawns when the dashboard is wedged (alive pid, probe fails)',
|
|||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
|
|
@ -505,9 +576,15 @@ test('connect() respawns when the dashboard is wedged (alive pid, probe fails)',
|
|||
[/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' })
|
||||
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)
|
||||
|
|
@ -519,6 +596,7 @@ test('connect() aborts on an unsupported remote platform before doing anything e
|
|||
() => connect(connectDeps(ssh)),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -528,13 +606,21 @@ test('connect() aborts on an unsupported remote platform before doing anything e
|
|||
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)
|
||||
|
||||
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)
|
||||
|
|
@ -543,6 +629,7 @@ test('openForward retries bind collisions only', async () => {
|
|||
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'],
|
||||
|
|
@ -550,14 +637,22 @@ test('connect() preserves an owned backend when a reuse transport throws', async
|
|||
[/kill -0/, 'ALIVE'],
|
||||
[/print\("OWNED"/, 'OWNED\n']
|
||||
])
|
||||
await assert.rejects(() => connect(connectDeps(ssh, {
|
||||
reuseToken,
|
||||
forward: async () => { throw new Error('network reset') }
|
||||
})), /network reset/)
|
||||
|
||||
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'))
|
||||
|
|
@ -624,6 +719,7 @@ test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is
|
|||
logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),
|
||||
spawnNonce: SPAWN_NONCE
|
||||
})
|
||||
|
||||
assert.match(cmd, /--ssh-session-token-file/)
|
||||
assert.match(cmd, /\.hermes\/desktop-ssh\//)
|
||||
})
|
||||
|
|
@ -638,11 +734,13 @@ test('buildSpawnCommand always uses serve, never dashboard', () => {
|
|||
|
||||
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/
|
||||
|
|
@ -653,24 +751,50 @@ test('spawnRemoteDashboard removes a token file when upload reporting fails', as
|
|||
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 ''
|
||||
|
||||
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
|
||||
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')
|
||||
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}`)
|
||||
}
|
||||
|
|
@ -678,19 +802,37 @@ test('spawnRemoteDashboard streams the token over stdin, not argv/env', async ()
|
|||
|
||||
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 ''
|
||||
|
||||
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
|
||||
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')
|
||||
|
|
@ -728,6 +870,7 @@ test('readLockfile accepts a complete owned lock', async () => {
|
|||
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'],
|
||||
|
|
@ -735,21 +878,21 @@ test('connect() reuse path does not write a token file', async () => {
|
|||
[/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')
|
||||
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']
|
||||
])
|
||||
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
|
||||
}
|
||||
)
|
||||
|
|
@ -784,6 +927,7 @@ test('connect removes the token file when a fresh backend fails after returning
|
|||
[/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)))
|
||||
})
|
||||
|
|
@ -791,6 +935,7 @@ test('connect removes the token file when a fresh backend fails after returning
|
|||
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'],
|
||||
|
|
@ -798,10 +943,19 @@ test('connect preserves an exact-owned backend when reuse proof transport fails'
|
|||
[/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')
|
||||
|
||||
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)))
|
||||
})
|
||||
|
|
@ -809,6 +963,7 @@ test('connect preserves an exact-owned backend when reuse proof transport fails'
|
|||
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'],
|
||||
|
|
@ -821,28 +976,38 @@ test('connect replaces an exact-owned backend only after authenticated stale pro
|
|||
[/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'
|
||||
}))
|
||||
|
||||
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'
|
||||
}
|
||||
]])
|
||||
|
||||
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/)
|
||||
|
|
|
|||
|
|
@ -45,18 +45,30 @@ function mintToken() {
|
|||
// 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)
|
||||
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.')
|
||||
|
||||
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.')
|
||||
|
||||
if (!/^[0-9a-f]{16}$/.test(value)) {
|
||||
throw new Error('SSH spawn nonce is invalid.')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
@ -79,16 +91,34 @@ function shq(value) {
|
|||
|
||||
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
|
||||
|
||||
if (!s) {
|
||||
throw new Error('Remote path must not be empty.')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths
|
||||
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))
|
||||
|
||||
if (p === '~') {
|
||||
return '"$HOME"'
|
||||
}
|
||||
|
||||
if (p.startsWith('~/')) {
|
||||
return '"$HOME"' + shq(p.slice(1))
|
||||
}
|
||||
|
||||
return shq(p)
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +142,9 @@ async function locateHermes(ssh, remoteHermesPath) {
|
|||
' break\n' +
|
||||
'except (OSError,ValueError):pass\n' +
|
||||
'print(out)'
|
||||
|
||||
const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim()
|
||||
|
||||
return resolved || candidate
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +152,7 @@ async function locateHermes(ssh, remoteHermesPath) {
|
|||
try {
|
||||
validateRemotePath(candidate)
|
||||
const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim()
|
||||
|
||||
return ok === 'OK'
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -130,24 +163,29 @@ async function locateHermes(ssh, 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')
|
||||
|
|
@ -155,7 +193,10 @@ async function locateHermes(ssh, remoteHermesPath) {
|
|||
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (await isExecutable(candidate)) {
|
||||
return resolveLauncher(candidate)
|
||||
}
|
||||
|
|
@ -166,6 +207,7 @@ async function locateHermes(ssh, remoteHermesPath) {
|
|||
'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
|
||||
}
|
||||
|
|
@ -176,6 +218,7 @@ async function locateHermes(ssh, remoteHermesPath) {
|
|||
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 ''
|
||||
|
|
@ -186,13 +229,16 @@ 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, macOS, and Windows remote hosts.`
|
||||
)
|
||||
|
||||
err.kind = 'unsupported-platform'
|
||||
throw err
|
||||
}
|
||||
|
||||
return { os: osName, arch }
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +248,7 @@ async function probeRemotePlatform(ssh) {
|
|||
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.')
|
||||
|
|
@ -214,6 +261,7 @@ async function probeRemoteHermesHome(ssh) {
|
|||
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) {
|
||||
|
|
@ -222,30 +270,60 @@ async function readLockfile(ssh, ownershipId) {
|
|||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return null
|
||||
|
||||
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(pid) || pid <= 0 || pid > 4194304) {
|
||||
return null
|
||||
}
|
||||
|
||||
// port 0 = spawn-in-progress record (written before readiness); valid
|
||||
// ownership proof for cleanup, but never reusable.
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -263,6 +341,7 @@ async function writeLockfile(ssh, ownershipId, lock) {
|
|||
|
||||
async function removeLockfile(ssh, ownershipId) {
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(lpath)}`)
|
||||
} catch {
|
||||
|
|
@ -271,9 +350,13 @@ async function removeLockfile(ssh, ownershipId) {
|
|||
}
|
||||
|
||||
async function remotePidAlive(ssh, pid) {
|
||||
if (!pid || !Number.isInteger(Number(pid))) return false
|
||||
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.')
|
||||
|
|
@ -286,7 +369,10 @@ async function remotePidAlive(ssh, pid) {
|
|||
// 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
|
||||
if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const script =
|
||||
'import os,shlex,subprocess,sys\n' +
|
||||
|
|
@ -308,7 +394,9 @@ async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') {
|
|||
' 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.')
|
||||
|
|
@ -320,13 +408,16 @@ async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') {
|
|||
|
||||
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
||||
async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) {
|
||||
if (pidAlive && lock && await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)) {
|
||||
if (pidAlive && 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()
|
||||
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.')
|
||||
|
|
@ -335,10 +426,17 @@ async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : ''
|
||||
|
||||
if (lock?.logPath === expectedLogPath) {
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`) } catch {}
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
await removeLockfile(ssh, ownershipId)
|
||||
}
|
||||
|
||||
|
|
@ -354,6 +452,7 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
|
|||
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 $!`)}`
|
||||
|
|
@ -362,36 +461,48 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
|
|||
|
||||
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')
|
||||
|
||||
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
|
||||
|
|
@ -403,6 +514,7 @@ async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownership
|
|||
'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
|
||||
}
|
||||
|
|
@ -441,36 +553,63 @@ async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownership
|
|||
' 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 {}
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
let out
|
||||
|
||||
try {
|
||||
out = await ssh.exec(
|
||||
buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath })
|
||||
)
|
||||
out = await ssh.exec(buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath }))
|
||||
} catch (error) {
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {}
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
const pid = parseInt(String(out || '').trim().split('\n').pop(), 10)
|
||||
|
||||
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 {}
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
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
|
||||
if (typeof deps.cancelForward !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.cancelForward(localPort, remotePort)
|
||||
} catch {
|
||||
|
|
@ -492,16 +631,23 @@ function isForwardBindCollision(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
|
||||
|
||||
if (!isForwardBindCollision(error) || attempt === attempts - 1) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
|
|
@ -516,11 +662,13 @@ async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, s
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -548,23 +696,36 @@ async function connect(deps) {
|
|||
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
||||
log(`located hermes at ${hermesPath}`)
|
||||
const hermesVersion = await probeHermesVersion(ssh, hermesPath)
|
||||
if (hermesVersion) log(`remote hermes version: ${hermesVersion}`)
|
||||
|
||||
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 = pidAlive && await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)
|
||||
const reusable = pidAlive && owned && lock.port > 0 && Boolean(reuseToken) &&
|
||||
const owned = pidAlive && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))
|
||||
|
||||
const reusable =
|
||||
pidAlive &&
|
||||
owned &&
|
||||
lock.port > 0 &&
|
||||
Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === hermesPath && lock.hermesHome === hermesHome
|
||||
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) {
|
||||
|
|
@ -573,16 +734,24 @@ async function connect(deps) {
|
|||
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'
|
||||
adoptServedToken,
|
||||
baseUrl,
|
||||
reuseToken,
|
||||
ssh,
|
||||
lock.pid,
|
||||
'reused remote dashboard'
|
||||
)
|
||||
|
||||
assertNotAborted(signal)
|
||||
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
|
|
@ -615,12 +784,14 @@ async function connect(deps) {
|
|||
|
||||
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 = {
|
||||
|
|
@ -636,8 +807,10 @@ async function connect(deps) {
|
|||
protocolVersion: PROTOCOL_VERSION,
|
||||
startedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let localPort = 0
|
||||
let remotePort = 0
|
||||
|
||||
try {
|
||||
// Write the ownership record IMMEDIATELY (port=0): a supersede between
|
||||
// spawn and readiness whose cleanup cannot reach the box must not leave a
|
||||
|
|
@ -659,9 +832,8 @@ async function connect(deps) {
|
|||
await waitForHermes(baseUrl, spawnToken)
|
||||
assertNotAborted(signal)
|
||||
|
||||
const token = await adoptOwnedServedToken(
|
||||
adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard'
|
||||
)
|
||||
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 })
|
||||
|
|
@ -683,44 +855,52 @@ async function connect(deps) {
|
|||
logPath
|
||||
}
|
||||
} catch (error) {
|
||||
if (localPort && remotePort) await cancelForwardSafe(deps, localPort, remotePort)
|
||||
try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {}
|
||||
if (localPort && remotePort) {
|
||||
await cancelForwardSafe(deps, localPort, remotePort)
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
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,
|
||||
DEFAULT_READY_TIMEOUT_MS,
|
||||
expandRemotePath,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
lockfilePath,
|
||||
ownershipDirectory,
|
||||
spawnLogPath,
|
||||
isForwardBindCollision,
|
||||
openForward,
|
||||
locateHermes,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
lockfilePath,
|
||||
mintToken,
|
||||
openForward,
|
||||
ownershipDirectory,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
probeHermesVersion,
|
||||
probeRemoteHermesHome,
|
||||
remoteSupportsSshOwnership,
|
||||
probeRemotePlatform,
|
||||
PROTOCOL_VERSION,
|
||||
readLockfile,
|
||||
READY_RE,
|
||||
REMOTE_LOCK_DIR,
|
||||
remotePidAlive,
|
||||
remoteSupportsSshOwnership,
|
||||
removeLockfile,
|
||||
scrapeReadyPort,
|
||||
shq,
|
||||
spawnLogPath,
|
||||
spawnRemoteDashboard,
|
||||
SUPPORTED_REMOTE_OS,
|
||||
validateRemotePath,
|
||||
writeLockfile
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
|
|
@ -6,10 +7,12 @@ import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstra
|
|||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
|
||||
const promise = new Promise((ok, fail) => {
|
||||
resolve = ok
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
|
|
@ -18,9 +21,18 @@ const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHe
|
|||
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' })) {
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
|
|
@ -28,14 +40,19 @@ 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')
|
||||
|
|
@ -47,6 +64,7 @@ test('changed fingerprint waits for old rollback before starting', async () => {
|
|||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
let oldLease
|
||||
|
||||
const oldPromise = coordinator.start('', 'old', async lease => {
|
||||
oldLease = lease
|
||||
events.push('old-start')
|
||||
|
|
@ -54,12 +72,16 @@ test('changed fingerprint waits for old rollback before starting', async () => {
|
|||
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'])
|
||||
|
|
@ -73,10 +95,14 @@ 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++ })
|
||||
lease.onForceCleanup(async () => {
|
||||
cleaned++
|
||||
})
|
||||
await gate.promise
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await coordinator.forceCleanupAll()
|
||||
assert.equal(cleaned, 1)
|
||||
|
|
@ -87,10 +113,14 @@ test('forceCleanupAll runs registered pending resource cleanup', async () => {
|
|||
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()
|
||||
}))
|
||||
|
||||
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())
|
||||
|
|
@ -102,18 +132,26 @@ 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 })
|
||||
|
||||
const drain = coordinator.cancelAndWait('first').then(() => {
|
||||
drained = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.equal(drained, false)
|
||||
firstGate.resolve()
|
||||
|
|
@ -128,17 +166,22 @@ test('a generation started during cancelAndWait cannot run before the drain comp
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
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')
|
||||
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() {
|
||||
|
|
@ -13,17 +25,23 @@ function createBootstrapCoordinator() {
|
|||
|
||||
function start(scope, fingerprint, run) {
|
||||
const current = pending.get(scope)
|
||||
if (current?.fingerprint === fingerprint) return current.promise
|
||||
|
||||
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,
|
||||
|
|
@ -35,20 +53,30 @@ function createBootstrapCoordinator() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -58,20 +86,33 @@ function createBootstrapCoordinator() {
|
|||
|
||||
async function cancelAndWait(scope) {
|
||||
let release
|
||||
const barrier = new Promise<void>(resolve => { release = resolve })
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
if (drains.get(scope) === barrier) {
|
||||
drains.delete(scope)
|
||||
}
|
||||
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
for (const entry of active) entry.controller.abort()
|
||||
for (const entry of active) {
|
||||
entry.controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
async function forceCleanupAll() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config'
|
||||
|
|
@ -12,6 +13,7 @@ test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patte
|
|||
'# Host commented-out',
|
||||
'host lower-case'
|
||||
].join('\n')
|
||||
|
||||
assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case'])
|
||||
})
|
||||
|
||||
|
|
@ -31,10 +33,12 @@ test('collectSshConfigHosts follows Include directives (read-only)', () => {
|
|||
'/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())
|
||||
})
|
||||
|
||||
|
|
@ -47,10 +51,12 @@ test('collectSshConfigHosts does not loop on a self-include cycle', () => {
|
|||
'/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'])
|
||||
})
|
||||
|
||||
|
|
@ -60,12 +66,14 @@ test('collectSshConfigHosts expands globbed includes via injected globSync', ()
|
|||
'/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())
|
||||
})
|
||||
|
||||
|
|
@ -78,6 +86,7 @@ test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
|
|||
'identityfile ~/.ssh/id_ed25519',
|
||||
'forwardagent no'
|
||||
].join('\n')
|
||||
|
||||
assert.deepEqual(parseSshGOutput(out), {
|
||||
hostname: '10.0.0.5',
|
||||
user: 'alice',
|
||||
|
|
|
|||
|
|
@ -14,35 +14,58 @@ 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
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const m = /^host\s+(.+)$/i.exec(line)
|
||||
if (!m) continue
|
||||
|
||||
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
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const m = /^include\s+(.+)$/i.exec(line)
|
||||
if (!m) continue
|
||||
|
||||
if (!m) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const token of m[1].split(/\s+/)) {
|
||||
if (token) includes.push(token)
|
||||
if (token) {
|
||||
includes.push(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return includes
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +79,7 @@ function collectSshConfigHosts(rootPath = '', deps: any = {}) {
|
|||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const homeDir = deps.homeDir || os.homedir()
|
||||
const root = rootPath || path.join(homeDir, '.ssh', 'config')
|
||||
const sshDir = path.join(homeDir, '.ssh')
|
||||
|
|
@ -65,25 +89,40 @@ function collectSshConfigHosts(rootPath = '', deps: any = {}) {
|
|||
const visited = new Set()
|
||||
|
||||
const resolveIncludePath = token => {
|
||||
if (token.startsWith('~/')) return path.join(homeDir, token.slice(2))
|
||||
if (path.isAbsolute(token)) return 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
|
||||
if (depth > 8 || visited.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
visited.add(filePath)
|
||||
const text = readFile(filePath)
|
||||
if (text == null) return
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -91,6 +130,7 @@ function collectSshConfigHosts(rootPath = '', deps: any = {}) {
|
|||
}
|
||||
|
||||
walk(root, 0)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -101,18 +141,34 @@ function parseSshGOutput(text) {
|
|||
port: null,
|
||||
identityFile: null
|
||||
}
|
||||
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sp = line.indexOf(' ')
|
||||
if (sp === -1) continue
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ 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,
|
||||
|
|
@ -20,13 +19,14 @@ import {
|
|||
hostArgs,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
stopTunnelChild,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
sshErrorMessage,
|
||||
stopTunnelChild,
|
||||
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)
|
||||
|
|
@ -56,7 +56,6 @@ test('redactSecrets handles null/undefined and non-secret text untouched', () =>
|
|||
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')
|
||||
|
|
@ -81,7 +80,6 @@ test('controlSocketPath default base stays under sun_path even with the temp-lis
|
|||
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(' ')
|
||||
|
|
@ -163,9 +161,11 @@ test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
|
|||
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('@@@@ 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)
|
||||
})
|
||||
|
|
@ -186,27 +186,38 @@ test('sshErrorMessage gives actionable guidance for auth and host-key-change', (
|
|||
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))
|
||||
|
||||
if (stdout) {
|
||||
child.stdout.emit('data', Buffer.from(stdout))
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
child.stderr.emit('data', Buffer.from(stderr))
|
||||
}
|
||||
|
||||
child.emit('close', code)
|
||||
})
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
|
|
@ -215,13 +226,17 @@ function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -229,11 +244,17 @@ 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' }
|
||||
|
||||
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')
|
||||
|
|
@ -241,10 +262,13 @@ test('open() establishes the master when not already alive', async () => {
|
|||
|
||||
test('open() is a no-op when the master is already alive and execs verify', async () => {
|
||||
const ops: string[] = []
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master')
|
||||
|
||||
return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')
|
||||
|
|
@ -255,29 +279,57 @@ test('open() evicts a wedged master (check passes, exec hangs) and dials fresh',
|
|||
// every exec through it hangs. open() must verify, evict (-O exit), and
|
||||
// establish a fresh master instead of trusting the corpse.
|
||||
const ops: string[] = []
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) { ops.push('check'); return { code: 0 } }
|
||||
if (args.includes('exit 0')) { ops.push('verify'); return { hang: true } }
|
||||
if (args.includes('-O')) { ops.push('evict'); return { code: 0 } }
|
||||
ops.push('master'); return { code: 0 }
|
||||
if (args.includes('check')) {
|
||||
ops.push('check')
|
||||
|
||||
return { code: 0 }
|
||||
}
|
||||
|
||||
if (args.includes('exit 0')) {
|
||||
ops.push('verify')
|
||||
|
||||
return { hang: true }
|
||||
}
|
||||
|
||||
if (args.includes('-O')) {
|
||||
ops.push('evict')
|
||||
|
||||
return { code: 0 }
|
||||
}
|
||||
|
||||
ops.push('master')
|
||||
|
||||
return { code: 0 }
|
||||
})
|
||||
const conn = new SshConnection(
|
||||
{ host: 'box', user: 'me' },
|
||||
{ spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 }
|
||||
)
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 })
|
||||
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check', 'verify', 'evict', 'master'],
|
||||
'wedged master: verified, evicted, then a fresh master is dialed')
|
||||
assert.deepEqual(
|
||||
ops,
|
||||
['check', 'verify', 'evict', 'master'],
|
||||
'wedged master: verified, evicted, then a fresh master is dialed'
|
||||
)
|
||||
})
|
||||
|
||||
test('close() removes the control socket when -O exit fails', async () => {
|
||||
const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`)
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) return { code: 255 } // not alive → open dials master
|
||||
if (args.includes('-M')) return { code: 0 }
|
||||
if (args.includes('check')) {
|
||||
return { code: 255 }
|
||||
} // not alive → open dials master
|
||||
|
||||
if (args.includes('-M')) {
|
||||
return { code: 0 }
|
||||
}
|
||||
|
||||
return { code: 255, stderr: 'mux: master gone' } // -O exit fails
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
await conn.open()
|
||||
fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file
|
||||
|
|
@ -291,6 +343,7 @@ test('open() creates the control-socket directory if it does not exist', async (
|
|||
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')
|
||||
|
|
@ -305,15 +358,20 @@ test('open() creates the control-socket directory if it does not exist', async (
|
|||
|
||||
test('open() surfaces a classified auth error', async () => {
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) return { code: 255 }
|
||||
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
|
||||
}
|
||||
)
|
||||
|
|
@ -330,6 +388,7 @@ test('exec() returns stdout on success and rejects (classified) on failure', asy
|
|||
() => conn2.exec('uname -s'),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -342,6 +401,7 @@ test('exec() treats a hung ssh as a timeout (half-open connection)', async () =>
|
|||
() => conn.exec('uname -s', { timeoutMs: 30 }),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -360,23 +420,27 @@ test('forward() issues -O forward with a loopback-bound -L spec', async () => {
|
|||
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}`)
|
||||
}
|
||||
|
|
@ -387,7 +451,10 @@ test('no-mux: open() verifies auth with a one-shot exec, no -M master', async ()
|
|||
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] === 'exit 0'), 'liveness/openness via one-shot exec')
|
||||
assert.ok(
|
||||
spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'),
|
||||
'liveness/openness via one-shot exec'
|
||||
)
|
||||
})
|
||||
|
||||
test('SSH probe never creates or closes a ControlMaster', async () => {
|
||||
|
|
@ -414,26 +481,37 @@ test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it',
|
|||
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))
|
||||
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')
|
||||
|
|
@ -453,15 +531,19 @@ test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', as
|
|||
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')
|
||||
})
|
||||
|
|
@ -471,11 +553,14 @@ test('no-mux: an unrelated listener cannot mask a delayed bind failure', async (
|
|||
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`))
|
||||
|
|
@ -483,8 +568,10 @@ test('no-mux: an unrelated listener cannot mask a delayed bind failure', async (
|
|||
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()
|
||||
|
|
@ -496,18 +583,27 @@ test('no-mux: tunnel death after readiness makes the connection unhealthy', asyn
|
|||
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))
|
||||
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)
|
||||
|
|
@ -516,7 +612,6 @@ test('no-mux: tunnel death after readiness makes the connection unhealthy', asyn
|
|||
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)
|
||||
|
|
@ -600,18 +695,24 @@ test('hostArgs accepts valid key paths', () => {
|
|||
|
||||
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) }
|
||||
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')
|
||||
})
|
||||
|
|
@ -629,7 +730,10 @@ test('open() rejects a control-dir that is a symlink', async () => {
|
|||
})
|
||||
|
||||
test('open() enforces 0700 on an existing control dir with lax permissions', async () => {
|
||||
if (process.platform === 'win32') return
|
||||
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 })
|
||||
|
|
@ -647,49 +751,74 @@ test('control socket identity separates installation scope and key identity', ()
|
|||
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'
|
||||
}))
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
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()
|
||||
|
|
@ -715,15 +844,22 @@ test('failed ControlMaster close disowns the master instead of retrying it', asy
|
|||
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 })
|
||||
|
||||
const stopping = stopTunnelChild(child).then(() => {
|
||||
stopped = true
|
||||
})
|
||||
|
||||
assert.equal(stopped, false)
|
||||
await stopping
|
||||
assert.equal(stopped, true)
|
||||
|
|
|
|||
|
|
@ -41,35 +41,46 @@ const DEFAULT_EXEC_TIMEOUT_MS = 20_000
|
|||
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
|
||||
const CONTROL_PERSIST_SECONDS = 300
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- deliberately reject control chars in ssh targets
|
||||
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 (!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}").`)
|
||||
}
|
||||
|
|
@ -86,9 +97,11 @@ const _REDACTIONS: Array<[RegExp, string]> = [
|
|||
|
||||
function redactSecrets(text) {
|
||||
let out = String(text == null ? '' : text)
|
||||
|
||||
for (const [re, repl] of _REDACTIONS) {
|
||||
out = out.replace(re, repl)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -107,8 +120,19 @@ function redactSecrets(text) {
|
|||
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 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`)
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +142,7 @@ function defaultControlDir() {
|
|||
if (process.platform === 'win32') {
|
||||
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
|
||||
}
|
||||
|
||||
|
|
@ -128,28 +153,44 @@ function defaultControlDir() {
|
|||
// 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}`]
|
||||
? [
|
||||
'-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}`
|
||||
'-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
|
||||
}
|
||||
|
||||
|
|
@ -158,18 +199,40 @@ function target(user, host) {
|
|||
}
|
||||
|
||||
function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) {
|
||||
return [...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host), remoteCommand]
|
||||
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)]
|
||||
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)]
|
||||
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
|
||||
|
|
@ -179,18 +242,29 @@ function buildMasterArgs(conn, connectTimeoutMs?) {
|
|||
// 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?, remoteCommand?) {
|
||||
const args = ['-tt', ...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host)]
|
||||
const args = [
|
||||
'-tt',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
'--',
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
|
||||
if (remoteCommand) {
|
||||
args.push(remoteCommand)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
const cwd = String(remoteCwd || '').trim()
|
||||
|
||||
if (cwd) {
|
||||
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
|
||||
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
|
||||
} else {
|
||||
args.push('exec "$SHELL" -l')
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
|
|
@ -214,20 +288,37 @@ const SSH_ERROR = {
|
|||
// 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)) {
|
||||
|
||||
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)) {
|
||||
|
||||
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)) {
|
||||
|
||||
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 (
|
||||
|
|
@ -236,6 +327,7 @@ function sshErrorMessage(kind, conn, stderr?) {
|
|||
`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 ` +
|
||||
|
|
@ -243,10 +335,13 @@ function sshErrorMessage(kind, conn, stderr?) {
|
|||
`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'}`
|
||||
}
|
||||
|
|
@ -260,10 +355,12 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -276,13 +373,18 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData
|
|||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
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)
|
||||
|
|
@ -295,13 +397,19 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData
|
|||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', error => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', code => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ code, stdout, stderr })
|
||||
|
|
@ -310,24 +418,35 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData
|
|||
}
|
||||
|
||||
function stopTunnelChild(child, timeoutMs = 5_000) {
|
||||
if (!child || child.exitCode != null || child.signalCode != null) return Promise.resolve()
|
||||
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
|
||||
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.'))
|
||||
if (!child.kill()) {
|
||||
finish(new Error('SSH tunnel termination was refused.'))
|
||||
}
|
||||
} catch (error) {
|
||||
finish(error)
|
||||
}
|
||||
|
|
@ -355,9 +474,14 @@ class SshConnection {
|
|||
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)
|
||||
|
||||
if (cfg.keyPath) {
|
||||
validateKeyPath(cfg.keyPath)
|
||||
}
|
||||
|
||||
this.host = cfg.host
|
||||
this.user = cfg.user || ''
|
||||
this.port = port
|
||||
|
|
@ -378,6 +502,7 @@ class SshConnection {
|
|||
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
|
||||
|
|
@ -394,12 +519,15 @@ class SshConnection {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -414,14 +542,18 @@ class SshConnection {
|
|||
// a real exec before trusting it; on failure, evict and dial fresh.
|
||||
if (!this._mux || (await this._verifyMuxChannel())) {
|
||||
this._opened = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this._logLine('existing control master failed exec verification; evicting stale master')
|
||||
await this._evictStaleMaster()
|
||||
}
|
||||
|
||||
if (!this._mux) {
|
||||
this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`)
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
|
|
@ -430,43 +562,59 @@ class SshConnection {
|
|||
} 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 {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
|
@ -474,12 +622,17 @@ class SshConnection {
|
|||
// 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
|
||||
if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const args = this._mux
|
||||
? buildControlArgs(this, 'check', [], this._connectTimeoutMs)
|
||||
: buildExecArgs(this, 'exit 0', this._connectTimeoutMs)
|
||||
|
||||
try {
|
||||
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -494,6 +647,7 @@ class SshConnection {
|
|||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -510,11 +664,16 @@ class SshConnection {
|
|||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(this.controlPath)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`)
|
||||
if (error?.code !== 'ENOENT') {
|
||||
this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -523,6 +682,7 @@ class SshConnection {
|
|||
async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) {
|
||||
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await runSsh(args, {
|
||||
timeoutMs: timeoutMs ?? this._execTimeoutMs,
|
||||
|
|
@ -532,9 +692,11 @@ class SshConnection {
|
|||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
|
|
@ -545,8 +707,19 @@ class SshConnection {
|
|||
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 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)
|
||||
|
|
@ -554,14 +727,20 @@ class SshConnection {
|
|||
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
|
||||
if (readyConfirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
stderr = `${stderr}${String(d)}`.slice(-16_384)
|
||||
|
||||
if (readyPattern.test(stderr)) {
|
||||
readyConfirmed = true
|
||||
readyResolve()
|
||||
|
|
@ -580,11 +759,15 @@ class SshConnection {
|
|||
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)
|
||||
readyTimeout = setTimeout(
|
||||
() => reject(new Error('tunnel did not confirm local forwarding')),
|
||||
this._forwardTimeoutMs
|
||||
)
|
||||
})
|
||||
])
|
||||
} catch (error: any) {
|
||||
|
|
@ -594,19 +777,24 @@ class SshConnection {
|
|||
} 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)
|
||||
}
|
||||
|
|
@ -616,16 +804,21 @@ class SshConnection {
|
|||
// 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}`)
|
||||
|
|
@ -637,28 +830,45 @@ class SshConnection {
|
|||
// 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._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)
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
|
||||
this._logLine('control master closed')
|
||||
} catch (error: any) {
|
||||
// A master that refuses -O exit is the wedge that poisons re-attach;
|
||||
// disown it. (Without its socket the orphan is inert; ControlPersist may
|
||||
// not reap it if a wedged channel never idles.)
|
||||
this._logLine(`close failed; removing control socket: ${error.message}`)
|
||||
try { fs.unlinkSync(this.controlPath) } catch {}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(this.controlPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
this._opened = false
|
||||
}
|
||||
}
|
||||
|
|
@ -684,27 +894,27 @@ function createSshProbeConnection(config, options: any = {}) {
|
|||
}
|
||||
|
||||
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,
|
||||
CONTROL_PERSIST_SECONDS,
|
||||
controlSocketPath,
|
||||
createSshProbeConnection,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_EXEC_TIMEOUT_MS,
|
||||
DEFAULT_FORWARD_TIMEOUT_MS,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
stopTunnelChild,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
sshErrorMessage,
|
||||
stopTunnelChild,
|
||||
target,
|
||||
validateKeyPath,
|
||||
validateSshTarget
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
|
|
@ -26,11 +27,25 @@ test('PowerShell transport uses UTF-16LE encoded commands and literal escaping',
|
|||
test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => {
|
||||
assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\nx86_64\n')), { os: 'Linux', arch: 'x86_64' })
|
||||
const calls: string[] = []
|
||||
const result = await detectRemotePlatform(sshWith(async command => {
|
||||
calls.push(command)
|
||||
if (command.startsWith('uname ')) throw new Error('PowerShell does not recognize uname')
|
||||
return JSON.stringify({ os: 'Windows', arch: 'ARM64', hermesHome: 'C:\\h', hermesPath: 'C:\\h\\hermes.exe', python: 'C:\\h\\python.exe' })
|
||||
}))
|
||||
|
||||
const result = await detectRemotePlatform(
|
||||
sshWith(async command => {
|
||||
calls.push(command)
|
||||
|
||||
if (command.startsWith('uname ')) {
|
||||
throw new Error('PowerShell does not recognize uname')
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
os: 'Windows',
|
||||
arch: 'ARM64',
|
||||
hermesHome: 'C:\\h',
|
||||
hermesPath: 'C:\\h\\hermes.exe',
|
||||
python: 'C:\\h\\python.exe'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
assert.equal(result.os, 'Windows')
|
||||
assert.match(calls[1], /EncodedCommand/)
|
||||
})
|
||||
|
|
@ -41,22 +56,34 @@ test('platform detection surfaces transport failures as themselves, not unsuppor
|
|||
const transportErr: any = new Error('SSH connection timed out')
|
||||
transportErr.kind = 'timeout'
|
||||
await assert.rejects(
|
||||
detectRemotePlatform(sshWith(async () => { throw transportErr })),
|
||||
detectRemotePlatform(
|
||||
sshWith(async () => {
|
||||
throw transportErr
|
||||
})
|
||||
),
|
||||
(err: any) => err.kind === 'timeout'
|
||||
)
|
||||
// Probe genuinely failing on a reachable host still classifies unsupported,
|
||||
// and carries the probe detail for diagnosis.
|
||||
await assert.rejects(
|
||||
detectRemotePlatform(sshWith(async command => {
|
||||
if (command.startsWith('uname ')) throw new Error('not recognized')
|
||||
throw new Error('Hermes is not installed on the remote Windows host.')
|
||||
})),
|
||||
detectRemotePlatform(
|
||||
sshWith(async command => {
|
||||
if (command.startsWith('uname ')) {
|
||||
throw new Error('not recognized')
|
||||
}
|
||||
|
||||
throw new Error('Hermes is not installed on the remote Windows host.')
|
||||
})
|
||||
),
|
||||
(err: any) => err.kind === 'unsupported-platform' && /Hermes is not installed/.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('helper command uses the fixed remote Python entry point and quotes path data', () => {
|
||||
const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', ["C:\\x y\\hermes.exe"])
|
||||
const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', [
|
||||
'C:\\x y\\hermes.exe'
|
||||
])
|
||||
|
||||
const encoded = command.split(' ').pop()!
|
||||
const script = Buffer.from(encoded, 'base64').toString('utf16le')
|
||||
assert.match(script, /-m' 'hermes_cli\.windows_ssh_runtime' 'inspect'/)
|
||||
|
|
@ -77,6 +104,7 @@ test('Windows lock validation is scoped and exact', () => {
|
|||
hermesPath: 'C:\\h\\hermes.exe',
|
||||
hermesHome: 'C:\\h'
|
||||
}
|
||||
|
||||
assert.equal(validLock(lock, ownershipId), true)
|
||||
assert.equal(validLock({ ...lock, ownershipId: 'b'.repeat(32) }, ownershipId), false)
|
||||
assert.equal(validLock({ ...lock, creationTimeNs: '0' }, ownershipId), false)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import crypto from 'node:crypto'
|
||||
|
||||
import { SSH_ERROR, redactSecrets } from './ssh-connection'
|
||||
import { redactSecrets, SSH_ERROR } from './ssh-connection'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
const PROTOCOL_VERSION = 1
|
||||
|
|
@ -21,6 +21,7 @@ function powerShellCommand(script) {
|
|||
|
||||
async function probeWindowsRemote(ssh, explicitHermesPath = '') {
|
||||
const explicit = psLiteral(explicitHermesPath)
|
||||
|
||||
const script = [
|
||||
'$ErrorActionPreference="Stop"',
|
||||
`$explicit=${explicit}`,
|
||||
|
|
@ -39,14 +40,21 @@ async function probeWindowsRemote(ssh, explicitHermesPath = '') {
|
|||
'if(-not (Test-Path -LiteralPath $python -PathType Leaf)){throw "The remote Hermes Python runtime was not found."}',
|
||||
'[ordered]@{os="Windows";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress'
|
||||
].join(';')
|
||||
|
||||
return JSON.parse((await ssh.exec(powerShellCommand(script))).trim())
|
||||
}
|
||||
|
||||
const TRANSPORT_KINDS = new Set([SSH_ERROR.AUTH_FAILED, SSH_ERROR.HOST_KEY_CHANGED, SSH_ERROR.TIMEOUT, SSH_ERROR.UNREACHABLE])
|
||||
const TRANSPORT_KINDS = new Set([
|
||||
SSH_ERROR.AUTH_FAILED,
|
||||
SSH_ERROR.HOST_KEY_CHANGED,
|
||||
SSH_ERROR.TIMEOUT,
|
||||
SSH_ERROR.UNREACHABLE
|
||||
])
|
||||
|
||||
async function detectRemotePlatform(ssh, explicitHermesPath = '') {
|
||||
try {
|
||||
const output = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
|
||||
|
||||
if (output[0] === 'Linux' || output[0] === 'Darwin') {
|
||||
return { os: output[0], arch: output[1] || '' }
|
||||
}
|
||||
|
|
@ -54,17 +62,28 @@ async function detectRemotePlatform(ssh, explicitHermesPath = '') {
|
|||
// uname failing is the expected Windows fall-through; a TRANSPORT failure
|
||||
// (auth/host-key/timeout/unreachable) is not a platform verdict — surface it
|
||||
// as itself instead of letting the probe chain end in 'unsupported-platform'.
|
||||
if (TRANSPORT_KINDS.has(error?.kind)) throw error
|
||||
if (TRANSPORT_KINDS.has(error?.kind)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await probeWindowsRemote(ssh, explicitHermesPath)
|
||||
} catch (cause: any) {
|
||||
if (TRANSPORT_KINDS.has(cause?.kind)) throw cause
|
||||
if (TRANSPORT_KINDS.has(cause?.kind)) {
|
||||
throw cause
|
||||
}
|
||||
|
||||
// detail is remote-controlled output headed for the UI: redact + strip control chars.
|
||||
const detail = redactSecrets(String(cause?.message || cause || '')).replace(/[\x00-\x1f\x7f]/g, ' ').trim()
|
||||
const detail = redactSecrets(String(cause?.message || cause || ''))
|
||||
// eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
.trim()
|
||||
|
||||
const error: any = new Error(
|
||||
`The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}`
|
||||
)
|
||||
|
||||
error.kind = 'unsupported-platform'
|
||||
error.cause = cause
|
||||
throw error
|
||||
|
|
@ -73,30 +92,61 @@ async function detectRemotePlatform(ssh, explicitHermesPath = '') {
|
|||
|
||||
function helperCommand(runtime, operation, args = []) {
|
||||
const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args]
|
||||
const script = ['$ErrorActionPreference="Stop"', `& ${argv.map(psLiteral).join(' ')}`, 'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}'].join(';')
|
||||
|
||||
const script = [
|
||||
'$ErrorActionPreference="Stop"',
|
||||
`& ${argv.map(psLiteral).join(' ')}`,
|
||||
'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}'
|
||||
].join(';')
|
||||
|
||||
return powerShellCommand(script)
|
||||
}
|
||||
|
||||
async function helper(ssh, runtime, operation, args = [], stdinData?) {
|
||||
const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData })
|
||||
const lines = String(output || '').replace(/^\uFEFF/, '').trim().split(/\r?\n/).filter(Boolean)
|
||||
|
||||
const lines = String(output || '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
|
||||
const parsed = JSON.parse(lines[lines.length - 1] || 'null')
|
||||
if (parsed?.error) throw new Error(parsed.error)
|
||||
|
||||
if (parsed?.error) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function fingerprintToken(token) {
|
||||
return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32)
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(String(token || ''))
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
}
|
||||
|
||||
function validLock(lock, ownershipId) {
|
||||
// port 0 = spawn-in-progress record (written before readiness); a valid
|
||||
// ownership proof for cleanup, but never reusable.
|
||||
return Boolean(lock && lock.schemaVersion === LOCKFILE_SCHEMA_VERSION && lock.protocolVersion === PROTOCOL_VERSION &&
|
||||
lock.ownershipId === ownershipId && /^[0-9a-f]{16}$/.test(lock.spawnNonce || '') &&
|
||||
Number.isInteger(lock.pid) && lock.pid > 0 && /^[0-9]{10,20}$/.test(lock.creationTimeNs || '') &&
|
||||
Number.isInteger(lock.port) && lock.port >= 0 && lock.port <= 65535 && /^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') &&
|
||||
typeof lock.hermesPath === 'string' && typeof lock.hermesHome === 'string')
|
||||
return Boolean(
|
||||
lock &&
|
||||
lock.schemaVersion === LOCKFILE_SCHEMA_VERSION &&
|
||||
lock.protocolVersion === PROTOCOL_VERSION &&
|
||||
lock.ownershipId === ownershipId &&
|
||||
/^[0-9a-f]{16}$/.test(lock.spawnNonce || '') &&
|
||||
Number.isInteger(lock.pid) &&
|
||||
lock.pid > 0 &&
|
||||
/^[0-9]{10,20}$/.test(lock.creationTimeNs || '') &&
|
||||
Number.isInteger(lock.port) &&
|
||||
lock.port >= 0 &&
|
||||
lock.port <= 65535 &&
|
||||
/^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') &&
|
||||
typeof lock.hermesPath === 'string' &&
|
||||
typeof lock.hermesHome === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function assertCurrent(signal) {
|
||||
|
|
@ -108,94 +158,185 @@ function assertCurrent(signal) {
|
|||
}
|
||||
|
||||
async function processState(ssh, runtime, lock) {
|
||||
return helper(ssh, runtime, 'process-state', [String(lock.pid), String(lock.creationTimeNs), lock.hermesPath, lock.spawnNonce])
|
||||
return helper(ssh, runtime, 'process-state', [
|
||||
String(lock.pid),
|
||||
String(lock.creationTimeNs),
|
||||
lock.hermesPath,
|
||||
lock.spawnNonce
|
||||
])
|
||||
}
|
||||
|
||||
async function cleanupOwned(ssh, runtime, ownershipId, lock) {
|
||||
const attempt = async fn => { try { await fn() } catch {} }
|
||||
const attempt = async fn => {
|
||||
try {
|
||||
await fn()
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
if (lock) {
|
||||
const state = await processState(ssh, runtime, lock)
|
||||
|
||||
if (state.alive && state.owned) {
|
||||
// Deliberately not attempt()-wrapped: a thrown terminate must abort before
|
||||
// remove-lock, or a live backend is orphaned with no lock to reclaim it.
|
||||
await helper(ssh, runtime, 'terminate', [String(lock.pid), String(lock.creationTimeNs), lock.hermesPath, lock.spawnNonce])
|
||||
await helper(ssh, runtime, 'terminate', [
|
||||
String(lock.pid),
|
||||
String(lock.creationTimeNs),
|
||||
lock.hermesPath,
|
||||
lock.spawnNonce
|
||||
])
|
||||
}
|
||||
|
||||
if (lock.spawnNonce) {
|
||||
await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce]))
|
||||
await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce]))
|
||||
}
|
||||
}
|
||||
|
||||
await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId]))
|
||||
}
|
||||
|
||||
async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
assertCurrent(signal)
|
||||
let state
|
||||
|
||||
try {
|
||||
state = await processState(ssh, runtime, lock)
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!state.indeterminate && (!state.alive || !state.owned)) {
|
||||
let detail = ''
|
||||
try { detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' } catch {}
|
||||
const error: any = new Error(`Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}`)
|
||||
|
||||
try {
|
||||
detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
const error: any = new Error(
|
||||
`Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}`
|
||||
)
|
||||
|
||||
error.kind = 'spawn-failed'
|
||||
throw error
|
||||
}
|
||||
|
||||
let content = ''
|
||||
try { content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' } catch {}
|
||||
|
||||
try {
|
||||
content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
let port
|
||||
for (const match of content.matchAll(READY_RE)) port = Number(match[1])
|
||||
if (port) return port
|
||||
|
||||
for (const match of content.matchAll(READY_RE)) {
|
||||
port = Number(match[1])
|
||||
}
|
||||
|
||||
if (port) {
|
||||
return port
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`)
|
||||
error.kind = 'ready-timeout'
|
||||
throw error
|
||||
}
|
||||
|
||||
async function connectWindowsRemote(deps) {
|
||||
const { ssh, ownershipId, profile = '', remoteHermesPath = '', reuseToken = '', signal,
|
||||
pickLocalPort, forward, cancelForward, waitForHermes, probeReuseProof, rememberLog = () => {}, readyTimeoutMs = 45_000 } = deps
|
||||
const {
|
||||
ssh,
|
||||
ownershipId,
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
reuseToken = '',
|
||||
signal,
|
||||
pickLocalPort,
|
||||
forward,
|
||||
cancelForward,
|
||||
waitForHermes,
|
||||
probeReuseProof,
|
||||
rememberLog = () => {},
|
||||
readyTimeoutMs = 45_000
|
||||
} = deps
|
||||
|
||||
assertCurrent(signal)
|
||||
const runtime = await probeWindowsRemote(ssh, remoteHermesPath)
|
||||
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
|
||||
|
||||
if (!inspection.supported) {
|
||||
const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.')
|
||||
error.kind = 'update-required'
|
||||
throw error
|
||||
}
|
||||
|
||||
runtime.hermesPath = inspection.path
|
||||
const hermesVersion = inspection.version || ''
|
||||
rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`)
|
||||
rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`)
|
||||
|
||||
const lock = await helper(ssh, runtime, 'read-lock', [ownershipId])
|
||||
|
||||
if (validLock(lock, ownershipId)) {
|
||||
const state = await processState(ssh, runtime, lock)
|
||||
|
||||
if (state.indeterminate) {
|
||||
const error: any = new Error('Could not determine the state of the existing remote backend.')
|
||||
error.kind = 'transient-transport-error'
|
||||
throw error
|
||||
}
|
||||
const reusable = state.alive && state.owned && lock.port > 0 && Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) && lock.hermesPath === runtime.hermesPath && lock.hermesHome === runtime.hermesHome
|
||||
|
||||
const reusable =
|
||||
state.alive &&
|
||||
state.owned &&
|
||||
lock.port > 0 &&
|
||||
Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === runtime.hermesPath &&
|
||||
lock.hermesHome === runtime.hermesHome
|
||||
|
||||
if (reusable) {
|
||||
const localPort = await pickLocalPort()
|
||||
await forward(localPort, lock.port)
|
||||
|
||||
try {
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
|
||||
|
||||
if (classification === 'authenticated-ok') {
|
||||
return { baseUrl, token: reuseToken, remotePort: lock.port, localPort, pid: lock.pid, reused: true,
|
||||
platform: { os: 'Windows', arch: runtime.arch }, hermesPath: runtime.hermesPath, hermesVersion,
|
||||
ownershipId, spawnNonce: lock.spawnNonce, creationTimeNs: lock.creationTimeNs }
|
||||
return {
|
||||
baseUrl,
|
||||
token: reuseToken,
|
||||
remotePort: lock.port,
|
||||
localPort,
|
||||
pid: lock.pid,
|
||||
reused: true,
|
||||
platform: { os: 'Windows', arch: runtime.arch },
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce: lock.spawnNonce,
|
||||
creationTimeNs: lock.creationTimeNs
|
||||
}
|
||||
}
|
||||
if (classification !== 'authenticated-stale') throw new Error('Invalid SSH reuse classification.')
|
||||
|
||||
if (classification !== 'authenticated-stale') {
|
||||
throw new Error('Invalid SSH reuse classification.')
|
||||
}
|
||||
|
||||
await cancelForward(localPort, lock.port)
|
||||
await cleanupOwned(ssh, runtime, ownershipId, lock)
|
||||
} catch (error) {
|
||||
|
|
@ -214,17 +355,38 @@ async function connectWindowsRemote(deps) {
|
|||
const spawnNonce = crypto.randomBytes(8).toString('hex')
|
||||
await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)
|
||||
let spawned
|
||||
|
||||
try {
|
||||
spawned = await helper(ssh, runtime, 'spawn', [], JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath }))
|
||||
spawned = await helper(
|
||||
ssh,
|
||||
runtime,
|
||||
'spawn',
|
||||
[],
|
||||
JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath })
|
||||
)
|
||||
} catch (error) {
|
||||
await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce])
|
||||
throw error
|
||||
}
|
||||
const owned = { schemaVersion: LOCKFILE_SCHEMA_VERSION, protocolVersion: PROTOCOL_VERSION, ownershipId, spawnNonce,
|
||||
pid: spawned.pid, creationTimeNs: spawned.creationTimeNs, port: 0, profile, hermesPath: runtime.hermesPath,
|
||||
hermesHome: runtime.hermesHome, tokenFingerprint: fingerprintToken(token), startedAt: new Date().toISOString() }
|
||||
|
||||
const owned = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
pid: spawned.pid,
|
||||
creationTimeNs: spawned.creationTimeNs,
|
||||
port: 0,
|
||||
profile,
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesHome: runtime.hermesHome,
|
||||
tokenFingerprint: fingerprintToken(token),
|
||||
startedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let localPort = 0
|
||||
let remotePort = 0
|
||||
|
||||
try {
|
||||
// Write the ownership record IMMEDIATELY (port=0): if this attempt is
|
||||
// superseded before readiness and cleanup cannot reach the box, the next
|
||||
|
|
@ -239,11 +401,26 @@ async function connectWindowsRemote(deps) {
|
|||
await waitForHermes(baseUrl, token)
|
||||
assertCurrent(signal)
|
||||
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))
|
||||
return { baseUrl, token, remotePort, localPort, pid: spawned.pid, reused: false,
|
||||
platform: { os: 'Windows', arch: runtime.arch }, hermesPath: runtime.hermesPath, hermesVersion,
|
||||
ownershipId, spawnNonce, creationTimeNs: spawned.creationTimeNs }
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
remotePort,
|
||||
localPort,
|
||||
pid: spawned.pid,
|
||||
reused: false,
|
||||
platform: { os: 'Windows', arch: runtime.arch },
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
creationTimeNs: spawned.creationTimeNs
|
||||
}
|
||||
} catch (error) {
|
||||
if (localPort && remotePort) await cancelForward(localPort, remotePort)
|
||||
if (localPort && remotePort) {
|
||||
await cancelForward(localPort, remotePort)
|
||||
}
|
||||
|
||||
await cleanupOwned(ssh, runtime, ownershipId, owned)
|
||||
throw error
|
||||
}
|
||||
|
|
@ -252,9 +429,27 @@ async function connectWindowsRemote(deps) {
|
|||
function buildWindowsInteractiveCommand(remoteCwd = '') {
|
||||
const cwd = String(remoteCwd || '').trim()
|
||||
const script = ['$ErrorActionPreference="Stop"']
|
||||
if (cwd) script.push(`if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}`)
|
||||
|
||||
if (cwd) {
|
||||
script.push(
|
||||
`if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}`
|
||||
)
|
||||
}
|
||||
|
||||
script.push('$host.UI.RawUI.WindowTitle="Hermes SSH"', 'powershell.exe -NoLogo')
|
||||
|
||||
return powerShellCommand(script.join(';'))
|
||||
}
|
||||
|
||||
export { buildWindowsInteractiveCommand, connectWindowsRemote, detectRemotePlatform, encodedPowerShell, helper, helperCommand, powerShellCommand, probeWindowsRemote, psLiteral, validLock }
|
||||
export {
|
||||
buildWindowsInteractiveCommand,
|
||||
connectWindowsRemote,
|
||||
detectRemotePlatform,
|
||||
encodedPowerShell,
|
||||
helper,
|
||||
helperCommand,
|
||||
powerShellCommand,
|
||||
probeWindowsRemote,
|
||||
psLiteral,
|
||||
validLock
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { SshConnection, pickLocalPort } from './ssh-connection'
|
||||
import { pickLocalPort, SshConnection } from './ssh-connection'
|
||||
import { connectWindowsRemote } from './windows-remote-lifecycle'
|
||||
|
||||
// Live test against a real Windows host over SSH. Opt-in: set the env trio to
|
||||
|
|
@ -16,60 +17,90 @@ const ownershipId = '89abcdef0123456789abcdef01234567'
|
|||
|
||||
function fetchJson(url, token, path) {
|
||||
return fetch(`${url}${path}`, { headers: { 'X-Hermes-Session-Token': token } }).then(async response => {
|
||||
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
})
|
||||
}
|
||||
|
||||
test.skipIf(!liveHost || !liveUser || !configuredHermes)(
|
||||
'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership', async () => {
|
||||
const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true })
|
||||
await ssh.open()
|
||||
const common = {
|
||||
ssh,
|
||||
ownershipId,
|
||||
profile: '',
|
||||
remoteHermesPath: configuredHermes,
|
||||
pickLocalPort,
|
||||
forward: (local, remote) => ssh.forward(local, remote),
|
||||
cancelForward: (local, remote) => ssh.cancelForward(local, remote),
|
||||
waitForHermes: async (baseUrl, token) => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try { await fetchJson(baseUrl, token, '/api/status'); return } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error('status timeout')
|
||||
},
|
||||
probeReuseProof: async (baseUrl, token, nonce) => {
|
||||
const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership')
|
||||
return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale'
|
||||
},
|
||||
rememberLog: () => {}
|
||||
}
|
||||
let first
|
||||
let second
|
||||
try {
|
||||
first = await connectWindowsRemote(common)
|
||||
assert.equal(first.platform.os, 'Windows')
|
||||
assert.equal(first.reused, false)
|
||||
const status: any = await fetchJson(first.baseUrl, first.token, '/api/status')
|
||||
assert.ok(status)
|
||||
await ssh.cancelForward(first.localPort, first.remotePort)
|
||||
second = await connectWindowsRemote({ ...common, reuseToken: first.token })
|
||||
assert.equal(second.reused, true)
|
||||
assert.equal(second.pid, first.pid)
|
||||
assert.equal(second.spawnNonce, first.spawnNonce)
|
||||
} finally {
|
||||
if (second) await ssh.cancelForward(second.localPort, second.remotePort)
|
||||
const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'`
|
||||
const lock: any = JSON.parse(await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`))
|
||||
if (lock) {
|
||||
const python = configuredHermes.replace('hermes.exe', 'python.exe')
|
||||
const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'`
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`)
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"`)
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"`)
|
||||
'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership',
|
||||
async () => {
|
||||
const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true })
|
||||
await ssh.open()
|
||||
|
||||
const common = {
|
||||
ssh,
|
||||
ownershipId,
|
||||
profile: '',
|
||||
remoteHermesPath: configuredHermes,
|
||||
pickLocalPort,
|
||||
forward: (local, remote) => ssh.forward(local, remote),
|
||||
cancelForward: (local, remote) => ssh.cancelForward(local, remote),
|
||||
waitForHermes: async (baseUrl, token) => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
await fetchJson(baseUrl, token, '/api/status')
|
||||
|
||||
return
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error('status timeout')
|
||||
},
|
||||
probeReuseProof: async (baseUrl, token, nonce) => {
|
||||
const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership')
|
||||
|
||||
return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale'
|
||||
},
|
||||
rememberLog: () => {}
|
||||
}
|
||||
await ssh.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
let first
|
||||
let second
|
||||
|
||||
try {
|
||||
first = await connectWindowsRemote(common)
|
||||
assert.equal(first.platform.os, 'Windows')
|
||||
assert.equal(first.reused, false)
|
||||
const status: any = await fetchJson(first.baseUrl, first.token, '/api/status')
|
||||
assert.ok(status)
|
||||
await ssh.cancelForward(first.localPort, first.remotePort)
|
||||
second = await connectWindowsRemote({ ...common, reuseToken: first.token })
|
||||
assert.equal(second.reused, true)
|
||||
assert.equal(second.pid, first.pid)
|
||||
assert.equal(second.spawnNonce, first.spawnNonce)
|
||||
} finally {
|
||||
if (second) {
|
||||
await ssh.cancelForward(second.localPort, second.remotePort)
|
||||
}
|
||||
|
||||
const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'`
|
||||
|
||||
const lock: any = JSON.parse(
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`)
|
||||
)
|
||||
|
||||
if (lock) {
|
||||
const python = configuredHermes.replace('hermes.exe', 'python.exe')
|
||||
const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'`
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`)
|
||||
await ssh.exec(
|
||||
`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"`
|
||||
)
|
||||
await ssh.exec(
|
||||
`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"`
|
||||
)
|
||||
}
|
||||
|
||||
await ssh.close()
|
||||
}
|
||||
},
|
||||
90_000
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ import { ModelPickerOverlay } from '../model-picker-overlay'
|
|||
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
|
||||
import { FileActionDialogs } from '../right-sidebar/file-actions'
|
||||
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
|
||||
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
|
||||
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
|
||||
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
|
||||
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
|
||||
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
|
||||
|
|
|
|||
|
|
@ -100,7 +100,10 @@ function fakeDesktop() {
|
|||
onBackendExit: vi.fn(() => () => undefined),
|
||||
onConnectionApplied: vi.fn(callback => {
|
||||
connectionApplied = callback
|
||||
return () => { connectionApplied = null }
|
||||
|
||||
return () => {
|
||||
connectionApplied = null
|
||||
}
|
||||
}),
|
||||
onPowerResume: vi.fn(() => () => undefined),
|
||||
onWindowStateChanged: vi.fn(() => () => undefined),
|
||||
|
|
|
|||
|
|
@ -409,6 +409,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
const text = coerceGatewayText(payload?.text)
|
||||
|
||||
if (text) {
|
||||
finalizeInterimAssistantMessage(sessionId, text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ export function useMessageStream({
|
|||
}
|
||||
|
||||
const authoritativeText = renderMediaTags(text).trim()
|
||||
|
||||
if (!authoritativeText) {
|
||||
return state
|
||||
}
|
||||
|
|
@ -386,29 +387,30 @@ export function useMessageStream({
|
|||
const streamId = state.streamId
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleText = stripGeneratedImageEchoes(
|
||||
authoritativeText, generatedImageEchoSources(parts)
|
||||
).trim()
|
||||
const visibleText = stripGeneratedImageEchoes(authoritativeText, generatedImageEchoSources(parts)).trim()
|
||||
|
||||
return mergeFinalAssistantText(parts, visibleText)
|
||||
}
|
||||
|
||||
let nextMessages = state.messages
|
||||
|
||||
if (streamId && nextMessages.some(m => m.id === streamId)) {
|
||||
// Finalize the existing streaming bubble in place
|
||||
nextMessages = nextMessages.map(m =>
|
||||
m.id === streamId
|
||||
? { ...m, parts: replaceTextPart(m.parts), pending: false }
|
||||
: m
|
||||
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m
|
||||
)
|
||||
} else {
|
||||
// No streaming bubble — create a standalone interim message
|
||||
nextMessages = [...nextMessages, {
|
||||
id: `assistant-interim-${Date.now()}`,
|
||||
role: 'assistant' as const,
|
||||
parts: [assistantTextPart(authoritativeText)],
|
||||
pending: false,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}]
|
||||
nextMessages = [
|
||||
...nextMessages,
|
||||
{
|
||||
id: `assistant-interim-${Date.now()}`,
|
||||
role: 'assistant' as const,
|
||||
parts: [assistantTextPart(authoritativeText)],
|
||||
pending: false,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -451,6 +453,7 @@ export function useMessageStream({
|
|||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
|
||||
|
||||
return mergeFinalAssistantText(parts, visibleFinalText)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { useEffect, useRef } from 'react'
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
|
@ -55,10 +55,13 @@ async function mountStream() {
|
|||
|
||||
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
|
||||
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
|
||||
|
||||
const interim = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
|
||||
|
||||
const complete = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
const completePreviewed = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
|
|
@ -69,11 +72,13 @@ function getState(): ClientSessionState {
|
|||
function assistantText(): string {
|
||||
const state = getState()
|
||||
const last = [...state.messages].reverse().find(m => m.role === 'assistant' && !m.hidden)
|
||||
|
||||
return last ? chatMessageText(last) : ''
|
||||
}
|
||||
|
||||
function assistantMessages(): string[] {
|
||||
const state = getState()
|
||||
|
||||
return state.messages
|
||||
.filter(m => m.role === 'assistant' && !m.hidden)
|
||||
.map(m => chatMessageText(m))
|
||||
|
|
@ -222,7 +227,9 @@ describe('useMessageStream interim text sealing', () => {
|
|||
// Empty text
|
||||
await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
|
||||
// Undefined text
|
||||
await act(() => handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent))
|
||||
await act(() =>
|
||||
handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
|
||||
)
|
||||
|
||||
// Turn continues without finalizing or throwing
|
||||
expect(getState().busy).toBe(true)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,11 @@ export function useSessionActions({
|
|||
// rebind race, so leaving the old id here could revive it on a very fast
|
||||
// New Chat -> Enter sequence.
|
||||
onFreshDraftRouteIntent?.()
|
||||
if (!preserveRoute) navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })
|
||||
|
||||
if (!preserveRoute) {
|
||||
navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })
|
||||
}
|
||||
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
setSelectedStoredSessionId(null)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,19 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw, Terminal } from '@/lib/icons'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Cloud,
|
||||
FileText,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Terminal
|
||||
} from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -365,17 +377,30 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
// instantly snapped the just-clicked-Custom (empty-host) input back to the
|
||||
// dropdown, making a raw-IP host impossible to type. The way back to the
|
||||
// dropdown is the input's onBlur (empty host + suggestions).
|
||||
if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) setSshCustomHost(true)
|
||||
if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) {
|
||||
setSshCustomHost(true)
|
||||
}
|
||||
}, [state.sshHost, sshHostSuggestions])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) return
|
||||
if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void window.hermesDesktop.sshConfigHosts().then(result => {
|
||||
if (!cancelled) setSshHostSuggestions(result.hosts)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setSshHostSuggestions([])
|
||||
})
|
||||
void window.hermesDesktop
|
||||
.sshConfigHosts()
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setSshHostSuggestions(result.hosts)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSshHostSuggestions([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [state.mode])
|
||||
|
||||
|
|
@ -417,6 +442,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
const save = async (apply: boolean) => {
|
||||
const seq = ++saveSeq.current
|
||||
|
||||
if (state.mode === 'remote' && !canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
|
|
@ -433,7 +459,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
const next = apply
|
||||
? await window.hermesDesktop.applyConnectionConfig(payload())
|
||||
: await window.hermesDesktop.saveConnectionConfig(payload())
|
||||
if (seq !== saveSeq.current) return
|
||||
|
||||
if (seq !== saveSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
acceptSavedConfig(next)
|
||||
setRemoteToken('')
|
||||
|
|
@ -443,8 +472,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
message: apply ? g.restartingMessage : g.savedMessage
|
||||
})
|
||||
} catch (err) {
|
||||
if (seq !== saveSeq.current) return
|
||||
if (seq !== saveSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : ''
|
||||
|
||||
const errors = {
|
||||
'auth-failed': g.sshErrAuth,
|
||||
'hermes-not-found': g.sshErrNotInstalled,
|
||||
|
|
@ -454,6 +487,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
'unsupported-platform': g.sshErrPlatform,
|
||||
'update-required': g.sshErrUpdateRequired
|
||||
}
|
||||
|
||||
if (state.mode === 'ssh' && sshError) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
|
|
@ -464,7 +498,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
notifyError(err, apply ? g.applyFailed : g.saveFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === saveSeq.current) setSaving(false)
|
||||
if (seq === saveSeq.current) {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -473,6 +509,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
// refresh the connection status from the saved config once it completes.
|
||||
const signIn = async () => {
|
||||
const seq = ++signingSeq.current
|
||||
|
||||
if (!trimmedUrl) {
|
||||
notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst })
|
||||
|
||||
|
|
@ -490,12 +527,18 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
if (seq !== signingSeq.current) return
|
||||
|
||||
if (seq !== signingSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
acceptSavedConfig(saved)
|
||||
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
if (seq !== signingSeq.current) return
|
||||
|
||||
if (seq !== signingSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
|
|
@ -509,9 +552,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq === signingSeq.current) notifyError(err, g.signInFailed)
|
||||
if (seq === signingSeq.current) {
|
||||
notifyError(err, g.signInFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === signingSeq.current) setSigningIn(false)
|
||||
if (seq === signingSeq.current) {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -522,13 +569,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
if (seq !== signingSeq.current) return
|
||||
|
||||
if (seq !== signingSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
acceptSavedConfig(refreshed)
|
||||
notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
|
||||
} catch (err) {
|
||||
if (seq === signingSeq.current) notifyError(err, g.signOutFailed)
|
||||
if (seq === signingSeq.current) {
|
||||
notifyError(err, g.signOutFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === signingSeq.current) setSigningIn(false)
|
||||
if (seq === signingSeq.current) {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -550,7 +605,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
try {
|
||||
const result = await desktop.cloud.discover(org)
|
||||
if (seq !== contextSeq.current) return
|
||||
|
||||
if (seq !== contextSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('needsOrgSelection' in result && result.needsOrgSelection) {
|
||||
// Multi-org user with no org chosen yet: show the picker. Don't clear a
|
||||
|
|
@ -579,7 +637,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
setCloudDiscover('done')
|
||||
} catch (err) {
|
||||
if (seq !== contextSeq.current) return
|
||||
if (seq !== contextSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('error')
|
||||
|
||||
|
|
@ -674,16 +735,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
try {
|
||||
const result = await desktop.cloud.login()
|
||||
if (seq !== signingSeq.current) return
|
||||
|
||||
if (seq !== signingSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSignedIn(result.signedIn)
|
||||
|
||||
if (result.signedIn) {
|
||||
await discoverCloud()
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq === signingSeq.current) notifyError(err, g.cloudSignInFailed)
|
||||
if (seq === signingSeq.current) {
|
||||
notifyError(err, g.cloudSignInFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === signingSeq.current) setCloudSigningIn(false)
|
||||
if (seq === signingSeq.current) {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -699,7 +768,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
try {
|
||||
await desktop.cloud.logout()
|
||||
if (seq !== signingSeq.current) return
|
||||
|
||||
if (seq !== signingSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSignedIn(false)
|
||||
setCloudAgents([])
|
||||
setCloudOrgs([])
|
||||
|
|
@ -707,9 +780,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
setCloudDiscover('idle')
|
||||
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
|
||||
} catch (err) {
|
||||
if (seq === signingSeq.current) notifyError(err, g.signOutFailed)
|
||||
if (seq === signingSeq.current) {
|
||||
notifyError(err, g.signOutFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === signingSeq.current) setCloudSigningIn(false)
|
||||
if (seq === signingSeq.current) {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -718,6 +795,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
// connection pointed at its dashboardUrl and apply it (soft-reconnects in place).
|
||||
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
|
||||
const seq = contextSeq.current
|
||||
|
||||
if (!agent.dashboardUrl) {
|
||||
return
|
||||
}
|
||||
|
|
@ -732,7 +810,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
try {
|
||||
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
|
||||
if (seq !== contextSeq.current) return
|
||||
|
||||
if (seq !== contextSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.connected) {
|
||||
notify({
|
||||
|
|
@ -755,28 +836,44 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
remoteUrl: agent.dashboardUrl,
|
||||
cloudOrg: cloudOrgRef.current ?? undefined
|
||||
})
|
||||
if (seq !== contextSeq.current) return
|
||||
|
||||
if (seq !== contextSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
acceptSavedConfig(next)
|
||||
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
|
||||
} catch (err) {
|
||||
if (seq !== contextSeq.current) return
|
||||
if (seq !== contextSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudConnectFailed)
|
||||
} finally {
|
||||
if (seq === contextSeq.current) setCloudConnectingId(null)
|
||||
if (seq === contextSeq.current) {
|
||||
setCloudConnectingId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveSshHost = async (host: string) => {
|
||||
if (!host || !window.hermesDesktop?.sshResolveHost) return
|
||||
if (!host || !window.hermesDesktop?.sshResolveHost) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++sshResolveSeq.current
|
||||
|
||||
try {
|
||||
const resolved = await window.hermesDesktop.sshResolveHost(host)
|
||||
if (seq !== sshResolveSeq.current) return
|
||||
|
||||
if (seq !== sshResolveSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(current => enrichSelectedSshHost(current, host, resolved))
|
||||
} catch {
|
||||
return
|
||||
|
|
@ -787,8 +884,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
if (value === SSH_HOST_CUSTOM) {
|
||||
setSshCustomHost(true)
|
||||
setState(current => selectSshHost(current, ''))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSshCustomHost(false)
|
||||
setState(current => selectSshHost(current, value))
|
||||
void resolveSshHost(value)
|
||||
|
|
@ -796,15 +895,23 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
|
||||
const testSsh = async () => {
|
||||
const seq = ++sshTestSeq.current
|
||||
|
||||
if (!state.sshHost.trim()) {
|
||||
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
setLastTest(null)
|
||||
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig(payload())
|
||||
if (seq !== sshTestSeq.current) return
|
||||
|
||||
if (seq !== sshTestSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.reachable) {
|
||||
const errors = {
|
||||
'auth-failed': g.sshErrAuth,
|
||||
|
|
@ -816,20 +923,27 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
'update-required': g.sshErrUpdateRequired,
|
||||
unknown: g.sshErrUnknown
|
||||
}
|
||||
|
||||
throw new Error(errors[result.sshError || 'unknown'] || result.error || g.sshErrUnknown)
|
||||
}
|
||||
|
||||
const message = g.sshReachable(result.host || state.sshHost, result.remotePlatform || '?')
|
||||
setLastTest(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
} catch (err) {
|
||||
if (seq === sshTestSeq.current) notifyError(err, g.testFailed)
|
||||
if (seq === sshTestSeq.current) {
|
||||
notifyError(err, g.testFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === sshTestSeq.current) setTesting(false)
|
||||
if (seq === sshTestSeq.current) {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
const seq = ++sshTestSeq.current
|
||||
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
|
|
@ -851,15 +965,22 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
if (seq !== sshTestSeq.current) return
|
||||
|
||||
if (seq !== sshTestSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined)
|
||||
setLastTest(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
} catch (err) {
|
||||
if (seq === sshTestSeq.current) notifyError(err, g.testFailed)
|
||||
if (seq === sshTestSeq.current) {
|
||||
notifyError(err, g.testFailed)
|
||||
}
|
||||
} finally {
|
||||
if (seq === sshTestSeq.current) setTesting(false)
|
||||
if (seq === sshTestSeq.current) {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1199,10 +1320,19 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
{sshHostSuggestions.length > 0 && !sshCustomHost ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Select value={sshHostSuggestions.includes(state.sshHost) ? state.sshHost : SSH_HOST_CUSTOM} onValueChange={selectHost}>
|
||||
<SelectTrigger className={cn('h-8', CONTROL_TEXT)}><SelectValue placeholder={g.sshHostPick} /></SelectTrigger>
|
||||
<Select
|
||||
onValueChange={selectHost}
|
||||
value={sshHostSuggestions.includes(state.sshHost) ? state.sshHost : SSH_HOST_CUSTOM}
|
||||
>
|
||||
<SelectTrigger className={cn('h-8', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={g.sshHostPick} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshHostSuggestions.map(host => <SelectItem key={host} value={host}>{host}</SelectItem>)}
|
||||
{sshHostSuggestions.map(host => (
|
||||
<SelectItem key={host} value={host}>
|
||||
{host}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={SSH_HOST_CUSTOM}>{g.sshHostCustom}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -1212,20 +1342,79 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
/>
|
||||
) : (
|
||||
<ListRow
|
||||
action={<Input autoFocus={sshCustomHost} className={cn('h-8', CONTROL_TEXT)} onBlur={() => {
|
||||
// Empty host on blur with suggestions available = the user backed
|
||||
// out of Custom; return to the dropdown.
|
||||
if (!state.sshHost.trim() && sshHostSuggestions.length > 0) { setSshCustomHost(false); return }
|
||||
void resolveSshHost(state.sshHost)
|
||||
}} onChange={event => setState(current => selectSshHost(current, event.target.value))} value={state.sshHost} />}
|
||||
action={
|
||||
<Input
|
||||
autoFocus={sshCustomHost}
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
onBlur={() => {
|
||||
// Empty host on blur with suggestions available = the user backed
|
||||
// out of Custom; return to the dropdown.
|
||||
if (!state.sshHost.trim() && sshHostSuggestions.length > 0) {
|
||||
setSshCustomHost(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void resolveSshHost(state.sshHost)
|
||||
}}
|
||||
onChange={event => setState(current => selectSshHost(current, event.target.value))}
|
||||
value={state.sshHost}
|
||||
/>
|
||||
}
|
||||
description={g.sshHostDesc}
|
||||
title={g.sshHostTitle}
|
||||
/>
|
||||
)}
|
||||
<ListRow action={<Input className={cn('h-8', CONTROL_TEXT)} onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))} placeholder={g.sshUserPlaceholder} value={state.sshUser} />} description={g.sshUserDesc} title={g.sshUserTitle} />
|
||||
<ListRow action={<Input className={cn('h-8', CONTROL_TEXT)} inputMode="numeric" onChange={event => setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null }))} placeholder="22" value={state.sshPort ?? ''} />} description={g.sshPortDesc} title={g.sshPortTitle} />
|
||||
<ListRow action={<Input className={cn('h-8 font-mono', CONTROL_TEXT)} onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))} value={state.sshKeyPath} />} description={g.sshKeyDesc} title={g.sshKeyTitle} />
|
||||
<ListRow action={<Input className={cn('h-8 font-mono', CONTROL_TEXT)} onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} placeholder={g.sshHermesPathPlaceholder} value={state.sshRemoteHermesPath} />} description={g.sshHermesPathDesc} title={g.sshHermesPathTitle} />
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))}
|
||||
placeholder={g.sshUserPlaceholder}
|
||||
value={state.sshUser}
|
||||
/>
|
||||
}
|
||||
description={g.sshUserDesc}
|
||||
title={g.sshUserTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
inputMode="numeric"
|
||||
onChange={event =>
|
||||
setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null }))
|
||||
}
|
||||
placeholder="22"
|
||||
value={state.sshPort ?? ''}
|
||||
/>
|
||||
}
|
||||
description={g.sshPortDesc}
|
||||
title={g.sshPortTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))}
|
||||
value={state.sshKeyPath}
|
||||
/>
|
||||
}
|
||||
description={g.sshKeyDesc}
|
||||
title={g.sshKeyTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))}
|
||||
placeholder={g.sshHermesPathPlaceholder}
|
||||
value={state.sshRemoteHermesPath}
|
||||
/>
|
||||
}
|
||||
description={g.sshHermesPathDesc}
|
||||
title={g.sshHermesPathTitle}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1248,7 +1437,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
{g.testRemote}
|
||||
</Button>
|
||||
) : state.mode === 'ssh' ? (
|
||||
<Button className="mr-auto" disabled={testing || !state.sshHost.trim()} onClick={() => void testSsh()} size="sm" variant="text">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={testing || !state.sshHost.trim()}
|
||||
onClick={() => void testSsh()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.sshTestConnection}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ describe('selectSshHost', () => {
|
|||
|
||||
it('enriches only the host that produced the ssh config result', () => {
|
||||
const selected = selectSshHost(state, 'mac-box')
|
||||
expect(enrichSelectedSshHost(selected, 'mac-box', {
|
||||
identityFile: '~/.ssh/id_ed25519',
|
||||
port: 22,
|
||||
user: 'hermes'
|
||||
})).toMatchObject({
|
||||
expect(
|
||||
enrichSelectedSshHost(selected, 'mac-box', {
|
||||
identityFile: '~/.ssh/id_ed25519',
|
||||
port: 22,
|
||||
user: 'hermes'
|
||||
})
|
||||
).toMatchObject({
|
||||
sshHost: 'mac-box',
|
||||
sshUser: 'hermes',
|
||||
sshPort: null,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ type ResolvedSshHost = {
|
|||
}
|
||||
|
||||
function selectSshHost<T extends SshHostState>(state: T, host: string): T {
|
||||
if (host === state.sshHost) return state
|
||||
if (host === state.sshHost) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
sshHost: host,
|
||||
|
|
@ -25,11 +28,14 @@ function selectSshHost<T extends SshHostState>(state: T, host: string): T {
|
|||
}
|
||||
|
||||
function enrichSelectedSshHost<T extends SshHostState>(state: T, host: string, resolved: ResolvedSshHost): T {
|
||||
if (state.sshHost !== host) return state
|
||||
if (state.sshHost !== host) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
sshUser: state.sshUser || resolved.user || '',
|
||||
sshPort: state.sshPort ?? (resolved.port === 22 ? null : resolved.port ?? null),
|
||||
sshPort: state.sshPort ?? (resolved.port === 22 ? null : (resolved.port ?? null)),
|
||||
sshKeyPath: state.sshKeyPath || resolved.identityFile || ''
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,15 +290,30 @@ export function useStatusbarItems({
|
|||
])
|
||||
|
||||
const connectionItem = useMemo<StatusbarItem | null>(() => {
|
||||
if (connection?.mode !== 'remote' || !connection.remoteHost) return null
|
||||
if (connection?.mode !== 'remote' || !connection.remoteHost) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ssh = connection.remoteKind === 'ssh'
|
||||
const cloud = connection.remoteKind === 'cloud'
|
||||
|
||||
return {
|
||||
className: cn('px-2 -ml-1 font-medium', ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground'),
|
||||
className: cn(
|
||||
'px-2 -ml-1 font-medium',
|
||||
ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground'
|
||||
),
|
||||
icon: <Terminal className="size-3" />,
|
||||
id: 'connection',
|
||||
label: ssh ? copy.connectionSsh(connection.remoteHost) : cloud ? copy.connectionCloud(connection.remoteHost) : copy.connectionRemote(connection.remoteHost),
|
||||
title: ssh ? copy.connectionSshTooltip(connection.remoteHost) : cloud ? copy.connectionCloudTooltip(connection.remoteHost) : copy.connectionRemoteTooltip(connection.remoteHost),
|
||||
label: ssh
|
||||
? copy.connectionSsh(connection.remoteHost)
|
||||
: cloud
|
||||
? copy.connectionCloud(connection.remoteHost)
|
||||
: copy.connectionRemote(connection.remoteHost),
|
||||
title: ssh
|
||||
? copy.connectionSshTooltip(connection.remoteHost)
|
||||
: cloud
|
||||
? copy.connectionCloudTooltip(connection.remoteHost)
|
||||
: copy.connectionRemoteTooltip(connection.remoteHost),
|
||||
to: `${SETTINGS_ROUTE}?tab=gateway`
|
||||
}
|
||||
}, [connection?.mode, connection?.remoteHost, connection?.remoteKind, copy])
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ import { notify, notifyError } from '@/store/notifications'
|
|||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
|
||||
import type { RemoteReauth } from './boot-failure-reauth'
|
||||
import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel, sshFailureMessage } from './boot-failure-reauth'
|
||||
import {
|
||||
deriveProviderShape,
|
||||
isRemoteConfig,
|
||||
isRemoteReauthFailure,
|
||||
signInLabel,
|
||||
sshFailureMessage
|
||||
} from './boot-failure-reauth'
|
||||
|
||||
// The recovery "Gateway settings" view embeds the real Settings → Gateway panel
|
||||
// (identical URL/auth/test/save controls — no parallel form to drift). Lazy so
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe('isRemoteConfig', () => {
|
|||
const ssh = config({ mode: 'ssh' as never, remoteUrl: '', remoteAuthMode: 'token' }) as DesktopConnectionConfig & {
|
||||
sshHost: string
|
||||
}
|
||||
|
||||
ssh.sshHost = 'remote-box'
|
||||
|
||||
expect(isRemoteConfig(ssh)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -31,10 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = {
|
|||
// Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target
|
||||
// the bundled backend and can't help. Drives the escape-hatch emphasis.
|
||||
export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean {
|
||||
if (!config) return false
|
||||
if (!config) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ssh = config as DesktopConnectionConfig & { sshHost?: string }
|
||||
return ((config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl)) ||
|
||||
|
||||
return (
|
||||
((config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl)) ||
|
||||
((config.mode as string) === 'ssh' && Boolean(ssh.sshHost))
|
||||
)
|
||||
}
|
||||
|
||||
// True when a boot error is auth-shaped — the refresh token was rejected or the
|
||||
|
|
@ -73,15 +79,41 @@ export function sshFailureMessage(
|
|||
}
|
||||
): string {
|
||||
const raw = String(error || '')
|
||||
if (config?.mode !== 'ssh') return raw
|
||||
|
||||
if (config?.mode !== 'ssh') {
|
||||
return raw
|
||||
}
|
||||
|
||||
const text = raw.toLowerCase()
|
||||
if (text.includes('host key')) return copy.sshErrHostKey || raw
|
||||
if (text.includes('auth')) return copy.sshErrAuth || raw
|
||||
if (text.includes('not installed') || text.includes('not found')) return copy.sshErrNotInstalled || raw
|
||||
if (text.includes('unsupported')) return copy.sshErrPlatform || raw
|
||||
if (text.includes('timed out') || text.includes('timeout')) return copy.sshErrTimeout || raw
|
||||
if (text.includes('update')) return copy.sshErrUpdateRequired || raw
|
||||
if (text.includes('unreachable') || text.includes('could not reach')) return copy.sshErrUnreachable || raw
|
||||
|
||||
if (text.includes('host key')) {
|
||||
return copy.sshErrHostKey || raw
|
||||
}
|
||||
|
||||
if (text.includes('auth')) {
|
||||
return copy.sshErrAuth || raw
|
||||
}
|
||||
|
||||
if (text.includes('not installed') || text.includes('not found')) {
|
||||
return copy.sshErrNotInstalled || raw
|
||||
}
|
||||
|
||||
if (text.includes('unsupported')) {
|
||||
return copy.sshErrPlatform || raw
|
||||
}
|
||||
|
||||
if (text.includes('timed out') || text.includes('timeout')) {
|
||||
return copy.sshErrTimeout || raw
|
||||
}
|
||||
|
||||
if (text.includes('update')) {
|
||||
return copy.sshErrUpdateRequired || raw
|
||||
}
|
||||
|
||||
if (text.includes('unreachable') || text.includes('could not reach')) {
|
||||
return copy.sshErrUnreachable || raw
|
||||
}
|
||||
|
||||
return copy.sshErrUnknown || raw
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -676,7 +676,8 @@ export const en: Translations = {
|
|||
'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.',
|
||||
sshErrNotInstalled:
|
||||
'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.',
|
||||
sshErrPlatform: 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.',
|
||||
sshErrPlatform:
|
||||
'Unsupported remote platform. Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.',
|
||||
sshErrTimeout: 'SSH connection timed out. The host may be unreachable or asleep.',
|
||||
sshErrUpdateRequired: 'Update Hermes on the remote host before connecting with Desktop SSH.',
|
||||
sshErrUnknown: 'SSH connection failed.'
|
||||
|
|
|
|||
|
|
@ -748,7 +748,8 @@ export const ja = defineLocale({
|
|||
'前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。',
|
||||
sshErrNotInstalled:
|
||||
'リモートホストに Hermes がインストールされていません。リモートでインストールする(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)か、Hermes パスを設定してください。',
|
||||
sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。',
|
||||
sshErrPlatform:
|
||||
'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。',
|
||||
sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',
|
||||
sshErrUpdateRequired: 'Desktop SSH で接続する前に、リモートホストの Hermes を更新してください。',
|
||||
sshErrUnknown: 'SSH 接続に失敗しました。'
|
||||
|
|
|
|||
|
|
@ -723,8 +723,7 @@ export const zhHant = defineLocale({
|
|||
sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。',
|
||||
sshErrAuth:
|
||||
'SSH 驗證失敗。請將金鑰載入 ssh-agent(ssh-add),或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。',
|
||||
sshErrHostKey:
|
||||
'自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
|
||||
sshErrHostKey: '自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
|
||||
sshErrNotInstalled:
|
||||
'遠端主機上未安裝 Hermes。請在遠端安裝(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或設定 Hermes 路徑。',
|
||||
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式支援 Linux、macOS 和 Windows 遠端主機。',
|
||||
|
|
|
|||
|
|
@ -866,8 +866,7 @@ export const zh: Translations = {
|
|||
sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。',
|
||||
sshErrAuth:
|
||||
'SSH 认证失败。请将密钥加载到 ssh-agent(ssh-add),或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。',
|
||||
sshErrHostKey:
|
||||
'自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
|
||||
sshErrHostKey: '自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
|
||||
sshErrNotInstalled:
|
||||
'远程主机上未安装 Hermes。请在远程安装(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或设置 Hermes 路径。',
|
||||
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式支持 Linux、macOS 和 Windows 远程主机。',
|
||||
|
|
|
|||
|
|
@ -804,10 +804,7 @@ describe('mergeFinalAssistantText', () => {
|
|||
})
|
||||
|
||||
it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => {
|
||||
const parts = [
|
||||
reasoningPart('Let me check the files.'),
|
||||
{ type: 'text' as const, text: 'streamed' }
|
||||
]
|
||||
const parts = [reasoningPart('Let me check the files.'), { type: 'text' as const, text: 'streamed' }]
|
||||
|
||||
const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.')
|
||||
|
||||
|
|
@ -819,7 +816,9 @@ describe('mergeFinalAssistantText', () => {
|
|||
// #61447: a short final ("Done.") must NOT swallow a longer reasoning block
|
||||
// that merely starts with it.
|
||||
const parts = [
|
||||
reasoningPart('Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'),
|
||||
reasoningPart(
|
||||
'Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'
|
||||
),
|
||||
{ type: 'text' as const, text: 'streamed' }
|
||||
]
|
||||
|
||||
|
|
@ -842,10 +841,7 @@ describe('mergeFinalAssistantText', () => {
|
|||
})
|
||||
|
||||
it('handles empty final text', () => {
|
||||
const parts = [
|
||||
{ type: 'text' as const, text: 'streamed' },
|
||||
reasoningPart('some reasoning')
|
||||
]
|
||||
const parts = [{ type: 'text' as const, text: 'streamed' }, reasoningPart('some reasoning')]
|
||||
|
||||
const result = mergeFinalAssistantText(parts, '')
|
||||
|
||||
|
|
|
|||
|
|
@ -152,10 +152,7 @@ const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
|
|||
* - Keeps all other part types (tool-call, image, etc.).
|
||||
* - Appends the final text as a new text part.
|
||||
*/
|
||||
export function mergeFinalAssistantText(
|
||||
parts: ChatMessagePart[],
|
||||
finalText: string
|
||||
): ChatMessagePart[] {
|
||||
export function mergeFinalAssistantText(parts: ChatMessagePart[], finalText: string): ChatMessagePart[] {
|
||||
const dedupeReference = normalizeWs(finalText)
|
||||
|
||||
const kept = parts.filter(part => {
|
||||
|
|
|
|||
|
|
@ -125,12 +125,18 @@ describe('desktop filesystem facade', () => {
|
|||
|
||||
it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => {
|
||||
$connection.set({
|
||||
mode: 'remote', remoteKind: 'ssh', remoteHost: 'operator@remote-box', baseUrl: 'http://127.0.0.1:41001'
|
||||
mode: 'remote',
|
||||
remoteKind: 'ssh',
|
||||
remoteHost: 'operator@remote-box',
|
||||
baseUrl: 'http://127.0.0.1:41001'
|
||||
} as never)
|
||||
const first = desktopFsCacheKey()
|
||||
|
||||
$connection.set({
|
||||
mode: 'remote', remoteKind: 'ssh', remoteHost: 'operator@remote-box', baseUrl: 'http://127.0.0.1:52002'
|
||||
mode: 'remote',
|
||||
remoteKind: 'ssh',
|
||||
remoteHost: 'operator@remote-box',
|
||||
baseUrl: 'http://127.0.0.1:52002'
|
||||
} as never)
|
||||
|
||||
expect(desktopFsCacheKey()).toBe(first)
|
||||
|
|
@ -140,15 +146,27 @@ describe('desktop filesystem facade', () => {
|
|||
|
||||
it('separates SSH filesystem caches by ownership and profile', () => {
|
||||
$connection.set({
|
||||
mode: 'remote', remoteKind: 'ssh', remoteHost: 'host-a', remoteIdentity: 'owner-a', profile: 'one'
|
||||
mode: 'remote',
|
||||
remoteKind: 'ssh',
|
||||
remoteHost: 'host-a',
|
||||
remoteIdentity: 'owner-a',
|
||||
profile: 'one'
|
||||
} as never)
|
||||
const first = desktopFsCacheKey()
|
||||
$connection.set({
|
||||
mode: 'remote', remoteKind: 'ssh', remoteHost: 'host-a', remoteIdentity: 'owner-b', profile: 'one'
|
||||
mode: 'remote',
|
||||
remoteKind: 'ssh',
|
||||
remoteHost: 'host-a',
|
||||
remoteIdentity: 'owner-b',
|
||||
profile: 'one'
|
||||
} as never)
|
||||
const otherOwner = desktopFsCacheKey()
|
||||
$connection.set({
|
||||
mode: 'remote', remoteKind: 'ssh', remoteHost: 'host-a', remoteIdentity: 'owner-a', profile: 'two'
|
||||
mode: 'remote',
|
||||
remoteKind: 'ssh',
|
||||
remoteHost: 'host-a',
|
||||
remoteIdentity: 'owner-a',
|
||||
profile: 'two'
|
||||
} as never)
|
||||
|
||||
expect(otherOwner).not.toBe(first)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ function connectionCacheKey(connection: HermesConnection | null) {
|
|||
return 'local:'
|
||||
}
|
||||
|
||||
const target = connection.remoteKind === 'ssh'
|
||||
? connection.remoteIdentity || connection.remoteHost || ''
|
||||
: connection.baseUrl || ''
|
||||
const target =
|
||||
connection.remoteKind === 'ssh'
|
||||
? connection.remoteIdentity || connection.remoteHost || ''
|
||||
: connection.baseUrl || ''
|
||||
|
||||
return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}`
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue