mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
The recurring Windows desktop console-flash bug (#54220) is governed by the *parent's* console, not by each child spawn. The desktop backend was launched as GUI-subsystem pythonw.exe, which has no console at all — so every console-subsystem child it spawns (git, gh, cmd, wmic, powershell, ...) had to allocate its own console, flashing a window. That is why the fix had become an endless per-call-site sweep of CREATE_NO_WINDOW flags: each leaf spawn was papering over a missing console on the root. Launch the backend as the venv's console python.exe instead. Under the existing hiddenWindowsChildOptions() wrapper (windowsHide: true -> CREATE_NO_WINDOW) the backend owns a single *windowless* console, and every descendant spawn inherits it instead of allocating a visible one. This makes "no flashing windows" a property of the one backend launch rather than a flag that must be remembered at every spawn site — including spawns inside third-party libraries that no call-site sweep can reach. Verified on Windows 11 25H2 (Windows Terminal default): with the per-site hide flag forcibly neutered, the canonical culprits (git/gh/cmd/wmic/powershell) spawned naively and none flashed, while the same naive spawn from the old console-less pythonw parent did flash — isolating the parent console as the cause. Two premises behind the old pythonw approach did not hold up on current Windows and are dropped here: - The venv Scripts\python.exe uv shim, under CREATE_NO_WINDOW, re-execs base python *windowless* — it does not flash a conhost (the #52239 concern), so the base-pythonw detour is unnecessary. - Console python restores stdout, so the backend announces its port on the normal HERMES_DASHBOARD_READY stdout line; the pythonw-only ready-file side channel is no longer needed and the readyFile opt-in is removed. Removes the now-dead pythonw machinery (getNoConsoleVenvPython, toNoConsolePython, applyWindowsNoConsoleSpawnHints, readVenvHome) and updates the test to assert the new invariant: backend command is never pythonw, both backend spawns still go through hiddenWindowsChildOptions, and no backend opts into the ready-file path. Scope: this fixes the high-frequency backend-descendant flash classes. The updater/UAC handoff (#54543) and embedded-terminal PTY accumulation (#53555) classes have separate root causes and are unaffected.
93 lines
4.2 KiB
JavaScript
93 lines
4.2 KiB
JavaScript
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
|
|
const ELECTRON_DIR = __dirname
|
|
|
|
function readElectronFile(name) {
|
|
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
|
}
|
|
|
|
function requireHiddenChildOptions(source, needle) {
|
|
const match = needle instanceof RegExp ? needle.exec(source) : null
|
|
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
|
|
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
|
const snippet = source.slice(index, index + 700)
|
|
assert.match(
|
|
snippet,
|
|
/hiddenWindowsChildOptions\(/,
|
|
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
|
)
|
|
}
|
|
|
|
test('desktop background child processes opt into hidden Windows consoles', () => {
|
|
const source = readElectronFile('main.cjs')
|
|
|
|
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
|
|
|
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
|
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
|
|
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
|
|
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
|
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
|
|
requireHiddenChildOptions(source, "spawn('curl'")
|
|
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
|
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
|
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
|
|
|
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
|
|
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
|
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
|
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
|
|
})
|
|
|
|
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
|
|
const source = readElectronFile('main.cjs')
|
|
|
|
// The flash fix is structural: the backend runs as a console-subsystem
|
|
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
|
|
// owns ONE windowless console that every descendant spawn inherits. Launching
|
|
// it as GUI-subsystem pythonw.exe is what made each child allocate (and flash)
|
|
// its own console, so the backend command must never be pythonw.
|
|
assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe')
|
|
assert.doesNotMatch(
|
|
source,
|
|
/function getNoConsoleVenvPython\b/,
|
|
'pythonw-conversion helper should be gone; console python is launched directly'
|
|
)
|
|
assert.doesNotMatch(
|
|
source,
|
|
/function applyWindowsNoConsoleSpawnHints\b/,
|
|
'pythonw spawn-hint rewriter should be gone'
|
|
)
|
|
|
|
// Console python restores stdout, so the port is announced on the normal
|
|
// HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set.
|
|
assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path')
|
|
|
|
// Both desktop backend launches must still go through hiddenWindowsChildOptions
|
|
// so the single backend console is created windowless.
|
|
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
|
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
|
})
|
|
|
|
test('intentional or interactive desktop child processes stay documented', () => {
|
|
const source = readElectronFile('main.cjs')
|
|
|
|
assert.match(source, /windowsHide: false/)
|
|
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
|
assert.match(source, /'--repair', '--branch'/)
|
|
assert.match(source, /'--update', '--branch'/)
|
|
assert.match(source, /nodePty\.spawn\(command, args/)
|
|
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
|
})
|
|
|
|
test('bootstrap PowerShell runner hides Windows console children', () => {
|
|
const source = readElectronFile('bootstrap-runner.cjs')
|
|
|
|
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
|
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
|
|
})
|