From 45acb62c6d362da2dfafb5b1dd0d82dfa1888455 Mon Sep 17 00:00:00 2001 From: yoniebans Date: Fri, 17 Jul 2026 11:37:22 +0200 Subject: [PATCH] feat(desktop): route Desktop SSH to the Windows lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/electron/main.ts | 92 +++++-- apps/desktop/electron/remote-lifecycle.ts | 14 +- apps/desktop/electron/ssh-connection.test.ts | 58 +++- apps/desktop/electron/ssh-connection.ts | 63 ++++- .../electron/windows-remote-lifecycle.test.ts | 94 +++++++ .../electron/windows-remote-lifecycle.ts | 260 ++++++++++++++++++ .../electron/windows-remote-live.test.ts | 75 +++++ .../src/app/settings/gateway-settings.tsx | 14 +- apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/ja.ts | 2 +- apps/desktop/src/i18n/zh-hant.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- 12 files changed, 631 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/electron/windows-remote-lifecycle.test.ts create mode 100644 apps/desktop/electron/windows-remote-lifecycle.ts create mode 100644 apps/desktop/electron/windows-remote-live.test.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9adecb9d5bb3..b4d749b864ce 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 }) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index aec04ed19248..6f83bee12093 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -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), diff --git a/apps/desktop/electron/ssh-connection.test.ts b/apps/desktop/electron/ssh-connection.test.ts index d62499bd3523..62cbc29b08d7 100644 --- a/apps/desktop/electron/ssh-connection.test.ts +++ b/apps/desktop/electron/ssh-connection.test.ts @@ -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 () => { diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts index 3d8257fa9a13..d120ada74fab 100644 --- a/apps/desktop/electron/ssh-connection.ts +++ b/apps/desktop/electron/ssh-connection.ts @@ -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 } } diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts new file mode 100644 index 000000000000..5485f3d9e078 --- /dev/null +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -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/) +}) diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts new file mode 100644 index 000000000000..525cc644016b --- /dev/null +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -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 } diff --git a/apps/desktop/electron/windows-remote-live.test.ts b/apps/desktop/electron/windows-remote-live.test.ts new file mode 100644 index 000000000000..c5b3488cbd23 --- /dev/null +++ b/apps/desktop/electron/windows-remote-live.test.ts @@ -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) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 14dd680762f8..1987b0a37c5e 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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 } = { /> ) : ( void resolveSshHost(state.sshHost)} onChange={event => setState(current => selectSshHost(current, event.target.value))} value={state.sshHost} />} + action={ { + // 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} /> diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2caf5bf2dd87..6808dae85fa3 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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 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.' diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 9b6bc4b792a4..cec11e7704ac 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -737,7 +737,7 @@ export const ja = defineLocale({ '前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R を実行してから再接続してください。', 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 接続に失敗しました。' diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 2fbd81d55575..e4a6d7f93908 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -717,7 +717,7 @@ export const zhHant = defineLocale({ '自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R 並重新連線。', 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 連線失敗。' diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a050a2b3612a..966ecdb213b4 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -840,7 +840,7 @@ export const zh: Translations = { '自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R 并重新连接。', 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 连接失败。'