From 4c797d0e23c12ffdfd9d34ce032e5e335094b2e9 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 10 Jun 2026 03:36:25 -0600 Subject: [PATCH] fix(desktop): hide Windows console children launched by GUI --- apps/desktop/electron/bootstrap-runner.cjs | 13 ++++- apps/desktop/electron/main.cjs | 37 +++++++------ .../electron/windows-child-process.test.cjs | 54 +++++++++++++++++++ apps/desktop/package.json | 2 +- 4 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/electron/windows-child-process.test.cjs diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 95c43c95521..644f9405056 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -40,6 +40,15 @@ const path = require('node:path') const https = require('node:https') const { spawn } = require('node:child_process') +const IS_WINDOWS = process.platform === 'win32' + +function hiddenWindowsChildOptions(options = {}) { + if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options + } + return { ...options, windowsHide: true } +} + const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i // Stages flagged needs_user_input=true in the manifest are skipped by the @@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh' const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args] - const child = spawn(ps, fullArgs, { + const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, @@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme // choice rather than re-computing the default. HERMES_HOME: hermesHome || process.env.HERMES_HOME || '' } - }) + })) let stdout = '' let stderr = '' diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2772295cab7..5e128421a83 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -107,6 +107,13 @@ const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() const APP_ROOT = app.getAppPath() +function hiddenWindowsChildOptions(options = {}) { + if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options + } + return { ...options, windowsHide: true } +} + // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the // wire, so the window flashes during scroll/streaming/animation. Local @@ -1106,7 +1113,7 @@ function findSystemPython() { const out = execFileSync( 'reg', ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) ) // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) @@ -1142,10 +1149,10 @@ function findSystemPython() { if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { - const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], { + const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] - }) + })) const candidate = out.trim() if (candidate && fileExists(candidate)) return candidate } catch { @@ -1280,11 +1287,11 @@ function resolveUpdateRoot() { function runGit(args, options = {}) { return new Promise((resolve, reject) => { - const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, { + const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) let stdout = '' let stderr = '' @@ -1494,7 +1501,7 @@ function forceKillProcessTree(pid) { if (!IS_WINDOWS) return if (!Number.isInteger(pid) || pid <= 0) return try { - execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { // Already gone, or no permission — best effort; the unlock wait below is // the real gate. @@ -1680,11 +1687,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { return new Promise(resolve => { let child try { - child = spawn(command, args, { + child = spawn(command, args, hiddenWindowsChildOptions({ cwd, env: { ...process.env, ...(env || {}) }, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) } catch (err) { resolve({ code: 1, error: err.message }) return @@ -2671,7 +2678,7 @@ function fetchHtmlTitleWithCurl(rawUrl) { '--raw', url ] - const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] }) + const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 @@ -4491,7 +4498,7 @@ async function spawnPoolBackend(profile, entry) { rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) - const child = spawn(backend.command, backend.args, { + const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, env: { ...process.env, @@ -4509,7 +4516,7 @@ async function spawnPoolBackend(profile, entry) { }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) entry.process = child entry.port = port entry.token = token @@ -4691,7 +4698,7 @@ async function startHermes() { await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) - hermesProcess = spawn(backend.command, backend.args, { + hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, env: { ...process.env, @@ -4714,7 +4721,7 @@ async function startHermes() { }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) hermesProcess.stdout.on('data', rememberLog) hermesProcess.stderr.on('data', rememberLog) @@ -5986,11 +5993,11 @@ async function getUninstallSummary() { resolve(value) } try { - const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], { + const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({ cwd: agentRoot, env: { ...process.env, HERMES_HOME, NO_COLOR: '1' }, stdio: ['ignore', 'pipe', 'ignore'] - }) + })) child.stdout.on('data', chunk => { stdout += chunk.toString() }) diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs new file mode 100644 index 00000000000..6bcc58a0a33 --- /dev/null +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -0,0 +1,54 @@ +'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') +} + +function requireHiddenChildOptions(source, needle) { + const index = 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(pyExe') + requireHiddenChildOptions(source, 'spawn(resolveGitBinary()') + requireHiddenChildOptions(source, "execFileSync('taskkill'") + requireHiddenChildOptions(source, 'spawn(command, args') + requireHiddenChildOptions(source, "spawn('curl'") + requireHiddenChildOptions(source, 'spawn(backend.command, backend.args') + requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args') + requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']") +}) + +test('intentional or interactive desktop child processes stay documented', () => { + const source = readElectronFile('main.cjs') + + assert.match(source, /windowsHide: false/) + 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') +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 734709ec72b..b80d44d0435 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs", "type-check": "tsc -b", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",