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:
committed by
brooklyn!
parent
25d7de6fad
commit
02a802a7ef
@@ -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[] = []
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user