fix(desktop): stream Windows update-marker probe over stdin

The marker probe (~8.3k chars encoded) was the second Windows SSH
command still over cmd.exe's 8191-char limit after the platform probe
moved to stdin: it died with 'The command line is too long' and the
relaunch gate could never observe CLEAR. Ride the same stdin transport
(powerShellStdinCommand) instead of splitting the probe, keeping the
C# no-follow marker reader and its fail-closed semantics intact, and
assert every emitted Windows probe command stays under the limit.

Co-authored-by: nikkoxgonzales <nikkoxgonzales@users.noreply.github.com>

Fixes #106716
Refs #106903
This commit is contained in:
Brooklyn Nicholson
2026-09-29 22:14:19 -05:00
committed by brooklyn!
parent 25d7de6fad
commit 02a802a7ef
2 changed files with 295 additions and 17 deletions

View File

@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'
import { exec as execCallback, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import fs from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
@@ -154,20 +154,20 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
// (MissingCatchOrFinally), so no probe may join a handler onto a separate
// statement. The line-oriented builders join with `;`; the pair must live
// in one array element.
const decode = (command: string) => Buffer.from(command.split(' ').at(-1) || '', 'base64').toString('utf16le')
const decode = (command: string, options: SshExecOptions = {}) => scriptFromInvocation(command, options)
const scripts: string[] = []
await probeWindowsRemote(
sshWith(async command => {
scripts.push(decode(command))
sshWith(async (command, options) => {
scripts.push(decode(command, options))
return JSON.stringify({ os: 'Windows', arch: 'AMD64' })
})
)
await assertWindowsRemoteInstallUpdateClear(
sshWith(async command => {
scripts.push(decode(command))
sshWith(async (command, options) => {
scripts.push(decode(command, options))
return 'CLEAR'
}),
@@ -242,14 +242,14 @@ test('Windows gates keep a dead marker while the checkout lock or a lease byte i
// Review G1: a dead claim is deleted (spawn) or reported CLEAR (probe) only
// after the update_lock byte range -- owner byte + 16 R5b lease bytes at
// 1048576 -- of the installer checkout and the runtime python's checkout is free.
const decode = (command: string) => Buffer.from(command.split(' ').at(-1) || '', 'base64').toString('utf16le')
const decode = (command: string, options: SshExecOptions = {}) => scriptFromInvocation(command, options)
const runtime = { hermesHome: 'C:\\Users\\alice\\.hermes', python: 'C:\\src\\hermes\\venv\\Scripts\\python.exe' }
const spawnScript = decode(atomicWindowsSpawnCommand(runtime))
let probeScript = ''
const observed = await assertWindowsRemoteInstallUpdateClear(
sshWith(async command => {
probeScript = decode(command)
sshWith(async (command, options) => {
probeScript = decode(command, options)
return 'HELD'
}),
@@ -333,11 +333,276 @@ test('Windows probe validates Hermes and Python topology before selection', asyn
assert.ok(pythonCheck < output)
})
import { exec as execCallback, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises' }
const CLEAN_PROBE_RESULT = JSON.stringify({
os: 'Windows',
arch: 'AMD64',
hermesHome: 'C:\\h',
hermesPath: 'C:\\h\\hermes.exe',
python: 'C:\\h\\python.exe'
})
const CLIXML_PROGRESS =
'#< CLIXML <Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/maml/2004/10"><Obj S="progress" RefId="0">正在标准配置首次使用模块</Obj></Objs>'
test('Windows probe script silences the PowerShell progress stream', async () => {
let script = ''
await probeWindowsRemote(
sshWith(async (command, options) => {
script = scriptFromInvocation(command, options)
return CLEAN_PROBE_RESULT
})
)
assert.ok(script.indexOf('$ProgressPreference="SilentlyContinue"') >= 0)
assert.ok(script.indexOf('$ProgressPreference') < script.indexOf('$ErrorActionPreference'))
})
test('Windows probe tolerates CLIXML progress-stream pollution around the probe JSON', async () => {
const pollutedOutputs = [
`${CLIXML_PROGRESS}\r\n${CLEAN_PROBE_RESULT}`,
`${CLIXML_PROGRESS}${CLEAN_PROBE_RESULT}`,
`\uFEFF${CLEAN_PROBE_RESULT}\r\n${CLIXML_PROGRESS}`
]
for (const output of pollutedOutputs) {
const parsed = await probeWindowsRemote(sshWith(async () => output))
assert.equal(parsed.os, 'Windows')
assert.equal(parsed.arch, 'AMD64')
assert.equal(parsed.hermesPath, 'C:\\h\\hermes.exe')
assert.equal(parsed.python, 'C:\\h\\python.exe')
}
})
test('Windows probe still rejects output that is not platform JSON', async () => {
await assert.rejects(probeWindowsRemote(sshWith(async () => 'hermes is not installed on this host')))
await assert.rejects(
probeWindowsRemote(
sshWith(async () => JSON.stringify({ os: 'Windows' }) + '\n' + JSON.stringify({ unrelated: true }))
)
)
})
test('the update marker gate stays CLEAR when CLIXML lands after the final Write-Output', async () => {
// The gate is fail-closed: it only accepts a strict `CLEAR` from the last
// line. A progress record serialized after `Write-Output $result` used to win
// the .pop() and refuse SSH startup as 'update-in-progress' — a misleading
// safety verdict from a cosmetic stdout race, not an actual update.
for (const observation of [
`CLEAR\r\n${CLIXML_PROGRESS}`,
`${CLIXML_PROGRESS}\r\nCLEAR`,
`\uFEFFCLEAR\r\n${CLIXML_PROGRESS}\r\n${CLIXML_PROGRESS}`
]) {
await assertWindowsRemoteInstallUpdateClear(
sshWith(async () => observation),
'C:\\h'
)
}
// The verdict itself is untouched: a LIVE marker still pauses startup.
await assert.rejects(
assertWindowsRemoteInstallUpdateClear(
sshWith(async () => `LIVE:4242\r\n${CLIXML_PROGRESS}`),
'C:\\h'
),
(err: any) => err.kind === 'update-in-progress' && /4242/.test(err.message)
)
})
test('every parsed Windows PowerShell script silences the progress stream', async () => {
// The marker gate runs Add-Type (C# compilation) and the spawn/helper scripts
// import the remote hermes_cli module — both emit progress records the
// probe's Get-Item/Get-Command suppression knows nothing about, on the same
// stdout the parsers read.
const decode = (command: string, options: SshExecOptions = {}) => scriptFromInvocation(command, options)
const scripts: string[] = []
await assertWindowsRemoteInstallUpdateClear(
sshWith(async (command, options) => {
scripts.push(decode(command, options))
return 'CLEAR'
}),
'C:\\h'
)
scripts.push(
decode(atomicWindowsSpawnCommand({ hermesHome: 'C:\\h', python: 'C:\\py\\python.exe' })),
decode(helperCommand({ python: 'C:\\py\\python.exe' }, 'inspect', []))
)
assert.equal(scripts.length, 3)
for (const script of scripts) {
assert.ok(script.indexOf('$ProgressPreference="SilentlyContinue"') >= 0, script.slice(0, 80))
assert.ok(script.indexOf('$ProgressPreference') < script.indexOf('$ErrorActionPreference'), script.slice(0, 80))
}
})
test('helper parsing ignores CLIXML progress blocks around its JSON line', async () => {
const payload = JSON.stringify({ supported: true, version: '1.2.3' })
for (const output of [`${CLIXML_PROGRESS}\r\n${payload}`, `\uFEFF${payload}\r\n${CLIXML_PROGRESS}`, `${payload}`]) {
const parsed = await helper(
sshWith(async () => output),
{ python: 'C:\\py\\python.exe' },
'inspect',
['C:\\h\\hermes.exe']
)
assert.equal(parsed.supported, true)
assert.equal(parsed.version, '1.2.3')
}
})
test('Windows platform probe streams long PowerShell scripts over stdin', async () => {
let command = ''
let stdinData = ''
const result = await probeWindowsRemote({
async exec(nextCommand: string, options: SshExecOptions = {}) {
command = nextCommand
stdinData = String(options.stdinData || '')
return JSON.stringify({
os: 'Windows',
arch: 'AMD64',
hermesHome: 'C:\\\\h',
hermesPath: 'C:\\\\h\\\\hermes.exe',
python: 'C:\\\\h\\\\python.exe'
})
}
})
assert.equal(result.os, 'Windows')
assert.match(command, /-Command \[ScriptBlock\]::Create/)
assert.doesNotMatch(command, /-EncodedCommand/)
assert.ok(command.length < 1024)
assert.match(stdinData, /^[A-Za-z0-9+/=\r\n]+$/)
const decodedScript = Buffer.from(stdinData.trim(), 'base64').toString('utf16le')
assert.match(decodedScript, /Assert-NoReparse/)
})
test('Windows platform probe preserves Unicode paths in its UTF-16LE stdin payload', async () => {
let stdinData = ''
await probeWindowsRemote(
{
async exec(_command: string, options: SshExecOptions = {}) {
stdinData = String(options.stdinData || '')
return JSON.stringify({
os: 'Windows',
arch: 'AMD64',
hermesHome: 'C:\\\\h',
hermesPath: 'C:\\\\h\\\\hermes.exe',
python: 'C:\\\\h\\\\python.exe'
})
}
},
'C:\\Users\\한글\\hermes.exe'
)
const decodedScript = Buffer.from(stdinData.trim(), 'base64').toString('utf16le')
assert.ok(decodedScript.includes('C:\\Users\\한글\\hermes.exe'))
})
test('every Windows probe command fits the cmd.exe 8191-char limit (#106716)', async () => {
// cmd.exe (the default OpenSSH shell on Windows) rejects command lines of
// 8192+ chars with "The command line is too long", which used to surface as
// a misleading unsupported-platform error. Every command the Windows remote
// lifecycle sends must stay under that limit; long probe scripts travel
// over stdin instead.
const commands: string[] = []
const ssh = sshWith(async (command, options) => {
commands.push(command)
const script = scriptFromInvocation(command, options)
if (script.includes('.hermes-update-in-progress')) {
return 'CLEAR'
}
return JSON.stringify({
os: 'Windows',
arch: 'AMD64',
hermesHome: 'C:\\h',
hermesPath: 'C:\\h\\hermes.exe',
python: 'C:\\h\\python.exe'
})
})
// Worst-case path lengths: an explicit path and home near the deeper end of
// what the connection settings allow.
const longHome = `C:\\Users\\${'u'.repeat(100)}\\.hermes\\profiles\\${'p'.repeat(100)}`
const longExplicit = `C:\\${'h'.repeat(200)}\\hermes.exe`
await probeWindowsRemote(ssh, longExplicit)
await assertWindowsRemoteInstallUpdateClear(ssh, longHome)
commands.push(
helperCommand({ python: `${longHome}\\python.exe` }, 'inspect', [longExplicit]),
buildWindowsInteractiveCommand(longHome)
)
assert.ok(commands.length >= 4)
for (const command of commands) {
assert.ok(
command.length < 8191,
`probe command is ${command.length} chars (limit 8191): ${command.slice(0, 80)}...`
)
}
})
function runLocalWindowsCommand(command, stdinData) {
return new Promise((resolve, reject) => {
const child = spawn('cmd.exe', ['/d', '/s', '/c', command], { windowsHide: true })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('close', code => {
const out = Buffer.concat(stdout).toString('utf8')
if (code === 0) {
resolve(out)
return
}
reject(new Error(Buffer.concat(stderr).toString('utf8') || `command exited with ${code}`))
})
child.stdin.end(stdinData || '')
})
}
test.skipIf(process.platform !== 'win32')('Windows platform probe executes in real PowerShell', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-probe-'))
const hermesPath = path.join(tempDir, 'hermes.exe')
try {
fs.writeFileSync(hermesPath, '')
fs.writeFileSync(path.join(tempDir, 'python.exe'), '')
const result = await probeWindowsRemote(
{
exec: (command: string, options: SshExecOptions = {}) => runLocalWindowsCommand(command, options.stdinData)
},
hermesPath
)
assert.equal(result.os, 'Windows')
assert.equal(result.hermesPath, hermesPath)
assert.equal(result.python, path.join(tempDir, 'python.exe'))
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}})
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[] = []

View File

@@ -42,7 +42,6 @@ function stripPowerShellNoise(stdout) {
function powerShellStdinCommand() {
return 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command [ScriptBlock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd()))).Invoke()'
}
}
async function probeWindowsRemote(ssh, explicitHermesPath = '') {
const explicit = psLiteral(explicitHermesPath)
@@ -126,7 +125,10 @@ function windowsCheckoutRootsScript(python = '') {
return `$checkoutRoots=@([IO.Path]::Combine($installRoot,"hermes-agent"),${runtimeRoot})`
}
function windowsUpdateMarkerProbeCommand(hermesHome, python = '') {
// Marker-gate probe script: reads the install-wide update marker fail-closed
// (missing -> CLEAR, unreadable/malformed -> UNCERTAIN) without following
// symlinks on any path level, including the marker file itself.
function windowsUpdateMarkerProbeScript(hermesHome, python = '') {
const script = [
'$ProgressPreference="SilentlyContinue"',
'$ErrorActionPreference="Stop"',
@@ -178,9 +180,16 @@ public static class HermesMarkerNoFollow {
'}',
'}}catch [IO.FileNotFoundException]{$result="CLEAR"}catch{$result="UNCERTAIN"}finally{if($memory){$memory.Dispose()};if($stream){$stream.Dispose()}}',
'Write-Output $result'
].join(';')
].join('\r\n')
return powerShellCommand(script)
return script
}
// The marker probe rides the same stdin transport as the platform probe
// (powerShellStdinCommand): its script, with the C# no-follow reader, is far
// over cmd.exe's 8191-char command-line limit.
function windowsUpdateMarkerProbeStdinData(hermesHome, python = '') {
return `${encodedPowerShell(windowsUpdateMarkerProbeScript(hermesHome, python))}\r\n`
}
/**
@@ -195,7 +204,11 @@ async function assertWindowsRemoteInstallUpdateClear(ssh, hermesHome, python = '
// Same stdout channel as the probe: a CLIXML progress block after the
// final `Write-Output $result` would otherwise win the .pop() and turn a
// CLEAR gate into a fail-closed 'update-in-progress' verdict.
observation = stripPowerShellNoise(await ssh.exec(windowsUpdateMarkerProbeCommand(hermesHome, python))).pop() || ''
observation = stripPowerShellNoise(
await ssh.exec(powerShellStdinCommand(), {
stdinData: windowsUpdateMarkerProbeStdinData(hermesHome, python)
})
).pop() || ''
} catch (cause) {
const error: any = new Error('Could not prove that the remote Hermes install is clear for SSH startup.')
error.kind = 'update-in-progress'