mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
feat(desktop): route Desktop SSH to the Windows lifecycle
Detect the remote platform on connect and Test SSH: uname first (Linux/Darwin keep the POSIX lifecycle), else an encoded-PowerShell probe that routes a Windows host through connectWindowsRemote. The Windows lifecycle mirrors the POSIX one — probe, one-shot token upload, spawn, readiness scrape, tunnel, reuse-by-exact-ownership, and teardown — over a PowerShell helper dialect. Preserve a backend on indeterminate process state (never destroy on unknowns); a thrown terminate aborts before remove-lock so a live backend is never orphaned. Interactive terminals on a Windows backend open PowerShell in the requested cwd instead of a POSIX login shell.
This commit is contained in:
parent
00e9f6dd4d
commit
45acb62c6d
12 changed files with 631 additions and 47 deletions
|
|
@ -66,6 +66,7 @@ import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstra
|
|||
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,
|
||||
|
|
@ -811,6 +812,9 @@ function registerMediaProtocol() {
|
|||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
// Consecutive indeterminate liveness-probe failures for the cached remote
|
||||
// backend; teardown fires only on proof (refused) or a full streak.
|
||||
let revalidateTimeoutStreak = 0
|
||||
// True while connection-config:apply soft-rehomes the primary — suppresses the
|
||||
// backend-exit toast so an intentional kill doesn't look like a crash.
|
||||
let softRehomeInProgress = false
|
||||
|
|
@ -6286,7 +6290,9 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
|
||||
let result
|
||||
try {
|
||||
result = await remoteLifecycle.connect({
|
||||
const platform = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '')
|
||||
const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect
|
||||
result = await lifecycle({
|
||||
ssh,
|
||||
profile: connectionScopeKey(profile) || '',
|
||||
remoteHermesPath: sshConfig.remoteHermesPath || '',
|
||||
|
|
@ -6333,6 +6339,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
host: sshConfig.host,
|
||||
hostLabel,
|
||||
hermesVersion: result.hermesVersion || '',
|
||||
remotePlatform: result.platform?.os || '',
|
||||
reused: result.reused
|
||||
})
|
||||
|
||||
|
|
@ -6551,18 +6558,45 @@ async function testDesktopConnectionConfig(input: any = {}) {
|
|||
{ rememberLog: sshRememberLog }
|
||||
)
|
||||
try {
|
||||
await ssh.open()
|
||||
const platform = await remoteLifecycle.probeRemotePlatform(ssh)
|
||||
const hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')
|
||||
const hermesVersion = await remoteLifecycle.probeHermesVersion(ssh, hermesPath)
|
||||
if (!(await remoteLifecycle.remoteSupportsSshOwnership(ssh, hermesPath))) {
|
||||
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,
|
||||
remotePlatform: `${platform.os}/${platform.arch}`,
|
||||
remoteHermesPath: hermesPath, remoteHermesVersion: hermesVersion,
|
||||
host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
|
||||
// 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()
|
||||
const platform: any = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '')
|
||||
let hermesPath
|
||||
let hermesVersion
|
||||
let supported
|
||||
if (platform.os === 'Windows') {
|
||||
const runtime = platform
|
||||
hermesPath = runtime.hermesPath
|
||||
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
|
||||
hermesVersion = inspection.version
|
||||
supported = inspection.supported
|
||||
} else {
|
||||
hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')
|
||||
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: true, sshError: null, error: null,
|
||||
remotePlatform: `${platform.os}/${platform.arch}`,
|
||||
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 }
|
||||
|
|
@ -6658,6 +6692,10 @@ function stopBackendChild(child) {
|
|||
function resetHermesConnection({ soft = false } = {}) {
|
||||
connectionPromise = null
|
||||
backendStartFailure = null
|
||||
// A new connection generation must not inherit an old backend's timeout
|
||||
// streak — one inherited strike would revert its first slow probe to
|
||||
// single-timeout teardown aggression.
|
||||
revalidateTimeoutStreak = 0
|
||||
|
||||
stopBackendChild(hermesProcess)
|
||||
|
||||
|
|
@ -7713,13 +7751,23 @@ ipcMain.handle('hermes:connection:revalidate', async () => {
|
|||
const base = conn.baseUrl.replace(/\/+$/, '')
|
||||
|
||||
try {
|
||||
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 })
|
||||
|
||||
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 10_000 })
|
||||
revalidateTimeoutStreak = 0
|
||||
return { ok: true, rebuilt: false }
|
||||
} catch {
|
||||
// Unreachable remote: drop the stale cache so the renderer's next reconnect
|
||||
// tick rebuilds a fresh, reachable descriptor. resetHermesConnection only
|
||||
// nulls connectionPromise for a remote (no child to SIGTERM).
|
||||
} catch (error: any) {
|
||||
// A timeout is indeterminate (slow VM, idle tunnel re-establishing) — not
|
||||
// proof of death. Tear down only on proof (connection refused) or after a
|
||||
// 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()
|
||||
|
|
@ -8938,12 +8986,16 @@ 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 ptyProcess = remote
|
||||
? nodePty.spawn(
|
||||
process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh',
|
||||
buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim()),
|
||||
buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand),
|
||||
{ cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows }
|
||||
)
|
||||
: nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows })
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ async function probeRemotePlatform(ssh) {
|
|||
const arch = (out[1] || '').trim()
|
||||
if (!SUPPORTED_REMOTE_OS.has(osName)) {
|
||||
const err: any = new Error(
|
||||
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux and macOS remote hosts only.`
|
||||
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.`
|
||||
)
|
||||
err.kind = 'unsupported-platform'
|
||||
throw err
|
||||
|
|
@ -236,7 +236,9 @@ async function readLockfile(ssh, ownershipId) {
|
|||
const pid = parsed.pid
|
||||
const port = parsed.port
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) return null
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
|
||||
// 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
|
||||
|
|
@ -554,7 +556,7 @@ async function connect(deps) {
|
|||
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 && Boolean(reuseToken) &&
|
||||
const reusable = pidAlive && owned && lock.port > 0 && Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === hermesPath && lock.hermesHome === hermesHome
|
||||
if (reusable) {
|
||||
|
|
@ -637,6 +639,12 @@ async function connect(deps) {
|
|||
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
|
||||
// lockless orphan — the next connect reaps it by exact ownership via this
|
||||
// record. Inside the try: if this write itself fails, the catch still
|
||||
// kills the just-spawned process via the in-memory record.
|
||||
await writeLockfile(ssh, ownershipId, ownedSpawn)
|
||||
remotePort = await scrapeReadyPort(ssh, logPath, {
|
||||
timeoutMs: readyTimeoutMs,
|
||||
isAlive: () => remotePidAlive(ssh, pid),
|
||||
|
|
|
|||
|
|
@ -239,15 +239,51 @@ test('open() establishes the master when not already alive', async () => {
|
|||
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
|
||||
})
|
||||
|
||||
test('open() is a no-op when the master is already alive', async () => {
|
||||
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' : 'master')
|
||||
return { code: 0 } // check succeeds → already alive
|
||||
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'], 'alive master → no second spawn to open it')
|
||||
assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')
|
||||
})
|
||||
|
||||
test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => {
|
||||
// The macOS mode-switch wedge: ControlPersist master answers -O check but
|
||||
// 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 }
|
||||
})
|
||||
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')
|
||||
})
|
||||
|
||||
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 }
|
||||
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
|
||||
await conn.close()
|
||||
assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh')
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('open() creates the control-socket directory if it does not exist', async () => {
|
||||
|
|
@ -351,7 +387,7 @@ 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] === 'true'), '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 () => {
|
||||
|
|
@ -663,15 +699,17 @@ test('closing one scope addresses only that scope control master', async () => {
|
|||
assert.equal(second._opened, true)
|
||||
})
|
||||
|
||||
test('failed ControlMaster close remains retryable', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }, { code: 0 }])
|
||||
test('failed ControlMaster close disowns the master instead of retrying it', async () => {
|
||||
// Old contract kept _opened=true for a retry — which left wedged ControlPersist
|
||||
// masters trusted and reattachable (the macOS mode-switch livelock). New
|
||||
// contract: a master that refuses -O exit is disowned — socket dropped,
|
||||
// connection marked closed — so the next open dials fresh.
|
||||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
conn._opened = true
|
||||
await conn.close()
|
||||
assert.equal(conn._opened, true)
|
||||
await conn.close()
|
||||
assert.equal(conn._opened, false)
|
||||
assert.equal(spawnFn.calls.length, 2)
|
||||
assert.equal(spawnFn.calls.length, 1)
|
||||
})
|
||||
|
||||
test('stopTunnelChild waits for process exit', async () => {
|
||||
|
|
|
|||
|
|
@ -178,11 +178,14 @@ 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?) {
|
||||
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteCommand?) {
|
||||
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) {
|
||||
// cd then exec a login shell; quote the path; tolerate a missing dir.
|
||||
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
|
||||
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
|
||||
} else {
|
||||
|
|
@ -405,14 +408,22 @@ class SshConnection {
|
|||
// reachability with a one-shot `ssh true` so failures classify identically.
|
||||
async open() {
|
||||
if (await this.isAlive()) {
|
||||
this._opened = true
|
||||
return
|
||||
// -O check passing is not proof the master works: a ControlPersist master
|
||||
// can survive a failed teardown with wedged channels (observed on macOS
|
||||
// after a mode switch — check succeeds, every exec times out). Verify with
|
||||
// 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, 'true', this._connectTimeoutMs), {
|
||||
result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
|
|
@ -466,7 +477,7 @@ class SshConnection {
|
|||
if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) return false
|
||||
const args = this._mux
|
||||
? buildControlArgs(this, 'check', [], this._connectTimeoutMs)
|
||||
: buildExecArgs(this, 'true', this._connectTimeoutMs)
|
||||
: buildExecArgs(this, 'exit 0', this._connectTimeoutMs)
|
||||
try {
|
||||
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
return result.code === 0
|
||||
|
|
@ -475,6 +486,38 @@ class SshConnection {
|
|||
}
|
||||
}
|
||||
|
||||
// A real exec through the master (`exit 0` works under POSIX shells and
|
||||
// cmd.exe); a wedged mux hangs to the timeout.
|
||||
async _verifyMuxChannel() {
|
||||
try {
|
||||
const result: any = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// -O exit (best-effort) then drop the socket so ControlMaster=auto cannot
|
||||
// re-attach to the corpse. (The orphaned master process is left to
|
||||
// ControlPersist; a wedged channel can pin it, but without its socket it is
|
||||
// inert.)
|
||||
async _evictStaleMaster() {
|
||||
try {
|
||||
await runSsh(buildControlArgs(this, 'exit', [], this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
} catch {}
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot remote command over the control connection. Resolves stdout;
|
||||
// rejects with a classified error on non-zero exit or timeout.
|
||||
async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) {
|
||||
|
|
@ -609,10 +652,14 @@ class SshConnection {
|
|||
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
if (result.code !== 0) throw this._fail(result.stderr)
|
||||
this._logLine('control master closed')
|
||||
this._opened = false
|
||||
} catch (error: any) {
|
||||
this._logLine(`close failed (retryable): ${error.message}`)
|
||||
// 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 {}
|
||||
}
|
||||
this._opened = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
94
apps/desktop/electron/windows-remote-lifecycle.test.ts
Normal file
94
apps/desktop/electron/windows-remote-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildWindowsInteractiveCommand,
|
||||
detectRemotePlatform,
|
||||
encodedPowerShell,
|
||||
helperCommand,
|
||||
powerShellCommand,
|
||||
psLiteral,
|
||||
validLock
|
||||
} from './windows-remote-lifecycle'
|
||||
|
||||
const ownershipId = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
function sshWith(exec) {
|
||||
return { exec }
|
||||
}
|
||||
|
||||
test('PowerShell transport uses UTF-16LE encoded commands and literal escaping', () => {
|
||||
assert.equal(Buffer.from(encodedPowerShell("'ok'"), 'base64').toString('utf16le'), "'ok'")
|
||||
assert.equal(psLiteral("a'b"), "'a''b'")
|
||||
assert.match(powerShellCommand('Write-Output ok'), /^powershell\.exe -NoProfile -NonInteractive .* -EncodedCommand /)
|
||||
})
|
||||
|
||||
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' })
|
||||
}))
|
||||
assert.equal(result.os, 'Windows')
|
||||
assert.match(calls[1], /EncodedCommand/)
|
||||
})
|
||||
|
||||
test('platform detection surfaces transport failures as themselves, not unsupported-platform', async () => {
|
||||
// A dead/unauthorized host is a connectivity verdict; only a host that answers
|
||||
// neither probe is an unsupported platform.
|
||||
const transportErr: any = new Error('SSH connection timed out')
|
||||
transportErr.kind = 'timeout'
|
||||
await assert.rejects(
|
||||
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.')
|
||||
})),
|
||||
(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 encoded = command.split(' ').pop()!
|
||||
const script = Buffer.from(encoded, 'base64').toString('utf16le')
|
||||
assert.match(script, /-m' 'hermes_cli\.windows_ssh_runtime' 'inspect'/)
|
||||
assert.match(script, /Hermes''s/)
|
||||
assert.match(script, /C:\\x y\\hermes\.exe/)
|
||||
})
|
||||
|
||||
test('Windows lock validation is scoped and exact', () => {
|
||||
const lock = {
|
||||
schemaVersion: 2,
|
||||
protocolVersion: 1,
|
||||
ownershipId,
|
||||
spawnNonce: '0123456789abcdef',
|
||||
pid: 10,
|
||||
creationTimeNs: '1784219690452757504',
|
||||
port: 1234,
|
||||
tokenFingerprint: 'a'.repeat(32),
|
||||
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)
|
||||
// port 0 = spawn-in-progress record: valid ownership proof (cleanup can act
|
||||
// on it) but the reuse gate must reject it separately.
|
||||
assert.equal(validLock({ ...lock, port: 0 }, ownershipId), true)
|
||||
assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false)
|
||||
})
|
||||
|
||||
test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => {
|
||||
const command = buildWindowsInteractiveCommand("C:\\Users\\O'Brien\\repo")
|
||||
const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le')
|
||||
assert.match(script, /Set-Location -LiteralPath 'C:\\Users\\O''Brien\\repo'/)
|
||||
assert.match(script, /powershell\.exe -NoLogo/)
|
||||
})
|
||||
260
apps/desktop/electron/windows-remote-lifecycle.ts
Normal file
260
apps/desktop/electron/windows-remote-lifecycle.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import crypto from 'node:crypto'
|
||||
|
||||
import { SSH_ERROR, redactSecrets } from './ssh-connection'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
const PROTOCOL_VERSION = 1
|
||||
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/gm
|
||||
const READY_POLL_INTERVAL_MS = 750
|
||||
|
||||
function psLiteral(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
function encodedPowerShell(script) {
|
||||
return Buffer.from(script, 'utf16le').toString('base64')
|
||||
}
|
||||
|
||||
function powerShellCommand(script) {
|
||||
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedPowerShell(script)}`
|
||||
}
|
||||
|
||||
async function probeWindowsRemote(ssh, explicitHermesPath = '') {
|
||||
const explicit = psLiteral(explicitHermesPath)
|
||||
const script = [
|
||||
'$ErrorActionPreference="Stop"',
|
||||
`$explicit=${explicit}`,
|
||||
'$hermesHome=$env:HERMES_HOME',
|
||||
'if(-not $hermesHome){$hermesHome=Join-Path $env:LOCALAPPDATA "hermes"}',
|
||||
'$candidates=@()',
|
||||
'if($explicit){$candidates+=$explicit}',
|
||||
'$cmd=Get-Command hermes.exe -ErrorAction SilentlyContinue',
|
||||
'if($cmd){$candidates+=$cmd.Source}',
|
||||
'$candidates+=(Join-Path $hermesHome "hermes-agent\\venv\\Scripts\\hermes.exe")',
|
||||
'$candidates+=(Join-Path $HOME "hermes-agent\\.venv\\Scripts\\hermes.exe")',
|
||||
'$hermes=$candidates|Where-Object{Test-Path -LiteralPath $_ -PathType Leaf}|Select-Object -First 1',
|
||||
'if(-not $hermes){throw "Hermes is not installed on the remote Windows host."}',
|
||||
'if($explicit -and $hermes -ne $explicit){throw "The configured Hermes path is not an executable file."}',
|
||||
'$python=Join-Path (Split-Path $hermes) "python.exe"',
|
||||
'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])
|
||||
|
||||
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] || '' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 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
|
||||
}
|
||||
try {
|
||||
return await probeWindowsRemote(ssh, explicitHermesPath)
|
||||
} catch (cause: any) {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
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(';')
|
||||
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 parsed = JSON.parse(lines[lines.length - 1] || 'null')
|
||||
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)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
function assertCurrent(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function processState(ssh, runtime, lock) {
|
||||
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 {} }
|
||||
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])
|
||||
}
|
||||
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)}`)
|
||||
error.kind = 'spawn-failed'
|
||||
throw error
|
||||
}
|
||||
let content = ''
|
||||
try { content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' } catch {}
|
||||
let 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
|
||||
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
|
||||
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 }
|
||||
}
|
||||
if (classification !== 'authenticated-stale') throw new Error('Invalid SSH reuse classification.')
|
||||
await cancelForward(localPort, lock.port)
|
||||
await cleanupOwned(ssh, runtime, ownershipId, lock)
|
||||
} catch (error) {
|
||||
await cancelForward(localPort, lock.port)
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
await cleanupOwned(ssh, runtime, ownershipId, lock)
|
||||
}
|
||||
} else if (lock) {
|
||||
await helper(ssh, runtime, 'remove-lock', [ownershipId])
|
||||
}
|
||||
|
||||
assertCurrent(signal)
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
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 }))
|
||||
} 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() }
|
||||
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
|
||||
// connect still finds the lock and reaps the process by exact ownership.
|
||||
// Inside the try: if this write itself fails, the catch still kills the
|
||||
// just-spawned process via the in-memory record.
|
||||
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify(owned))
|
||||
remotePort = await waitReady(ssh, runtime, ownershipId, owned, readyTimeoutMs, signal)
|
||||
localPort = await pickLocalPort()
|
||||
await forward(localPort, remotePort)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
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 }
|
||||
} catch (error) {
|
||||
if (localPort && remotePort) await cancelForward(localPort, remotePort)
|
||||
await cleanupOwned(ssh, runtime, ownershipId, owned)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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)}}`)
|
||||
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 }
|
||||
75
apps/desktop/electron/windows-remote-live.test.ts
Normal file
75
apps/desktop/electron/windows-remote-live.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { SshConnection, pickLocalPort } 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
|
||||
// your test rig; skipped everywhere else (CI, other machines).
|
||||
// HERMES_WIN_SSH_HOST ssh alias/host of the Windows box
|
||||
// HERMES_WIN_SSH_USER remote user
|
||||
// HERMES_WIN_SSH_HERMES absolute path to the remote hermes.exe under test
|
||||
const liveHost = process.env.HERMES_WIN_SSH_HOST || ''
|
||||
const liveUser = process.env.HERMES_WIN_SSH_USER || ''
|
||||
const configuredHermes = process.env.HERMES_WIN_SSH_HERMES || ''
|
||||
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()}`)
|
||||
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}'"`)
|
||||
}
|
||||
await ssh.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
|
@ -351,7 +351,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
|
||||
|
||||
useEffect(() => {
|
||||
setSshCustomHost(Boolean(state.sshHost && !sshHostSuggestions.includes(state.sshHost)))
|
||||
// One-directional: a saved host that isn't in the suggestions must render
|
||||
// the free-text input (rehydration). Never force custom OFF here — that
|
||||
// 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)
|
||||
}, [state.sshHost, sshHostSuggestions])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1198,7 +1203,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
/>
|
||||
) : (
|
||||
<ListRow
|
||||
action={<Input className={cn('h-8', CONTROL_TEXT)} onBlur={() => 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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -652,7 +652,7 @@ 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 and macOS remote hosts only.',
|
||||
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.'
|
||||
|
|
|
|||
|
|
@ -737,7 +737,7 @@ 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 のリモートホストのみ対応しています。',
|
||||
sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。',
|
||||
sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',
|
||||
sshErrUpdateRequired: 'Desktop SSH で接続する前に、リモートホストの Hermes を更新してください。',
|
||||
sshErrUnknown: 'SSH 接続に失敗しました。'
|
||||
|
|
|
|||
|
|
@ -717,7 +717,7 @@ export const zhHant = defineLocale({
|
|||
'自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
|
||||
sshErrNotInstalled:
|
||||
'遠端主機上未安裝 Hermes。請在遠端安裝(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或設定 Hermes 路徑。',
|
||||
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式僅支援 Linux 和 macOS 遠端主機。',
|
||||
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式支援 Linux、macOS 和 Windows 遠端主機。',
|
||||
sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。',
|
||||
sshErrUpdateRequired: '使用 Desktop SSH 連線前,請更新遠端主機上的 Hermes。',
|
||||
sshErrUnknown: 'SSH 連線失敗。'
|
||||
|
|
|
|||
|
|
@ -840,7 +840,7 @@ export const zh: Translations = {
|
|||
'自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
|
||||
sshErrNotInstalled:
|
||||
'远程主机上未安装 Hermes。请在远程安装(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或设置 Hermes 路径。',
|
||||
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式仅支持 Linux 和 macOS 远程主机。',
|
||||
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式支持 Linux、macOS 和 Windows 远程主机。',
|
||||
sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。',
|
||||
sshErrUpdateRequired: '使用 Desktop SSH 连接前,请更新远程主机上的 Hermes。',
|
||||
sshErrUnknown: 'SSH 连接失败。'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue