diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index f535097..67c7a88 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -231,9 +231,6 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { const bundledPython = isWin ? join(pythonDir(), 'python.exe') : join(pythonDir(), 'bin', 'python3') - const bundledPythonNoWindow = isWin - ? join(pythonDir(), 'pythonw.exe') - : bundledPython const bridgePort = await getFreeTcpPort() const workerPortBase = await getFreeTcpPortInRange(20000, 59000) const loginShellPath = await getLoginShellPath() @@ -257,7 +254,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { // ready handshakes. Use python.exe on Windows and hide windows at the // process creation layer instead of switching the bridge to pythonw.exe. HERMES_AGENT_BRIDGE_PYTHON: bundledPython, - HERMES_AGENT_CLI_PYTHON: existsSync(bundledPythonNoWindow) ? bundledPythonNoWindow : bundledPython, + HERMES_AGENT_CLI_PYTHON: bundledPython, HERMES_AGENT_ROOT: pythonDir(), // Force TCP loopback for the agent bridge. The default `ipc:///tmp/...` // unix socket is rejected on macOS in some EDR/sandbox setups (silent diff --git a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py index e4a144d..90cd51a 100755 --- a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -10,6 +10,7 @@ delimited JSON request/response protocol over a local socket. from __future__ import annotations import argparse +import asyncio import atexit import copy import errno @@ -71,6 +72,14 @@ def _hidden_subprocess_kwargs() -> dict[str, int]: return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)} +def _add_hidden_creationflags(kwargs: dict[str, Any], create_no_window: int) -> None: + flags = kwargs.get("creationflags", 0) or 0 + try: + kwargs["creationflags"] = int(flags) | create_no_window + except Exception: + kwargs["creationflags"] = create_no_window + + def _install_windows_hidden_subprocess_defaults() -> None: """Hide console windows for subprocesses launched inside desktop bridge runs. @@ -87,18 +96,26 @@ def _install_windows_hidden_subprocess_defaults() -> None: return original_popen = subprocess.Popen + original_create_subprocess_exec = asyncio.create_subprocess_exec + original_create_subprocess_shell = asyncio.create_subprocess_shell create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000 class HiddenPopen(original_popen): # type: ignore[misc, valid-type] def __init__(self, *args: Any, **kwargs: Any) -> None: - flags = kwargs.get("creationflags", 0) or 0 - try: - kwargs["creationflags"] = int(flags) | create_no_window - except Exception: - kwargs["creationflags"] = create_no_window + _add_hidden_creationflags(kwargs, create_no_window) super().__init__(*args, **kwargs) + async def hidden_create_subprocess_exec(*args: Any, **kwargs: Any) -> Any: + _add_hidden_creationflags(kwargs, create_no_window) + return await original_create_subprocess_exec(*args, **kwargs) + + async def hidden_create_subprocess_shell(*args: Any, **kwargs: Any) -> Any: + _add_hidden_creationflags(kwargs, create_no_window) + return await original_create_subprocess_shell(*args, **kwargs) + subprocess.Popen = HiddenPopen # type: ignore[assignment] + asyncio.create_subprocess_exec = hidden_create_subprocess_exec # type: ignore[assignment] + asyncio.create_subprocess_shell = hidden_create_subprocess_shell # type: ignore[assignment] subprocess._hermes_hidden_defaults_installed = True # type: ignore[attr-defined] diff --git a/packages/server/src/services/hermes/hermes-process.ts b/packages/server/src/services/hermes/hermes-process.ts index eca150c..cc64968 100644 --- a/packages/server/src/services/hermes/hermes-process.ts +++ b/packages/server/src/services/hermes/hermes-process.ts @@ -22,12 +22,15 @@ function bundledCliPythonForWindows(hermesBin: string): string | null { if (envPython) return envPython if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null - const pythonw = resolve(dirname(hermesBin), '..', 'pythonw.exe') - if (existsSync(pythonw)) return pythonw const python = resolve(dirname(hermesBin), '..', 'python.exe') return existsSync(python) ? python : null } +function withWindowsHide(options?: T): T { + if (process.platform !== 'win32') return (options || {}) as T + return { windowsHide: true, ...(options || {}) } as T +} + export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation { if (process.platform === 'win32') { const python = bundledCliPythonForWindows(hermesBin) @@ -47,7 +50,7 @@ export function execHermesWithBin( execFile( invocation.command, [...invocation.argsPrefix, ...args], - { ...options, encoding: 'utf8' }, + { ...withWindowsHide(options), encoding: 'utf8' }, (error, stdout, stderr) => { if (error) { rejectExec(Object.assign(error, { stdout, stderr })) @@ -69,7 +72,7 @@ export function spawnHermesWithBin( options?: SpawnOptions, ): ChildProcess { const invocation = resolveHermesInvocation(hermesBin) - return spawn(invocation.command, [...invocation.argsPrefix, ...args], options || {}) + return spawn(invocation.command, [...invocation.argsPrefix, ...args], withWindowsHide(options)) } export function spawnHermes(args: readonly string[], options?: SpawnOptions): ChildProcess { diff --git a/tests/server/agent-bridge-profile-env.test.ts b/tests/server/agent-bridge-profile-env.test.ts index ff97280..4432385 100644 --- a/tests/server/agent-bridge-profile-env.test.ts +++ b/tests/server/agent-bridge-profile-env.test.ts @@ -107,7 +107,7 @@ print(json.dumps({ }) describe('agent bridge Windows desktop subprocess defaults', () => { - it('adds CREATE_NO_WINDOW to nested subprocesses without replacing existing flags', async () => { + it('adds CREATE_NO_WINDOW to sync and async nested subprocesses without replacing existing flags', async () => { const result = await runBridgeProbe(String.raw` import importlib.util import json @@ -121,6 +121,8 @@ spec.loader.exec_module(bridge) original_os_name = bridge.os.name original_popen = bridge.subprocess.Popen +original_async_exec = bridge.asyncio.create_subprocess_exec +original_async_shell = bridge.asyncio.create_subprocess_shell original_create_no_window = getattr(bridge.subprocess, "CREATE_NO_WINDOW", None) original_installed = getattr(bridge.subprocess, "_hermes_hidden_defaults_installed", None) @@ -130,10 +132,22 @@ class FakePopen: def __init__(self, *args, **kwargs): FakePopen.calls.append({"args": args, "kwargs": kwargs}) +async_calls = [] + +async def fake_create_subprocess_exec(*args, **kwargs): + async_calls.append({"kind": "exec", "args": args, "kwargs": kwargs}) + return {"kind": "exec"} + +async def fake_create_subprocess_shell(*args, **kwargs): + async_calls.append({"kind": "shell", "args": args, "kwargs": kwargs}) + return {"kind": "shell"} + try: bridge.os.name = "nt" bridge.os.environ["HERMES_DESKTOP"] = "true" bridge.subprocess.Popen = FakePopen + bridge.asyncio.create_subprocess_exec = fake_create_subprocess_exec + bridge.asyncio.create_subprocess_shell = fake_create_subprocess_shell bridge.subprocess.CREATE_NO_WINDOW = 0x08000000 if hasattr(bridge.subprocess, "_hermes_hidden_defaults_installed"): delattr(bridge.subprocess, "_hermes_hidden_defaults_installed") @@ -141,9 +155,15 @@ try: bridge._install_windows_hidden_subprocess_defaults() bridge.subprocess.Popen(["git", "status"], creationflags=0x00000200) flags = FakePopen.calls[0]["kwargs"]["creationflags"] + bridge.asyncio.run(bridge.asyncio.create_subprocess_exec("git", "status", creationflags=0x00000400)) + bridge.asyncio.run(bridge.asyncio.create_subprocess_shell("git status")) + async_exec_flags = async_calls[0]["kwargs"]["creationflags"] + async_shell_flags = async_calls[1]["kwargs"]["creationflags"] finally: bridge.os.name = original_os_name bridge.subprocess.Popen = original_popen + bridge.asyncio.create_subprocess_exec = original_async_exec + bridge.asyncio.create_subprocess_shell = original_async_shell if original_create_no_window is None: try: delattr(bridge.subprocess, "CREATE_NO_WINDOW") @@ -163,6 +183,11 @@ print(json.dumps({ "flags": flags, "has_create_no_window": bool(flags & 0x08000000), "kept_existing_flag": bool(flags & 0x00000200), + "async_exec_flags": async_exec_flags, + "async_exec_has_create_no_window": bool(async_exec_flags & 0x08000000), + "async_exec_kept_existing_flag": bool(async_exec_flags & 0x00000400), + "async_shell_flags": async_shell_flags, + "async_shell_has_create_no_window": bool(async_shell_flags & 0x08000000), })) `) @@ -170,6 +195,11 @@ print(json.dumps({ flags: 0x08000200, has_create_no_window: true, kept_existing_flag: true, + async_exec_flags: 0x08000400, + async_exec_has_create_no_window: true, + async_exec_kept_existing_flag: true, + async_shell_flags: 0x08000000, + async_shell_has_create_no_window: true, }) }) }) diff --git a/tests/server/hermes-process.test.ts b/tests/server/hermes-process.test.ts index 3972a0a..c8706ec 100644 --- a/tests/server/hermes-process.test.ts +++ b/tests/server/hermes-process.test.ts @@ -4,13 +4,17 @@ import { tmpdir } from 'os' import { join } from 'path' const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>) +const spawnCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>) vi.mock('child_process', () => ({ execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => { execFileCalls.push({ command, args, options }) callback(null, 'ok\n', '') }), - spawn: vi.fn(), + spawn: vi.fn((command: string, args: string[], options: any) => { + spawnCalls.push({ command, args, options }) + return {} as any + }), })) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -21,6 +25,7 @@ function setPlatform(platform: NodeJS.Platform): void { afterEach(() => { execFileCalls.length = 0 + spawnCalls.length = 0 delete process.env.HERMES_AGENT_BRIDGE_PYTHON delete process.env.HERMES_AGENT_CLI_PYTHON if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) @@ -30,7 +35,7 @@ afterEach(() => { describe('Hermes process invocation', () => { it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => { setPlatform('win32') - process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\pythonw.exe' + process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe' const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') const result = await execHermesWithBin( @@ -43,25 +48,26 @@ describe('Hermes process invocation', () => { expect(execFileCalls[0]).toMatchObject({ command: process.env.HERMES_AGENT_CLI_PYTHON, args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'], + options: expect.objectContaining({ windowsHide: true }), }) }) - it('prefers sibling pythonw.exe for a Windows hermes.exe launcher', async () => { + it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => { setPlatform('win32') const root = mkdtempSync(join(tmpdir(), 'hermes-process-')) try { const scripts = join(root, 'Scripts') mkdirSync(scripts) writeFileSync(join(root, 'python.exe'), '') - writeFileSync(join(root, 'pythonw.exe'), '') writeFileSync(join(scripts, 'hermes.exe'), '') const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') - await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'], { windowsHide: true }) + await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version']) expect(execFileCalls[0]).toMatchObject({ - command: join(root, 'pythonw.exe'), + command: join(root, 'python.exe'), args: ['-m', 'hermes_cli.main', '--version'], + options: expect.objectContaining({ windowsHide: true }), }) } finally { rmSync(root, { recursive: true, force: true }) @@ -79,4 +85,18 @@ describe('Hermes process invocation', () => { args: ['--version'], }) }) + + it('defaults spawned Windows Hermes processes to hidden windows', async () => { + setPlatform('win32') + process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Hermes Studio\\resources\\python\\python.exe' + const { spawnHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') + + spawnHermesWithBin('C:\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe', ['gateway', 'run']) + + expect(spawnCalls[0]).toMatchObject({ + command: process.env.HERMES_AGENT_CLI_PYTHON, + args: ['-m', 'hermes_cli.main', 'gateway', 'run'], + options: expect.objectContaining({ windowsHide: true }), + }) + }) })