mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(desktop): carry the dev renderer URL through Windows SSH backends
connectWindowsRemote ignored devServer: the spawn payload never asked windows_ssh_runtime to set HERMES_DESKTOP_DEV_SERVER, the ownership record did not name a renderer, and reuse did not compare one. A dev build's renderer sockets through the SSH forward were then refused unless the Windows host had been configured by hand. Send devServer in the spawn payload and have the runtime helper set (or clear) HERMES_DESKTOP_DEV_SERVER from it, record it in both ownership writes, and decide reuse with the POSIX lockServesConnect predicate. Records without the field count as no dev server, so packaged builds reuse as before.
This commit is contained in:
1 parent
3d9a230d57
commit
98527bf2c5
5 files changed
+230
-14
No files matched your search
@@ -1838,6 +1838,7 @@ export {
|
||||
isForwardBindCollision,
|
||||
isLockfileSkew,
|
||||
listRemoteHermesProfiles,
|
||||
lockServesConnect,
|
||||
locateHermes,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
lockfilePath,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { connectWindowsRemote, decodePowerShellCommand } from './windows-remote-lifecycle'
|
||||
|
||||
const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef'
|
||||
const SPAWN_NONCE = '0123456789abcdef'
|
||||
const REUSE_TOKEN = 'stored-token'
|
||||
const HERMES_PATH = 'C:\\h\\hermes.exe'
|
||||
const HERMES_HOME = 'C:\\h'
|
||||
|
||||
function fingerprint(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
// A live backend this desktop owns, recorded as spawned for `devServer` (absent = an older or packaged record).
|
||||
function ownedLock(devServer?: string): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
protocolVersion: 1,
|
||||
ownershipId: OWNERSHIP_ID,
|
||||
spawnNonce: SPAWN_NONCE,
|
||||
pid: 333,
|
||||
creationTimeNs: '1784219690452757504',
|
||||
port: 40000,
|
||||
profile: '',
|
||||
hermesPath: HERMES_PATH,
|
||||
hermesHome: HERMES_HOME,
|
||||
tokenFingerprint: fingerprint(REUSE_TOKEN),
|
||||
startedAt: '2026-07-14T00:00:00.000Z',
|
||||
...(devServer === undefined ? {} : { devServer })
|
||||
}
|
||||
}
|
||||
|
||||
interface Exec {
|
||||
script: string
|
||||
stdinData: string
|
||||
}
|
||||
|
||||
// A Windows remote whose owned backend (`lock`, or none) passes every ownership check, and which
|
||||
// can replace it with pid 890. Every exec is a PowerShell -EncodedCommand; answer by its operation.
|
||||
function ownedWindowsSsh(lock: null | Record<string, unknown>) {
|
||||
const execs: Exec[] = []
|
||||
|
||||
return {
|
||||
execs,
|
||||
async exec(command: string, options: { stdinData?: string } = {}): Promise<string> {
|
||||
const script = decodePowerShellCommand(command)
|
||||
execs.push({ script, stdinData: String(options.stdinData ?? '') })
|
||||
const op = (name: string) => script.includes(`'hermes_cli.windows_ssh_runtime' '${name}'`)
|
||||
|
||||
if (op('spawn')) {
|
||||
return JSON.stringify({ pid: 890, creationTimeNs: '1784219690452757600' })
|
||||
}
|
||||
|
||||
if (script.includes('Get-Command hermes')) {
|
||||
return JSON.stringify({ os: 'Windows', arch: 'AMD64', hermesHome: HERMES_HOME, hermesPath: HERMES_PATH, python: 'C:\\h\\python.exe' })
|
||||
}
|
||||
|
||||
if (script.includes('.hermes-update-in-progress')) {
|
||||
return 'CLEAR'
|
||||
}
|
||||
|
||||
if (op('inspect')) {
|
||||
return JSON.stringify({ path: HERMES_PATH, version: 'Hermes Agent v1', runtimeBootstrapOwnership: 1, supported: true })
|
||||
}
|
||||
|
||||
if (op('read-lock')) {
|
||||
return JSON.stringify(lock)
|
||||
}
|
||||
|
||||
if (op('process-state')) {
|
||||
return JSON.stringify({ alive: true, owned: true, indeterminate: false })
|
||||
}
|
||||
|
||||
if (op('read-log')) {
|
||||
return JSON.stringify({ content: 'HERMES_DASHBOARD_READY port=52050\n' })
|
||||
}
|
||||
|
||||
return JSON.stringify({ ok: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectAs(ssh: ReturnType<typeof ownedWindowsSsh>, devServer: string) {
|
||||
return connectWindowsRemote({
|
||||
ssh,
|
||||
ownershipId: OWNERSHIP_ID,
|
||||
profile: '',
|
||||
reuseToken: REUSE_TOKEN,
|
||||
devServer,
|
||||
pickLocalPort: async () => 50001,
|
||||
forward: async () => {},
|
||||
cancelForward: async () => {},
|
||||
waitForHermes: async () => {},
|
||||
probeReuseProof: async () => 'authenticated-ok',
|
||||
readyTimeoutMs: 2000
|
||||
})
|
||||
}
|
||||
|
||||
const spawnOf = (ssh: ReturnType<typeof ownedWindowsSsh>) =>
|
||||
ssh.execs.find(({ script }) => script.includes("'hermes_cli.windows_ssh_runtime' 'spawn'"))?.script
|
||||
|
||||
const writtenLocks = (ssh: ReturnType<typeof ownedWindowsSsh>) =>
|
||||
ssh.execs
|
||||
.filter(({ script }) => script.includes("'hermes_cli.windows_ssh_runtime' 'write-lock'") && !script.includes("'spawn'"))
|
||||
.map(({ stdinData }) => JSON.parse(stdinData))
|
||||
|
||||
for (const [label, devServer, expected] of [
|
||||
['a dev build hands its renderer origin to the Windows serve', 'http://127.0.0.1:5175', 'http://127.0.0.1:5175'],
|
||||
['a packaged build hands the Windows serve no renderer origin', '', '']
|
||||
] as const) {
|
||||
test(`Windows spawn: ${label}`, async (): Promise<void> => {
|
||||
// The backend trusts a dev renderer's http origin only when it inherits HERMES_DESKTOP_DEV_SERVER;
|
||||
// windows_ssh_runtime.spawn_backend sets it from the payload's devServer, and only when non-empty.
|
||||
const ssh = ownedWindowsSsh(null)
|
||||
|
||||
const result = await connectAs(ssh, devServer)
|
||||
const spawn = spawnOf(ssh)
|
||||
|
||||
assert.equal(result.reused, false)
|
||||
assert.ok(spawn, 'a backend must be spawned')
|
||||
assert.ok(spawn.includes(`"devServer":"${expected}"`), 'the spawn payload names its renderer')
|
||||
assert.ok(spawn.includes(`devServer='${expected}'`), 'the record published under the spawn mutex names it')
|
||||
assert.deepEqual(
|
||||
writtenLocks(ssh).map(lock => lock.devServer),
|
||||
[expected, expected],
|
||||
'every record written for the new backend names its renderer'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const [label, recorded, current, reused] of [
|
||||
['the same dev renderer reuses its backend', 'http://127.0.0.1:5174', 'http://127.0.0.1:5174', true],
|
||||
['a dev renderer on another port replaces the backend', 'http://127.0.0.1:5174', 'http://127.0.0.1:5175', false],
|
||||
['a dev build replaces a backend a packaged build spawned', undefined, 'http://127.0.0.1:5175', false],
|
||||
['a packaged build replaces a backend a dev build spawned', 'http://127.0.0.1:5174', '', false],
|
||||
['a packaged build reuses a packaged backend', undefined, '', true]
|
||||
] as const) {
|
||||
test(`Windows SSH reuse: ${label}`, async (): Promise<void> => {
|
||||
// Reusing a backend spawned for another renderer would refuse every renderer socket.
|
||||
const ssh = ownedWindowsSsh(ownedLock(recorded))
|
||||
|
||||
const result = await connectAs(ssh, current)
|
||||
const spawn = spawnOf(ssh)
|
||||
|
||||
assert.equal(result.reused, reused)
|
||||
|
||||
if (reused) {
|
||||
assert.equal(result.pid, 333)
|
||||
assert.equal(spawn, undefined, 'a compatible backend must not be respawned')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.equal(result.pid, 890)
|
||||
assert.ok(
|
||||
ssh.execs.some(({ script }) => script.includes("'terminate' '333'")),
|
||||
'the owned backend must be reaped, not left running'
|
||||
)
|
||||
assert.ok(spawn?.includes(`"devServer":"${current}"`), 'the new backend is spawned for this renderer')
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'node:crypto'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
import { resolveReadyTimeoutMs } from './remote-lifecycle'
|
||||
import { lockServesConnect, resolveReadyTimeoutMs } from './remote-lifecycle'
|
||||
import { WINDOWS_CHECKOUT_LOCK_PS, WINDOWS_MARKER_JUDGE_PS } from './remote-update-marker-programs'
|
||||
import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection'
|
||||
import { WINDOWS_NO_REPARSE_PS, windowsHelperArgv } from './windows-runtime-command'
|
||||
@@ -470,7 +470,7 @@ function atomicWindowsSpawnCommand(runtime, reservation: any = {}, payload?: str
|
||||
? ' if($spawnExit -ne 0){exit $spawnExit}'
|
||||
: ' if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}',
|
||||
reservation.ownershipId
|
||||
? ` $spawned=$spawnLines[-1]|ConvertFrom-Json; $lock=[ordered]@{schemaVersion=2;protocolVersion=1;ownershipId=${psLiteral(reservation.ownershipId)};spawnNonce=${psLiteral(reservation.spawnNonce)};pid=[int]$spawned.pid;creationTimeNs=[string]$spawned.creationTimeNs;port=0;profile=${psLiteral(reservation.profile)};hermesPath=${psLiteral(reservation.hermesPath)};hermesHome=${psLiteral(reservation.hermesHome)};tokenFingerprint=${psLiteral(reservation.tokenFingerprint)};startedAt=${psLiteral(reservation.startedAt)}}|ConvertTo-Json -Compress; ` +
|
||||
? ` $spawned=$spawnLines[-1]|ConvertFrom-Json; $lock=[ordered]@{schemaVersion=2;protocolVersion=1;ownershipId=${psLiteral(reservation.ownershipId)};spawnNonce=${psLiteral(reservation.spawnNonce)};pid=[int]$spawned.pid;creationTimeNs=[string]$spawned.creationTimeNs;port=0;profile=${psLiteral(reservation.profile)};hermesPath=${psLiteral(reservation.hermesPath)};hermesHome=${psLiteral(reservation.hermesHome)};tokenFingerprint=${psLiteral(reservation.tokenFingerprint)};devServer=${psLiteral(reservation.devServer || '')};startedAt=${psLiteral(reservation.startedAt)}}|ConvertTo-Json -Compress; ` +
|
||||
` $lock | & ${helper('write-lock').map(psLiteral).join(' ')} ${psLiteral(reservation.ownershipId)} | Out-Null; if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}; $spawnLines|Write-Output`
|
||||
: '',
|
||||
' if([IO.File]::Exists($marker)){throw "remote update marker claimed during backend spawn"}',
|
||||
@@ -527,16 +527,17 @@ function validLock(lock, ownershipId) {
|
||||
)
|
||||
}
|
||||
|
||||
function reusableWindowsLock(lock, state, profile, reuseToken, runtime) {
|
||||
function reusableWindowsLock(lock, state, profile, reuseToken, runtime, devServer = '') {
|
||||
return Boolean(
|
||||
state.alive &&
|
||||
state.owned &&
|
||||
lock.port > 0 &&
|
||||
lock.profile === profile &&
|
||||
reuseToken &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === runtime.hermesPath &&
|
||||
lock.hermesHome === runtime.hermesHome
|
||||
lockServesConnect(lock, {
|
||||
profile,
|
||||
reuseToken,
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesHome: runtime.hermesHome,
|
||||
devServer
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -755,6 +756,7 @@ async function connectWindowsRemote(deps) {
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
reuseToken = '',
|
||||
devServer = '',
|
||||
signal,
|
||||
pickLocalPort,
|
||||
forward,
|
||||
@@ -799,7 +801,7 @@ async function connectWindowsRemote(deps) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const reusable = reusableWindowsLock(lock, state, profile, reuseToken, runtime)
|
||||
const reusable = reusableWindowsLock(lock, state, profile, reuseToken, runtime, devServer)
|
||||
|
||||
if (reusable) {
|
||||
await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome, runtime.python)
|
||||
@@ -863,7 +865,9 @@ async function connectWindowsRemote(deps) {
|
||||
spawned = await atomicWindowsSpawn(
|
||||
ssh,
|
||||
runtime,
|
||||
JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath }),
|
||||
// The backend trusts a dev renderer's http origin only from the HERMES_DESKTOP_DEV_SERVER it
|
||||
// inherits; the runtime helper sets it from devServer, as the POSIX spawn command does.
|
||||
JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath, devServer }),
|
||||
{
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
@@ -871,6 +875,7 @@ async function connectWindowsRemote(deps) {
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesHome: runtime.hermesHome,
|
||||
tokenFingerprint,
|
||||
devServer,
|
||||
startedAt
|
||||
}
|
||||
)
|
||||
@@ -912,6 +917,7 @@ async function connectWindowsRemote(deps) {
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesHome: runtime.hermesHome,
|
||||
tokenFingerprint,
|
||||
devServer,
|
||||
startedAt
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +380,22 @@ def _resolve_direct_command(hermes_path: str) -> list[str]:
|
||||
return command
|
||||
|
||||
|
||||
def _spawn_env(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""The serve's environment. A Desktop dev build's renderer is an http origin the backend trusts
|
||||
only from the HERMES_DESKTOP_DEV_SERVER it inherits, so the payload's ``devServer`` is handed
|
||||
down exactly as the POSIX spawn command does; without one the variable is not set at all."""
|
||||
dev_server = str(payload.get("devServer") or "")
|
||||
if len(dev_server) > 2048 or any(ch in dev_server for ch in "\x00\r\n"):
|
||||
raise ValueError("invalid devServer")
|
||||
env = dict(os.environ)
|
||||
env.pop("VIRTUAL_ENV", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("HERMES_DESKTOP_DEV_SERVER", None)
|
||||
if dev_server:
|
||||
env["HERMES_DESKTOP_DEV_SERVER"] = dev_server
|
||||
return env
|
||||
|
||||
|
||||
def spawn_backend(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
ownership_id = _ownership(str(payload["ownershipId"]))
|
||||
spawn_nonce = _nonce(str(payload["spawnNonce"]))
|
||||
@@ -396,9 +412,7 @@ def spawn_backend(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
args.extend(["--profile", profile])
|
||||
args.extend(["serve", "--isolated", "--host", "127.0.0.1", "--port", "0",
|
||||
"--ssh-session-token-file", token_path, "--ssh-owner-nonce", spawn_nonce])
|
||||
env = dict(os.environ)
|
||||
env.pop("VIRTUAL_ENV", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
env = _spawn_env(payload)
|
||||
_ensure_scope(ownership_id)
|
||||
log_path = _log_path(ownership_id, spawn_nonce)
|
||||
win32con = _win32().win32con
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""The Windows SSH runtime hands a Desktop dev renderer's origin to the serve it spawns."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.windows_ssh_runtime import _spawn_env
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload, expected", [
|
||||
({"devServer": "http://127.0.0.1:5175"}, "http://127.0.0.1:5175"), # a dev build's renderer
|
||||
({"devServer": ""}, None), # a packaged build
|
||||
({}, None), # an older Desktop
|
||||
])
|
||||
def test_spawn_env_carries_only_the_payloads_dev_renderer(monkeypatch, payload, expected):
|
||||
"""The backend trusts a dev renderer's http origin only from the HERMES_DESKTOP_DEV_SERVER it
|
||||
inherits, as the POSIX spawn command sets it. One the SSH session happens to export is not this
|
||||
Desktop's renderer, so it never leaks into the serve."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP_DEV_SERVER", "http://127.0.0.1:5999")
|
||||
monkeypatch.setenv("VIRTUAL_ENV", "C:\\venv")
|
||||
|
||||
env = _spawn_env(payload)
|
||||
|
||||
assert env.get("HERMES_DESKTOP_DEV_SERVER") == expected
|
||||
assert "VIRTUAL_ENV" not in env
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dev_server", ["http://127.0.0.1:5175\r\nX=1", "http://a\x00b", "h" * 2049])
|
||||
def test_spawn_env_refuses_a_malformed_dev_renderer(dev_server):
|
||||
with pytest.raises(ValueError, match="devServer"):
|
||||
_spawn_env({"devServer": dev_server})
|
||||
Reference in new issue
Block a user