From 02a802a7ef87f360fee0aa8d31e9232fc8800983 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 29 Sep 2026 22:14:19 -0500 Subject: [PATCH] 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 Fixes #106716 Refs #106903 --- .../electron/windows-remote-lifecycle.test.ts | 289 +++++++++++++++++- .../electron/windows-remote-lifecycle.ts | 23 +- 2 files changed, 295 insertions(+), 17 deletions(-) diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts index 64eaefc9c7..9cd509f8af 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.test.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -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 正在标准配置首次使用模块' + +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[] = [] diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index 2f70cdfaf1..ac79b13aa0 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -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'