Windows console/terminal flash suppression fixes
Cluster 24073 · apps/desktop. Data as of
2 open items. Fix merged in #54417 on 28 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 45 related items grouped here, 24 issues already closed.
- Fix event
- A fix was merged to main in PR #54417. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 0 issues and 2 PRs.
This cluster is focused on a single theme: preventing unwanted console window popups and flashes on Windows when background subprocesses are spawned. Members include bug reports, feature requests, and numerous incremental fixes targeting subprocess creation flags like CREATE_NO_WINDOW and CREATE_BREAKAWAY_FROM_JOB across various spawn sites (git, ffmpeg, netstat, gateway, etc.).
Open issues0
Open PRs2
Fixes on record
-
#53810 fix(windows): stop terminal-window popups from background spawns
files:
apps/desktop/electron/main.cjs,cron/scheduler.py,gateway/run.py,gateway/status.py,hermes_bootstrap.py,hermes_cli/_subprocess_compat.py,hermes_cli/claw.py,hermes_cli/clipboard.py,hermes_cli/gateway.py,hermes_cli/gateway_windows.py,hermes_cli/main.py,hermes_cli/web_server.py,hermes_constants.py,plugins/platforms/discord/adapter.py,plugins/platforms/whatsapp/adapter.py,tests/gateway/test_status.py,tests/hermes_cli/test_claw.py,tests/hermes_cli/test_gateway.py,tests/hermes_cli/test_gateway_windows.py,tests/hermes_cli/test_web_server.py(+13 more)Show the diff
-
apps/desktop/electron/main.cjs@@ -1565,18 +1565,17 @@ function readVenvHome(venvRoot) { function getNoConsoleVenvPython(venvRoot) { if (!IS_WINDOWS) return getVenvPython(venvRoot) - // Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages - // wiring. Falling back to the base uv/python.org pythonw.exe skips the venv - // and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched. - const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe') - if (fileExists(venvPythonw)) return venvPythonw - + // uv venv launchers can re-exec console python.exe, which allocates conhost / + // Windows Terminal. Use base pythonw directly and provide imports via env. const baseHome = readVenvHome(venvRoot) if (baseHome) { const basePythonw = path.join(baseHome, 'pythonw.exe') if (fileExists(basePythonw)) return basePythonw } + const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe') + if (fileExists(venvPythonw)) return venvPythonw + return venvPythonw } @@ -2797,7 +2796,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, - pythonPathEntries: [root], + pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], venvRoot }), root, @@ -2821,7 +2820,7 @@ function createActiveBackend(dashboardArgs) { args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, - pythonPathEntries: [ACTIVE_HERMES_ROOT], + pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], venvRoot: VENV_ROOT }), root: ACTIVE_HERMES_ROOT, -
cron/scheduler.py@@ -3046,4 +3046,11 @@ def _on_done(_f: concurrent.futures.Future) -> None: if __name__ == "__main__": + # Standalone background scheduler: drop any console a uv pythonw→python + # re-exec auto-allocated. No-op on POSIX / when run in-gateway. + try: + import hermes_bootstrap + hermes_bootstrap.detach_orphan_console() + except Exception: + pass tick(verbose=True) -
gateway/run.py@@ -18593,6 +18593,13 @@ def restart_signal_handler(): def main(): """CLI entry point for the gateway.""" + # Background daemon: drop any console auto-allocated by a uv pythonw→python + # re-exec so no terminal lingers. No-op on POSIX / when already detached. + try: + hermes_bootstrap.detach_orphan_console() + except Exception: + pass + # Force UTF-8 stdio on Windows — gateway logs and startup banner would # otherwise UnicodeEncodeError on cp1252 consoles. No-op on POSIX. try: -
gateway/status.py@@ -81,8 +81,10 @@ def terminate_pid(pid: int, *, force: bool = False) -> None: because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop. """ if force and _IS_WINDOWS: + from hermes_cli import _subprocess_compat + try: - result = subprocess.run( + result = _subprocess_compat.run( ["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, -
hermes_bootstrap.py@@ -80,6 +80,26 @@ def apply_windows_utf8_bootstrap() -> bool: os.environ.setdefault("PYTHONUTF8", "1") os.environ.setdefault("PYTHONIOENCODING", "utf-8") + # Python's platform.win32_ver()/platform.platform() can shell out to + # ``cmd.exe /c ver`` on Windows. In pythonw-launched background processes + # that still creates a visible terminal handoff on machines where Windows + # Terminal is the default console host. Disable that subprocess path early. + try: + import platform + + def _no_subprocess_syscmd_ver( + system: str = "", + release: str = "", + version: str = "", + *_args, + **_kwargs, + ) -> tuple[str, str, str]: + return system or "Windows", release, version + + platform._syscmd_ver = _no_subprocess_syscmd_ver # type: ignore[attr-defined] + except Exception: + pass + # 2. Reconfigure the current process's stdio to UTF-8. Needed # because os.environ changes don't retroactively rebind sys.stdout # — those were bound at interpreter startup based on the console @@ -122,6 +142,44 @@ def apply_windows_utf8_bootstrap() -> bool: return True +def detach_orphan_console() -> bool: + """Free a console window that was auto-allocated for this process alone. + + Background-only entry points (gateway daemon, dashboard backend, cron + runner, TUI/desktop stdio backends) call this explicitly. uv-created venvs + ship a ``Scripts\\pythonw.exe`` redirector that re-execs the *base* console + ``python.exe``; that re-exec allocates its own conhost/Windows Terminal + window even though the launcher wanted no console. We drop it so nothing + lingers. + + This is NOT wired into the import-time bootstrap on purpose: the discriminator + (``GetConsoleProcessList() == 1``) cannot tell a phantom console apart from a + user who deliberately opened the *interactive* CLI/TUI in its own fresh + console (double-click, Start-menu shortcut, a ConPTY), since both report a + single attached process with a tty. Intent is only knowable from the entry + point — so only known-background mains call this, never the interactive CLI. + + A properly detached daemon (``DETACHED_PROCESS``) has no console at all, so + ``GetConsoleWindow()`` is NULL and this is a no-op. Returns True iff a console + was actually freed. No-op (returns False) on non-Windows. + """ + if not _IS_WINDOWS: + return False + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + if not kernel32.GetConsoleWindow(): + return False + buf = (ctypes.c_uint * 4)() + if kernel32.GetConsoleProcessList(buf, 4) == 1: + kernel32.FreeConsole() + return True + except Exception: + pass + return False + + def harden_import_path(src_root: str | None = None) -> None: """Stop a package in the current directory from shadowing Hermes modules. -
hermes_cli/_subprocess_compat.py@@ -28,12 +28,15 @@ from __future__ import annotations import shutil +import subprocess import sys from typing import Sequence __all__ = [ "IS_WINDOWS", "resolve_node_command", + "run", + "popen", "windows_detach_flags", "windows_detach_flags_without_breakaway", "windows_hide_flags", @@ -201,6 +204,44 @@ def windows_hide_flags() -> int: return _CREATE_NO_WINDOW +# ----------------------------------------------------------------------------- +# The single chokepoint for spawning a process without a console window. +# ----------------------------------------------------------------------------- + + +def _no_window(kwargs: dict) -> dict: + """OR ``CREATE_NO_WINDOW`` into ``creationflags`` on Windows (no-op on POSIX). + + Merges rather than overwrites, so a caller that needs detach semantics can + pass ``creationflags=windows_detach_flags()`` and still go through here — + ``CREATE_NO_WINDOW`` is already part of that bundle, so the OR is idempotent. + """ + if IS_WINDOWS: + kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_NO_WINDOW + return kwargs + + +def run(cmd, **kwargs): + """``subprocess.run`` that never flashes a console window on Windows. + + This is the primitive every Hermes spawn of a *console-subsystem* program + (``taskkill``, ``schtasks``, ``agent-browser``, ``git-bash``, version + probes, …) must use. Routing through one function makes "no visible + terminal" structural instead of a per-call-site rule that gets forgotten — + which is exactly how cron-driven and future spawns leaked windows before. + + Python child processes are additionally covered by the ``FreeConsole`` + catch-all in :mod:`hermes_bootstrap`, but native exes can't run that, so the + spawn-time flag here is the only thing that helps them. + """ + return subprocess.run(cmd, **_no_window(kwargs)) + + +def popen(cmd, **kwargs): + """``subprocess.Popen`` counterpart of :func:`run` — see its docstring.""" + return subprocess.Popen(cmd, **_no_window(kwargs)) + + def windows_detach_popen_kwargs() -> dict: """Return a dict of Popen kwargs that detach a child on Windows and fall back to the POSIX equivalent (``start_new_session=True``) on -
hermes_cli/claw.py@@ -77,9 +77,11 @@ def _detect_openclaw_processes() -> list[str]: # -- process scan ------------------------------------------------------ if sys.platform == "win32": + from hermes_cli import _subprocess_compat + try: for exe in ("openclaw.exe", "clawd.exe"): - result = subprocess.run( + result = _subprocess_compat.run( ["tasklist", "/FI", f"IMAGENAME eq {exe}"], capture_output=True, text=True, timeout=5, ) @@ -93,7 +95,7 @@ def _detect_openclaw_processes() -> list[str]: 'Where-Object { $_.CommandLine -match "openclaw|clawd" } | ' 'Select-Object -First 1 ProcessId' ) - result = subprocess.run( + result = _subprocess_compat.run( ["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, timeout=5, ) -
hermes_cli/clipboard.py@@ -198,7 +198,9 @@ def _macos_osascript(dest: Path) -> bool: def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess: - return subprocess.run( + from hermes_cli import _subprocess_compat + + return _subprocess_compat.run( [exe, "-NoProfile", "-NonInteractive", "-Command", script], capture_output=True, text=True, timeout=timeout, ) @@ -254,9 +256,11 @@ def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> def _find_powershell() -> str | None: """Return the first available PowerShell executable, or None.""" + from hermes_cli import _subprocess_compat + for name in ("powershell", "pwsh"): try: - r = subprocess.run( + r = _subprocess_compat.run( [name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"], capture_output=True, text=True, timeout=5, ) -
hermes_cli/gateway.py@@ -380,6 +380,24 @@ def _matches_gateway_runtime(command: str) -> bool: try: if is_windows(): + try: + import psutil # type: ignore + + for proc in psutil.process_iter(["pid", "cmdline"]): + pid = int(proc.info.get("pid") or 0) + if pid == os.getpid() or pid in exclude_pids: + continue + command = " ".join(proc.info.get("cmdline") or []) + if _matches_gateway_runtime(command) and ( + all_profiles or _matches_current_profile(command) + ): + _append_unique_pid(pids, pid, exclude_pids) + return _filter_venv_launcher_stubs(pids) if len(pids) > 1 else pids + except Exception: + pass + + from hermes_cli import _subprocess_compat + # Prefer wmic when present (fast, stable output format). On # modern Windows 11 / Win 10 late builds, wmic has been # removed as part of the WMIC deprecation — fall back to @@ -390,7 +408,7 @@ def _matches_gateway_runtime(command: str) -> bool: result = None if wmic_path is not None: try: - result = subprocess.run( + result = _subprocess_compat.run( [ wmic_path, "process", @@ -421,7 +439,7 @@ def _matches_gateway_runtime(command: str) -> bool: "}" ) try: - result = subprocess.run( + result = _subprocess_compat.run( [powershell, "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, @@ -6632,7 +6650,6 @@ def _gateway_command_inner(args): # path that can be reaped with the old gateway process. If the # Windows backend raises, intentionally preserve the existing # generic failure fallback below. - service_configured = gateway_windows.is_installed() try: gateway_windows.restart() return -
hermes_cli/gateway_windows.py@@ -39,10 +39,10 @@ from pathlib import Path from xml.sax.saxutils import escape +from hermes_cli import _subprocess_compat from hermes_cli._subprocess_compat import ( windows_detach_flags, windows_detach_flags_without_breakaway, - windows_hide_flags, ) # Short timeouts: schtasks occasionally wedges and we don't want to hang forever. @@ -157,7 +157,7 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]: if schtasks is None: return (1, "", "schtasks.exe not found on PATH") try: - proc = subprocess.run( + proc = _subprocess_compat.run( [schtasks, *args], capture_output=True, text=True, @@ -168,10 +168,6 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]: encoding=_schtasks_encoding(), errors="replace", timeout=_SCHTASKS_TIMEOUT_S, - # CREATE_NO_WINDOW avoids a flashing console window when the CLI - # is itself hosted in a TUI. See tools/browser_tool.py for the - # same pattern and the windows-subprocess-sigint-storm.md ref. - creationflags=windows_hide_flags(), ) return (proc.returncode, proc.stdout or "", proc.stderr or "") except subprocess.TimeoutExpired: @@ -1605,7 +1601,17 @@ def stop() -> None: drained = _drain_gateway_pid(pid, _windows_stop_drain_timeout()) stopped_any = drained - if is_task_registered(): + has_service_artifact = ( + get_task_script_path().exists() + or get_task_script_path().with_suffix(".vbs").exists() + or get_startup_entry_path().exists() + or _legacy_startup_entry_path().exists() + ) + if ( + has_service_artifact + and os.getenv("HERMES_NONINTERACTIVE") != "1" + and is_task_registered() + ): code, _out, err = _exec_schtasks(["/End", "/TN", get_task_name()]) # schtasks returns nonzero when the task isn't currently running — don't treat that as an error. if code == 0: @@ -1673,7 +1679,8 @@ def restart() -> None: # Give Windows a moment to release the listening port. time.sleep(1.0) - start() + pid = _spawn_detached() + _report_gateway_start(f"direct spawn (PID {pid})") if not _wait_for_gateway_ready(timeout_s=15.0): raise RuntimeError( -
hermes_cli/main.py@@ -5730,6 +5730,8 @@ def _find_stale_dashboard_pids( try: if sys.platform == "win32": + from hermes_cli import _subprocess_compat + # wmic may emit text in the system code page (for example cp936 # on zh-CN systems), not UTF-8. In text mode, subprocess output # decoding depends on Python's configuration (locale-dependent @@ -5737,7 +5739,7 @@ def _find_stale_dashboard_pids( # here is errors="ignore": it prevents a reader-thread # UnicodeDecodeError from leaving result.stdout=None and turning # the later .split() into an AttributeError (#17049). - result = subprocess.run( + result = _subprocess_compat.run( ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], capture_output=True, text=True, @@ -5977,9 +5979,11 @@ def _kill_stale_dashboard_processes( failed: list[tuple[int, str]] = [] if sys.platform == "win32": + from hermes_cli import _subprocess_compat + for pid in pids: try: - result = subprocess.run( + result = _subprocess_compat.run( ["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True, -
hermes_cli/web_server.py@@ -2481,16 +2481,52 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None} -def _dashboard_spawn_executable() -> str: - """Prefer pythonw.exe for detached dashboard actions on Windows.""" +def _dashboard_spawn_details() -> Tuple[str, Dict[str, str]]: + """Return (executable, env overlay) for detached dashboard actions. + + On Windows this mirrors the gateway's uv-safe detached launcher logic so + action spawns do not regress to console python.exe (which creates a visible + terminal window). Non-Windows callsites get the current interpreter and no + env overlay. + """ if sys.platform != "win32": - return sys.executable + return sys.executable, {} + exe = sys.executable - if exe.lower().endswith("python.exe"): - pythonw = os.path.join(os.path.dirname(exe), "pythonw.exe") - if os.path.isfile(pythonw): - return pythonw - return exe + try: + from hermes_cli.gateway_windows import _resolve_detached_python + + venv_root = os.environ.get("VIRTUAL_ENV", "").strip() + if not venv_root: + for candidate in (PROJECT_ROOT / "venv", PROJECT_ROOT / ".venv"): + if (candidate / "Scripts" / "python.exe").exists(): + venv_root = str(candidate) + break + probe_exe = ( + os.path.join(venv_root, "Scripts", "python.exe") + if venv_root + else exe + ) + windowless_exe, venv_dir, extra_pythonpath = _resolve_detached_python(probe_exe) + env_overlay: Dict[str, str] = {} + if venv_dir: + env_overlay["VIRTUAL_ENV"] = str(venv_dir) + site_packages = Path(venv_dir) / "Lib" / "site-packages" + if site_packages.exists() and str(site_packages) not in extra_pythonpath: + extra_pythonpath = [*extra_pythonpath, str(site_packages)] + if extra_pythonpath: + existing = os.environ.get("PYTHONPATH", "") + env_overlay["PYTHONPATH"] = os.pathsep.join( + [*extra_pythonpath, existing] if existing else list(extra_pythonpath) + ) + return windowless_exe, env_overlay + except Exception: + # Best-effort fallback: sibling pythonw keeps the legacy no-console path. + if exe.lower().endswith("python.exe"): + pythonw = os.path.join(os.path.dirname(exe), "pythonw.exe") + if os.path.isfile(pythonw): + return pythonw, {} + return exe, {} def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: @@ -2507,15 +2543,20 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) - cmd = [_dashboard_spawn_executable(), "-m", "hermes_cli.main", *subcommand] + spawn_executable, spawn_env_overlay = _dashboard_spawn_details() + cmd = [spawn_executable, "-m", "hermes_cli.main", *subcommand] popen_kwargs: Dict[str, Any] = { "cwd": str(PROJECT_ROOT), "stdin": subprocess.DEVNULL, "stdout": log_file, "stderr": subprocess.STDOUT, - "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, + "env": {**os.environ, "HERMES_NONINTERACTIVE": "1", **spawn_env_overlay}, } + log_file.write(f"spawn executable: {spawn_executable}\n".encode()) + if spawn_env_overlay: + keys = ",".join(sorted(spawn_env_overlay.keys())) + log_file.write(f"spawn env overlay keys: {keys}\n".encode()) if sys.platform == "win32": popen_kwargs["creationflags"] = windows_detach_flags() else: @@ -13547,6 +13588,15 @@ def start_server( — used when a profile alias (``<profile> dashboard``) routes to the machine dashboard. """ + # Desktop spawns this backend via a no-console venv python; a uv + # pythonw→python re-exec can still auto-allocate a console. Drop it. + # No-op on POSIX and when launched from an interactive shell. + try: + import hermes_bootstrap + hermes_bootstrap.detach_orphan_console() + except Exception: + pass + import uvicorn try: -
hermes_constants.py@@ -561,12 +561,15 @@ def agent_browser_runnable(path: str | None) -> bool: return False import subprocess + from hermes_cli import _subprocess_compat + try: - result = subprocess.run( + result = _subprocess_compat.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), + stdin=subprocess.DEVNULL, ) except (OSError, subprocess.TimeoutExpired, ValueError): return False -
plugins/platforms/discord/adapter.py@@ -665,7 +665,9 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, f.write(pcm_data) pcm_path = f.name try: - subprocess.run( + from hermes_cli import _subprocess_compat + + _subprocess_compat.run( [ "ffmpeg", "-y", "-loglevel", "error", "-f", "s16le", @@ -679,7 +681,6 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, check=True, timeout=10, stdin=subprocess.DEVNULL, - creationflags=windows_hide_flags(), ) finally: try: -
plugins/platforms/whatsapp/adapter.py@@ -78,8 +78,10 @@ def _kill_port_process(port: int) -> None: """Kill any process *listening* on the given TCP port (a stale bridge).""" try: if _IS_WINDOWS: + from hermes_cli import _subprocess_compat + # Use netstat to find the PID bound to this port, then taskkill - result = subprocess.run( + result = _subprocess_compat.run( ["netstat", "-ano", "-p", "TCP"], capture_output=True, text=True, timeout=5, ) @@ -89,7 +91,7 @@ def _kill_port_process(port: int) -> None: local_addr = parts[1] if local_addr.endswith(f":{port}"): try: - subprocess.run( + _subprocess_compat.run( ["taskkill", "/PID", parts[4], "/F"], capture_output=True, timeout=5, ) @@ -207,11 +209,13 @@ def _write_bridge_pidfile(session_path: Path, pid: int) -> None: def _terminate_bridge_process(proc, *, force: bool = False) -> None: """Terminate the bridge process using process-tree semantics where possible.""" if _IS_WINDOWS: + from hermes_cli import _subprocess_compat + cmd = ["taskkill", "/PID", str(proc.pid), "/T"] if force: cmd.append("/F") try: - result = subprocess.run( + result = _subprocess_compat.run( cmd, capture_output=True, text=True, -
tests/gateway/test_status.py@@ -618,20 +618,25 @@ def no_proc(self, *args, **kwargs): class TestTerminatePid: def test_force_uses_taskkill_on_windows(self, monkeypatch): + from hermes_cli import _subprocess_compat + calls = [] monkeypatch.setattr(status, "_IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) - def fake_run(cmd, capture_output=False, text=False, timeout=None): - calls.append((cmd, capture_output, text, timeout)) + def fake_run(cmd, capture_output=False, text=False, timeout=None, **kwargs): + calls.append((cmd, capture_output, text, timeout, kwargs)) return SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(status.subprocess, "run", fake_run) status.terminate_pid(123, force=True) - assert calls == [ - (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10) - ] + assert len(calls) == 1 + cmd, capture_output, text, timeout, kwargs = calls[0] + assert cmd == ["taskkill", "/PID", "123", "/T", "/F"] + assert (capture_output, text, timeout) == (True, True, 10) + assert kwargs["creationflags"] & 0x08000000 def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch): calls = [] -
tests/hermes_cli/test_claw.py@@ -725,8 +725,9 @@ def test_detects_systemd_service(self): def test_returns_match_on_windows_when_openclaw_exe_running(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" - with patch.object(claw_mod, "subprocess") as mock_subprocess: - mock_subprocess.run.side_effect = [ + # Windows scans go through the hidden-spawn primitive (no console flash). + with patch("hermes_cli._subprocess_compat.run") as mock_run: + mock_run.side_effect = [ MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"), ] result = claw_mod._detect_openclaw_processes() @@ -736,8 +737,8 @@ def test_returns_match_on_windows_when_openclaw_exe_running(self): def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" - with patch.object(claw_mod, "subprocess") as mock_subprocess: - mock_subprocess.run.side_effect = [ + with patch("hermes_cli._subprocess_compat.run") as mock_run: + mock_run.side_effect = [ MagicMock(returncode=0, stdout=""), # tasklist openclaw.exe MagicMock(returncode=0, stdout=""), # tasklist clawd.exe MagicMock(returncode=0, stdout="1234\n"), # PowerShell @@ -749,8 +750,8 @@ def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self): def test_returns_empty_on_windows_when_nothing_found(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" - with patch.object(claw_mod, "subprocess") as mock_subprocess: - mock_subprocess.run.side_effect = [ + with patch("hermes_cli._subprocess_compat.run") as mock_run: + mock_run.side_effect = [ MagicMock(returncode=0, stdout=""), MagicMock(returncode=0, stdout=""), MagicMock(returncode=0, stdout=""), -
tests/hermes_cli/test_gateway.py@@ -887,23 +887,20 @@ def test_reap_unsupervised_orphans_returns_false_when_none_found(monkeypatch): def test_scan_gateway_pids_detects_windows_hermes_exe_case_variants(monkeypatch): + # Windows scan now goes through psutil first (no console spawn). A + # uppercase ``Hermes.EXE gateway run`` must still match case-insensitively. + import psutil + monkeypatch.setattr(gateway, "is_windows", lambda: True) monkeypatch.setattr(gateway, "_get_ancestor_pids", lambda: set()) - monkeypatch.setattr(gateway.shutil, "which", lambda name: "wmic.exe" if name == "wmic" else None) - - def fake_run(cmd, **kwargs): - if cmd[:4] == ["wmic.exe", "process", "get", "ProcessId,CommandLine"]: - return SimpleNamespace( - returncode=0, - stdout=( - "CommandLine=C:\\Program Files\\Hermes\\Hermes.EXE gateway run --replace\n" - "ProcessId=2468\n\n" - ), - stderr="", - ) - raise AssertionError(f"Unexpected command: {cmd}") - monkeypatch.setattr(gateway.subprocess, "run", fake_run) + proc = SimpleNamespace( + info={ + "pid": 2468, + "cmdline": ["C:\\Program Files\\Hermes\\Hermes.EXE", "gateway", "run", "--replace"], + } + ) + monkeypatch.setattr(psutil, "process_iter", lambda attrs=None: [proc]) assert gateway._scan_gateway_pids(set(), all_profiles=True) == [2468] -
tests/hermes_cli/test_gateway_windows.py@@ -29,6 +29,31 @@ def test_schtasks_fallback_does_not_hide_unknown_errors(): assert gateway_windows._should_fall_back(1, "ERROR: The system cannot find the file specified.") is False +def test_noninteractive_stop_skips_schtasks_query(monkeypatch, tmp_path): + """Desktop-triggered restarts must not invoke schtasks.exe. + + schtasks is a console-subsystem binary; on Windows Terminal default hosts it + can visibly pop a terminal even for `/Query`. Noninteractive desktop actions + already stop the known gateway PID directly, so service-manager probing is + unnecessary. + """ + + script = tmp_path / "Hermes_Gateway.cmd" + script.write_text("", encoding="utf-8") + + monkeypatch.setenv("HERMES_NONINTERACTIVE", "1") + monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script) + monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: tmp_path / "Hermes_Gateway.vbs") + monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: tmp_path / "Hermes_Gateway.cmd") + monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: pytest.fail("must not call schtasks")) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr(gateway_windows, "_collect_gateway_stop_pids", lambda pid=None: []) + monkeypatch.setattr(gateway_windows, "_force_terminate_known_gateway_pids", lambda pids: 0) + + gateway_windows.stop() + + def test_schtasks_encoding_falls_back_to_utf8(monkeypatch): """A broken/empty locale must not leave us without a decoder (issue #38172).""" @@ -112,6 +137,23 @@ def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, assert str(site_packages) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep) +def test_restart_relaunches_directly_without_start_service_probe(monkeypatch): + calls = [] + + monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr(gateway_windows, "stop", lambda: calls.append("stop")) + monkeypatch.setattr(gateway_windows, "_wait_for_gateway_absent", lambda *a, **k: True) + monkeypatch.setattr(gateway_windows.time, "sleep", lambda *_a, **_k: None) + monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda: 4321) + monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: calls.append(("report", via))) + monkeypatch.setattr(gateway_windows, "_wait_for_gateway_ready", lambda *a, **k: [4321]) + monkeypatch.setattr(gateway_windows, "start", lambda: pytest.fail("restart must not call start()")) + + gateway_windows.restart() + + assert calls == ["stop", ("report", "direct spawn (PID 4321)")] + + class TestStableWindowsGatewayWorkingDir: def test_stable_gateway_working_dir_uses_hermes_home(self, tmp_path, monkeypatch): home = tmp_path / ".hermes" -
tests/hermes_cli/test_web_server.py@@ -215,6 +215,75 @@ def test_falls_back_to_random_token(self, monkeypatch): assert ws._SESSION_TOKEN and len(ws._SESSION_TOKEN) >= 32 +class TestDashboardActionSpawnDetails: + def test_windows_uses_uv_safe_pythonw_with_env_overlay(self, monkeypatch): + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.sys, "platform", "win32") + monkeypatch.setattr(ws.sys, "executable", r"C:\venv\Scripts\python.exe") + monkeypatch.setenv("PYTHONPATH", r"C:\existing") + monkeypatch.setattr( + "hermes_cli.gateway_windows._resolve_detached_python", + lambda _exe: ( + r"C:\base\pythonw.exe", + Path(r"C:\venv"), + [r"C:\venv\Lib\site-packages"], + ), + ) + + executable, env_overlay = ws._dashboard_spawn_details() + + assert executable == r"C:\base\pythonw.exe" + assert env_overlay["VIRTUAL_ENV"] == str(Path(r"C:\venv")) + assert env_overlay["PYTHONPATH"] == os.pathsep.join( + [r"C:\venv\Lib\site-packages", r"C:\existing"] + ) + + def test_windows_falls_back_to_sibling_pythonw_when_resolver_fails(self, monkeypatch): + import hermes_cli.web_server as ws + + exe = "C:/venv/Scripts/python.exe" + expected = "C:/venv/Scripts/pythonw.exe" + + monkeypatch.setattr(ws.sys, "platform", "win32") + monkeypatch.setattr(ws.sys, "executable", exe) + monkeypatch.setattr( + "hermes_cli.gateway_windows._resolve_detached_python", + lambda _exe: (_ for _ in ()).throw(RuntimeError("boom")), + ) + monkeypatch.setattr(ws.os.path, "isfile", lambda candidate: candidate == expected) + + executable, env_overlay = ws._dashboard_spawn_details() + + assert executable == expected + assert env_overlay == {} + + def test_windows_resolves_venv_from_project_when_virtual_env_missing(self, monkeypatch, tmp_path): + import hermes_cli.web_server as ws + + project = tmp_path / "project" + scripts = project / "venv" / "Scripts" + site_packages = project / "venv" / "Lib" / "site-packages" + scripts.mkdir(parents=True) + site_packages.mkdir(parents=True) + (scripts / "python.exe").write_text("", encoding="utf-8") + + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setattr(ws.sys, "platform", "win32") + monkeypatch.setattr(ws.sys, "executable", r"C:\base\pythonw.exe") + monkeypatch.setattr(ws, "PROJECT_ROOT", project) + monkeypatch.setattr( + "hermes_cli.gateway_windows._resolve_detached_python", + lambda exe: (r"C:\base\pythonw.exe", project / "venv", []), + ) + + executable, env_overlay = ws._dashboard_spawn_details() + + assert executable == r"C:\base\pythonw.exe" + assert env_overlay["VIRTUAL_ENV"] == str(project / "venv") + assert str(site_packages) in env_overlay["PYTHONPATH"].split(os.pathsep) + + # --------------------------------------------------------------------------- # web_server tests (FastAPI endpoints) # --------------------------------------------------------------------------- -
tests/test_hermes_bootstrap.py@@ -21,6 +21,7 @@ import io import os +import platform import subprocess import sys import textwrap @@ -231,6 +232,82 @@ def reconfigure(self, **kwargs): hb.apply_windows_utf8_bootstrap() +class TestWindowsPlatformProbeGuard: + def test_windows_bootstrap_disables_platform_syscmd_subprocess(self): + hb = _fresh_import() + hb._IS_WINDOWS = True + hb._bootstrap_applied = False + + original = getattr(platform, "_syscmd_ver", None) + try: + hb.apply_windows_utf8_bootstrap() + + assert platform._syscmd_ver("Windows", "", "") == ("Windows", "", "") + finally: + if original is not None: + platform._syscmd_ver = original + + +class TestDetachOrphanConsole: + """detach_orphan_console() frees a solo-owned console (the uv pythonw→python + phantom) but leaves a shared interactive console attached, and is a pure + no-op on POSIX. It is intentionally NOT run at import time.""" + + def test_noop_on_posix(self): + hb = _fresh_import() + hb._IS_WINDOWS = False + assert hb.detach_orphan_console() is False + + def test_not_called_at_import_time(self): + # The FreeConsole catch-all must be opt-in per background entry point, + # never an import side effect (would detach the interactive CLI/TUI). + import pathlib + src = pathlib.Path(_fresh_import().__file__).read_text(encoding="utf-8") + body = src.split("def detach_orphan_console")[0] + assert "FreeConsole" not in body, ( + "FreeConsole must live only inside detach_orphan_console(), not in " + "apply_windows_utf8_bootstrap() / module import path" + ) + + def _fake_ctypes(self, monkeypatch, window, nproc): + import ctypes + + class _K: + def __init__(self): + self.freed = False + def GetConsoleWindow(self): + return window + def GetConsoleProcessList(self, buf, n): + return nproc + def FreeConsole(self): + self.freed = True + + k = _K() + monkeypatch.setattr(ctypes, "windll", type("_W", (), {"kernel32": k})(), raising=False) + return k + + def test_frees_when_solo_owner(self, monkeypatch): + hb = _fresh_import() + hb._IS_WINDOWS = True + k = self._fake_ctypes(monkeypatch, window=1, nproc=1) + assert hb.detach_orphan_console() is True + assert k.freed is True + + def test_leaves_shared_console_attached(self, monkeypatch): + hb = _fresh_import() + hb._IS_WINDOWS = True + k = self._fake_ctypes(monkeypatch, window=1, nproc=2) + assert hb.detach_orphan_console() is False + assert k.freed is False + + def test_noop_without_console(self, monkeypatch): + hb = _fresh_import() + hb._IS_WINDOWS = True + k = self._fake_ctypes(monkeypatch, window=0, nproc=1) + assert hb.detach_orphan_console() is False + assert k.freed is False + + class TestEntryPointsImportBootstrap: """Every Hermes entry point must import hermes_bootstrap as its first non-docstring import. We check this by scanning source files -
tests/test_hermes_constants.py@@ -1,6 +1,8 @@ """Tests for hermes_constants module.""" import os +import subprocess +import sys from pathlib import Path import pytest @@ -612,6 +614,30 @@ def test_npx_fallback_form_accepted(self): assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True +class TestAgentBrowserRunnableWindows: + def test_windows_validation_hides_console_subprocess(self, tmp_path, monkeypatch): + from hermes_cli import _subprocess_compat + + exe = tmp_path / "agent-browser-win32-x64.exe" + exe.write_text("", encoding="utf-8") + exe.chmod(0o755) + captured = {} + + def fake_run(args, **kwargs): + captured["args"] = args + captured.update(kwargs) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr(subprocess, "run", fake_run) + + assert agent_browser_runnable(str(exe)) is True + assert captured["args"] == [str(exe), "--version"] + assert "creationflags" in captured + assert captured["stdin"] is subprocess.DEVNULL + + class TestGetHermesDir: """Tests for ``get_hermes_dir(new_subpath, old_name)``. -
tests/test_no_visible_console_spawns.py@@ -0,0 +1,121 @@ +"""Enforcement for the "no visible terminal on Windows" invariant. + +Windows console-subsystem programs (``taskkill``, ``schtasks``, ``agent-browser``, +``git-bash`` …) pop a console window unless spawned with ``CREATE_NO_WINDOW``. +Relying on each call site to remember the flag is how cron-driven and future +spawns leaked terminal windows. The durable fix is a single chokepoint — +``hermes_cli._subprocess_compat.run`` / ``.popen`` — that always injects the +flag on Windows, plus the ``FreeConsole`` catch-all in ``hermes_bootstrap`` for +Python children. + +These tests pin both halves of that contract: + +1. The primitive actually injects ``CREATE_NO_WINDOW`` (and merges, so detach + callers still work). +2. No source file spawns a known console exe with a *raw* ``subprocess`` call, + which would bypass the primitive and reintroduce the window. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from hermes_cli import _subprocess_compat + +REPO_ROOT = Path(__file__).resolve().parent.parent +_CREATE_NO_WINDOW = 0x08000000 + + +class TestPrimitiveInjectsNoWindow: + def test_run_injects_create_no_window_on_windows(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr( + _subprocess_compat.subprocess, "run", lambda cmd, **kw: captured.update(kw) or "ok" + ) + + _subprocess_compat.run(["taskkill"], timeout=5) + + assert captured["creationflags"] & _CREATE_NO_WINDOW + + def test_popen_injects_create_no_window_on_windows(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr( + _subprocess_compat.subprocess, "Popen", lambda cmd, **kw: captured.update(kw) or "ok" + ) + + _subprocess_compat.popen(["agent-browser"]) + + assert captured["creationflags"] & _CREATE_NO_WINDOW + + def test_merges_with_existing_detach_flags(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr( + _subprocess_compat.subprocess, "run", lambda cmd, **kw: captured.update(kw) or "ok" + ) + + detach = _subprocess_compat.windows_detach_flags() + _subprocess_compat.run(["x"], creationflags=detach) + + assert captured["creationflags"] & _CREATE_NO_WINDOW + assert captured["creationflags"] & detach == detach + + def test_no_op_on_posix(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", False) + monkeypatch.setattr( + _subprocess_compat.subprocess, "run", lambda cmd, **kw: captured.update(kw) or "ok" + ) + + _subprocess_compat.run(["x"]) + + assert "creationflags" not in captured + + +# Windows-only console tools — they have no POSIX use, so a raw ``subprocess`` +# spawn is unambiguously a Windows path that flashes a terminal. Banning them +# repo-wide is a pure win (cross-platform tools like git/ffmpeg/node are NOT +# listed: they have legitimate foreground/POSIX uses a blanket ban would break; +# their Windows-background call sites are routed through the primitive instead). +# ``_subprocess_compat.run/.popen`` calls never match these (different prefix). +_WINDOWS_ONLY_CONSOLE_EXES = ("taskkill", "schtasks", "wmic", "netstat", "tasklist") +_RAW_CONSOLE_SPAWNS = [ + re.compile(rf"""subprocess\.(?:run|Popen|call)\(\s*\[\s*["']{exe}["']""") + for exe in _WINDOWS_ONLY_CONSOLE_EXES +] + +# The primitive itself is allowed to call raw subprocess — it IS the chokepoint. +_ALLOWED = {REPO_ROOT / "hermes_cli" / "_subprocess_compat.py"} + + +# Dev/CI tooling that never ships to a user's Windows desktop, where a flashing +# console is irrelevant and importing hermes_cli would be inappropriate. +_SKIP_DIRS = {"tests", "node_modules", ".venv", "venv", "scripts"} + + +def _python_sources(): + for path in REPO_ROOT.rglob("*.py"): + if _SKIP_DIRS & set(path.parts): + continue + if path in _ALLOWED: + continue + yield path + + [redacted:email]("pattern", _RAW_CONSOLE_SPAWNS, ids=_WINDOWS_ONLY_CONSOLE_EXES) +def test_no_raw_console_exe_spawns(pattern): + offenders = [ + str(path.relative_to(REPO_ROOT)) + for path in _python_sources() + if pattern.search(path.read_text(encoding="utf-8", errors="ignore")) + ] + + assert not offenders, ( + "Console-subsystem exe spawned via raw subprocess (flashes a terminal on " + f"Windows). Route through hermes_cli._subprocess_compat.run/.popen instead: {offenders}" + ) -
tests/tools/test_browser_hardening.py@@ -89,6 +89,28 @@ def mock_exists(self): with pytest.raises(FileNotFoundError, match="cached"): bt._find_agent_browser() + def test_windows_prefers_native_agent_browser_exe_over_cmd_shim(self, tmp_path, monkeypatch): + import tools.browser_tool as bt + + repo = tmp_path / "repo" + native = repo / "node_modules" / "agent-browser" / "bin" / "agent-browser-win32-x64.exe" + cmd = repo / "node_modules" / ".bin" / "agent-browser.cmd" + native.parent.mkdir(parents=True) + cmd.parent.mkdir(parents=True) + native.write_text("", encoding="utf-8") + cmd.write_text("", encoding="utf-8") + + def fake_which(command, path=None): + return str(cmd) if path == str(cmd.parent) else None + + monkeypatch.setattr(bt.sys, "platform", "win32") + monkeypatch.setattr(bt.shutil, "which", fake_which) + monkeypatch.setattr(bt, "agent_browser_runnable", lambda path: True) + monkeypatch.setattr(bt, "_merge_browser_path", lambda path: "") + monkeypatch.setattr(bt, "__file__", str(repo / "tools" / "browser_tool.py")) + + assert bt._find_agent_browser() == str(native) + # --------------------------------------------------------------------------- # Caching: _get_command_timeout -
tests/tools/test_windows_native_support.py@@ -188,11 +188,13 @@ class TestTerminatePidRoutingOnWindows: def test_force_uses_taskkill_on_windows(self, monkeypatch): from gateway import status + from hermes_cli import _subprocess_compat captured = {} def fake_run(args, **kwargs): captured["args"] = args + captured.update(kwargs) result = MagicMock() result.returncode = 0 result.stderr = "" @@ -200,6 +202,7 @@ def fake_run(args, **kwargs): return result monkeypatch.setattr(status, "_IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) monkeypatch.setattr(status.subprocess, "run", fake_run) status.terminate_pid(12345, force=True) @@ -208,6 +211,7 @@ def fake_run(args, **kwargs): assert "12345" in captured["args"] assert "/T" in captured["args"] assert "/F" in captured["args"] + assert captured["creationflags"] & 0x08000000 def test_force_taskkill_failure_raises_oserror(self, monkeypatch): from gateway import status -
tools/browser_tool.py@@ -68,7 +68,7 @@ from hermes_constants import agent_browser_runnable, get_hermes_home from utils import env_int, is_truthy_value from hermes_cli.config import DEFAULT_CONFIG, cfg_get -from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_cli import _subprocess_compat try: from tools.website_policy import check_website_access @@ -905,23 +905,19 @@ def _run_tmp(cmd: str, cmd_args: List[str]) -> Dict[str, Any]: # fileno=1 (stderr dup'd onto stdout at the OS level). # * close_fds=True → block inheritance of every other handle. # (Default on POSIX; must be explicit on Windows for stdio.) + # CREATE_NO_WINDOW is applied by _subprocess_compat.popen. We do NOT + # add CREATE_NEW_PROCESS_GROUP: on Python 3.11 Windows it interacts + # with asyncio's ProactorEventLoop such that the subprocess creation + # cancels the running loop task, surfacing as KeyboardInterrupt in + # app.run() and tearing down the CLI mid-turn (diag: + # "asyncio.CancelledError → KeyboardInterrupt"). _popen_extra: dict = {} if os.name == "nt": - # CREATE_NO_WINDOW → don't attach a console (cmd.exe would - # otherwise briefly allocate one for the .cmd shim). - # Do NOT add CREATE_NEW_PROCESS_GROUP: on Python 3.11 Windows - # it interacts with asyncio's ProactorEventLoop such that the - # subprocess creation cancels the running loop task, which - # surfaces as KeyboardInterrupt in app.run() and tears down - # the CLI mid-turn. The agent thread's subprocess spawn - # unwound MainThread's prompt_toolkit loop that way — see - # diag log: "asyncio.CancelledError → KeyboardInterrupt". - _popen_extra["creationflags"] = windows_hide_flags() _popen_extra["close_fds"] = True _si = subprocess.STARTUPINFO() _si.dwFlags |= subprocess.STARTF_USESTDHANDLES _popen_extra["startupinfo"] = _si - proc = subprocess.Popen( + proc = _subprocess_compat.popen( full, stdout=stdout_fd, stderr=stderr_fd, stdin=subprocess.DEVNULL, env=browser_env, **_popen_extra, @@ -1942,6 +1938,12 @@ def _find_agent_browser() -> str: repo_root = Path(__file__).parent.parent local_bin_dir = repo_root / "node_modules" / ".bin" if local_bin_dir.is_dir(): + if sys.platform == "win32": + native = repo_root / "node_modules" / "agent-browser" / "bin" / "agent-browser-win32-x64.exe" + if native.exists() and agent_browser_runnable(str(native)): + _cached_agent_browser = str(native) + _agent_browser_resolved = True + return _cached_agent_browser local_which = shutil.which("agent-browser", path=str(local_bin_dir)) if local_which and agent_browser_runnable(local_which): _cached_agent_browser = local_which @@ -2191,17 +2193,16 @@ def _run_browser_command( # three explicit handles (no leaked parent-console handles to # confuse the Rust binary's daemon-spawn), and close_fds=True to # block inheritance of everything else. + # CREATE_NO_WINDOW via _subprocess_compat.popen; NO + # CREATE_NEW_PROCESS_GROUP (cancels asyncio loop task on Python 3.11 + # Windows → KeyboardInterrupt in CLI MainThread). _popen_extra: dict = {} if os.name == "nt": - # See matching block at the other Popen site — CREATE_NO_WINDOW - # only, NO CREATE_NEW_PROCESS_GROUP (cancels asyncio loop task - # on Python 3.11 Windows → KeyboardInterrupt in CLI MainThread). - _popen_extra["creationflags"] = windows_hide_flags() _popen_extra["close_fds"] = True _si = subprocess.STARTUPINFO() _si.dwFlags |= subprocess.STARTF_USESTDHANDLES _popen_extra["startupinfo"] = _si - proc = subprocess.Popen( + proc = _subprocess_compat.popen( cmd_parts, stdout=stdout_fd, stderr=stderr_fd, -
tools/environments/local.py@@ -13,7 +13,7 @@ from pathlib import Path from tools.environments.base import BaseEnvironment, _pipe_stdin -from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_cli import _subprocess_compat _IS_WINDOWS = platform.system() == "Windows" @@ -738,9 +738,7 @@ def _run_bash(self, cmd_string: str, *, login: bool = False, _popen_cwd = self.cwd - _popen_kwargs = {"creationflags": windows_hide_flags()} if _IS_WINDOWS else {} - - proc = subprocess.Popen( + proc = _subprocess_compat.popen( args, text=True, env=run_env, @@ -751,7 +749,6 @@ def _run_bash(self, cmd_string: str, *, login: bool = False, stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL, start_new_session=True, cwd=_popen_cwd, - **_popen_kwargs, ) if not _IS_WINDOWS: try: -
tools/process_registry.py@@ -42,6 +42,7 @@ _IS_WINDOWS = platform.system() == "Windows" from tools.environments.local import _find_shell, _resolve_safe_cwd, _sanitize_subprocess_env +from hermes_cli import _subprocess_compat from hermes_cli._subprocess_compat import windows_hide_flags from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -569,12 +570,11 @@ def _terminate_host_pid(cls, pid: int, expected_start: Optional[int] = None) -> return if _IS_WINDOWS: try: - subprocess.run( + _subprocess_compat.run( ["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, timeout=10, - creationflags=windows_hide_flags(), stdin=subprocess.DEVNULL, ) except (FileNotFoundError, subprocess.TimeoutExpired, OSError): -
tools/transcription_tools.py@@ -37,6 +37,7 @@ from typing import Optional, Dict, Any from urllib.parse import urljoin +from hermes_cli import _subprocess_compat from utils import is_truthy_value from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import ( @@ -485,7 +486,7 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None: if os.name == "nt": try: - subprocess.run( + _subprocess_compat.run( ["taskkill", "/F", "/T", "/PID", str(proc.pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -556,7 +557,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces else: popen_kwargs["start_new_session"] = True - proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL) + proc = _subprocess_compat.popen(command, **popen_kwargs, stdin=subprocess.DEVNULL) try: stdout, stderr = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired as exc: @@ -1187,7 +1188,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], command = [ffmpeg, "-y", "-i", file_path, converted_path] try: - subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + _subprocess_compat.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) return converted_path, None except subprocess.TimeoutExpired: logger.error("ffmpeg conversion timed out for %s", file_path) @@ -1233,9 +1234,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode. use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) if use_shell: - subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + _subprocess_compat.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) else: - subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + _subprocess_compat.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) txt_files = sorted(Path(output_dir).glob("*.txt")) -
tools/tts_tool.py@@ -52,6 +52,7 @@ from typing import Callable, Dict, Any, Optional from urllib.parse import urljoin +from hermes_cli import _subprocess_compat from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) @@ -714,7 +715,7 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None: if os.name == "nt": try: - subprocess.run( + _subprocess_compat.run( ["taskkill", "/F", "/T", "/PID", str(proc.pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -772,7 +773,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces else: popen_kwargs["start_new_session"] = True - proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL) + proc = _subprocess_compat.popen(command, **popen_kwargs, stdin=subprocess.DEVNULL) try: stdout, stderr = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired as exc: @@ -905,7 +906,7 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]: ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg" try: - result = subprocess.run( + result = _subprocess_compat.run( ["ffmpeg", "-i", mp3_path, "-acodec", "libopus", "-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"], capture_output=True, timeout=30, @@ -1776,7 +1777,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] ] else: cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL) + result = _subprocess_compat.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL) if result.returncode != 0: stderr = result.stderr.decode("utf-8", errors="ignore")[:300] raise RuntimeError(f"ffmpeg conversion failed: {stderr}") @@ -1859,7 +1860,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> "--device", device, ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL) + result = _subprocess_compat.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL) if result.returncode != 0: stderr = result.stderr.strip() # Filter out the "OK:" line from stderr @@ -1871,7 +1872,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + _subprocess_compat.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) os.remove(wav_path) else: # No ffmpeg — just rename the WAV to the expected path @@ -1938,7 +1939,7 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str: import sys as _sys logger.info("[Piper] Downloading voice '%s' to %s (first use)", voice, download_dir) try: - result = subprocess.run( + result = _subprocess_compat.run( [_sys.executable, "-m", "piper.download_voices", voice, "--download-dir", str(download_dir)], capture_output=True, text=True, timeout=300, @@ -2050,7 +2051,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + _subprocess_compat.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) try: os.remove(wav_path) except OSError: @@ -2116,7 +2117,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + _subprocess_compat.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) os.remove(wav_path) else: # No ffmpeg — rename the WAV to the expected path @@ -2812,7 +2813,6 @@ def _check(importer, label): # Registry # --------------------------------------------------------------------------- from tools.registry import registry, tool_error -from hermes_cli._subprocess_compat import windows_hide_flags TTS_SCHEMA = { "name": "text_to_speech", -
tui_gateway/entry.py@@ -260,6 +260,13 @@ def join_mcp_discovery(timeout: float | None = None) -> bool: def main(): + # Stdio backend spawned by Node/Electron: drop any console a uv + # pythonw→python re-exec auto-allocated. No-op on POSIX. + try: + hermes_bootstrap.detach_orphan_console() + except Exception: + pass + _install_sidecar_publisher() # MCP tool discovery — runs in a background daemon thread so a slow or -
tui_gateway/git_probe.py@@ -46,7 +46,9 @@ def run_git(cwd: str, *args: str) -> str: if not cwd: return "" try: - result = subprocess.run( + from hermes_cli import _subprocess_compat + + result = _subprocess_compat.run( ["git", "-C", cwd, *args], capture_output=True, text=True, -
tui_gateway/slash_worker.py@@ -93,6 +93,14 @@ def _run(cli: HermesCLI, command: str) -> str: def main(): + # Stdio worker spawned by the gateway: drop any console a uv pythonw→python + # re-exec auto-allocated. No-op on POSIX. + try: + import hermes_bootstrap + hermes_bootstrap.detach_orphan_console() + except Exception: + pass + p = argparse.ArgumentParser(add_help=False) p.add_argument("--session-key", required=True) p.add_argument("--model", default="")
-
-
#53892 fix(windows): hide backend console-flash legs main missed (Electron base-pythonw, ffmpeg, netstat)
files:
apps/desktop/electron/main.cjs,apps/desktop/electron/windows-child-process.test.cjs,plugins/platforms/discord/adapter.py,plugins/platforms/whatsapp/adapter.py,tests/test_windows_subprocess_no_window_flags.py,tools/transcription_tools.py,tools/tts_tool.pyShow the diff
-
apps/desktop/electron/main.cjs@@ -1572,19 +1572,17 @@ function readVenvHome(venvRoot) { function getNoConsoleVenvPython(venvRoot) { if (!IS_WINDOWS) return getVenvPython(venvRoot) - // Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages - // wiring. Falling back to the base uv/python.org pythonw.exe skips the venv - // and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched. - const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe') - if (fileExists(venvPythonw)) return venvPythonw - + // The venv's ``Scripts\pythonw.exe`` is a uv launcher shim that re-execs the + // base console ``python.exe``, allocating a conhost/Windows Terminal window + // that CREATE_NO_WINDOW can't suppress. Use the base ``pythonw.exe`` directly; + // callers put the venv site-packages on PYTHONPATH so imports still resolve. const baseHome = readVenvHome(venvRoot) if (baseHome) { const basePythonw = path.join(baseHome, 'pythonw.exe') if (fileExists(basePythonw)) return basePythonw } - return venvPythonw + return path.join(venvRoot, 'Scripts', 'pythonw.exe') } function toNoConsolePython(pythonPath) { @@ -2847,7 +2845,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, - pythonPathEntries: [root], + pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], venvRoot }), root, @@ -2871,7 +2869,7 @@ function createActiveBackend(dashboardArgs) { args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, - pythonPathEntries: [ACTIVE_HERMES_ROOT], + pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], venvRoot: VENV_ROOT }), root: ACTIVE_HERMES_ROOT, -
apps/desktop/electron/windows-child-process.test.cjs@@ -53,6 +53,22 @@ test('desktop background child processes opt into hidden Windows consoles', () = assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/) }) +test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => { + const source = readElectronFile('main.cjs') + const body = source.slice( + source.indexOf('function getNoConsoleVenvPython(venvRoot)'), + source.indexOf('function getVenvSitePackagesEntries(venvRoot)') + ) + + // The venv Scripts\pythonw.exe re-execs a console python.exe (flashes a + // conhost); the base pythonw must be resolved first so it never runs. + const baseIdx = body.indexOf('basePythonw') + const shimIdx = body.indexOf("'Scripts', 'pythonw.exe'") + assert.notEqual(baseIdx, -1, 'base pythonw resolution missing') + assert.notEqual(shimIdx, -1, 'venv shim fallback missing') + assert.ok(baseIdx < shimIdx, 'base pythonw must be preferred before the venv Scripts shim') +}) + test('intentional or interactive desktop child processes stay documented', () => { const source = readElectronFile('main.cjs') -
plugins/platforms/discord/adapter.py@@ -665,6 +665,8 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, f.write(pcm_data) pcm_path = f.name try: + from hermes_cli._subprocess_compat import windows_hide_flags + subprocess.run( [ "ffmpeg", "-y", "-loglevel", "error", @@ -679,6 +681,7 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, check=True, timeout=10, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), ) finally: try: -
plugins/platforms/whatsapp/adapter.py@@ -78,10 +78,13 @@ def _kill_port_process(port: int) -> None: """Kill any process *listening* on the given TCP port (a stale bridge).""" try: if _IS_WINDOWS: + from hermes_cli._subprocess_compat import windows_hide_flags + # Use netstat to find the PID bound to this port, then taskkill result = subprocess.run( ["netstat", "-ano", "-p", "TCP"], capture_output=True, text=True, timeout=5, + creationflags=windows_hide_flags(), ) for line in result.stdout.splitlines(): parts = line.split() @@ -92,6 +95,7 @@ def _kill_port_process(port: int) -> None: subprocess.run( ["taskkill", "/PID", parts[4], "/F"], capture_output=True, timeout=5, + creationflags=windows_hide_flags(), ) except subprocess.SubprocessError: pass -
tests/test_windows_subprocess_no_window_flags.py@@ -261,3 +261,41 @@ def fake_run(cmd, **kwargs): assert skill_preprocessing.run_inline_shell("echo ok", cwd=None, timeout=5) == "ok" assert captured[0][0] == ["bash", "-c", "echo ok"] assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_tts_opus_conversion_hides_ffmpeg_window(monkeypatch, tmp_path): + from tools import tts_tool + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(returncode=0) + + monkeypatch.setattr(tts_tool, "_has_ffmpeg", lambda: True) + monkeypatch.setattr(tts_tool, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(tts_tool.subprocess, "run", fake_run) + + tts_tool._convert_to_opus(str(tmp_path / "v.mp3")) + + assert captured[0][0][0] == "ffmpeg" + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_local_stt_audio_prep_hides_ffmpeg_window(monkeypatch, tmp_path): + from tools import transcription_tools + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(returncode=0) + + monkeypatch.setattr(transcription_tools, "_find_ffmpeg_binary", lambda: "ffmpeg") + monkeypatch.setattr(transcription_tools, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(transcription_tools.subprocess, "run", fake_run) + + transcription_tools._prepare_local_audio(str(tmp_path / "in.m4a"), str(tmp_path)) + + assert captured[0][0][0] == "ffmpeg" + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW -
tools/transcription_tools.py@@ -37,6 +37,7 @@ from typing import Optional, Dict, Any from urllib.parse import urljoin +from hermes_cli._subprocess_compat import windows_hide_flags from utils import is_truthy_value from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import ( @@ -1187,7 +1188,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], command = [ffmpeg, "-y", "-i", file_path, converted_path] try: - subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) return converted_path, None except subprocess.TimeoutExpired: logger.error("ffmpeg conversion timed out for %s", file_path) @@ -1233,9 +1234,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode. use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) if use_shell: - subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) else: - subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) txt_files = sorted(Path(output_dir).glob("*.txt")) -
tools/tts_tool.py@@ -52,6 +52,7 @@ from typing import Callable, Dict, Any, Optional from urllib.parse import urljoin +from hermes_cli._subprocess_compat import windows_hide_flags from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) @@ -910,6 +911,7 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]: "-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"], capture_output=True, timeout=30, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), ) if result.returncode != 0: logger.warning("ffmpeg conversion failed with return code %d: %s", @@ -1776,7 +1778,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] ] else: cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL) + result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) if result.returncode != 0: stderr = result.stderr.decode("utf-8", errors="ignore")[:300] raise RuntimeError(f"ffmpeg conversion failed: {stderr}") @@ -1871,7 +1873,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) + subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) os.remove(wav_path) else: # No ffmpeg — just rename the WAV to the expected path @@ -2050,7 +2052,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) + subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) try: os.remove(wav_path) except OSError: @@ -2116,7 +2118,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) + subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) os.remove(wav_path) else: # No ffmpeg — rename the WAV to the expected path
-
-
#54236 fix(windows): hide console-window flash on backend git/gh/wmic/bash subprocess spawns
files:
agent/coding_context.py,agent/context_references.py,agent/shell_hooks.py,agent/skill_preprocessing.py,gateway/status.py,hermes_cli/copilot_auth.py,hermes_cli/gateway.py,hermes_cli/main.py,tests/gateway/test_status.py,tests/test_windows_subprocess_no_window_flags.py,tui_gateway/git_probe.py,tui_gateway/server.pyShow the diff
-
agent/coding_context.py@@ -60,6 +60,8 @@ from pathlib import Path from typing import Any, Optional +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + logger = logging.getLogger("hermes.coding_context") CODING_TOOLSET = "coding" @@ -647,12 +649,14 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]: def _git(cwd: Path, *args: str) -> str: + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: out = subprocess.run( ["git", "-C", str(cwd), *args], capture_output=True, text=True, timeout=_GIT_TIMEOUT, + **_popen_kwargs, ) except (OSError, subprocess.SubprocessError): return "" -
agent/context_references.py@@ -12,6 +12,7 @@ from typing import Awaitable, Callable from agent.model_metadata import estimate_tokens_rough +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags _QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')' REFERENCE_PATTERN = re.compile( @@ -290,6 +291,7 @@ def _expand_git_reference( args: list[str], label: str, ) -> tuple[str | None, str | None]: + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: result = subprocess.run( ["git", *args], @@ -298,6 +300,7 @@ def _expand_git_reference( text=True, timeout=30, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) except subprocess.TimeoutExpired: return f"{ref.raw}: git command timed out (30s)", None @@ -483,6 +486,7 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]: def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: result = subprocess.run( ["rg", "--files", str(path.relative_to(cwd))], @@ -491,6 +495,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: text=True, timeout=10, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) except (FileNotFoundError, OSError, subprocess.TimeoutExpired): return None -
agent/shell_hooks.py@@ -122,6 +122,8 @@ from pathlib import Path from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + try: import fcntl # POSIX only; Windows falls back to best-effort without flock. except ImportError: # pragma: no cover @@ -441,6 +443,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: return result t0 = time.monotonic() + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: proc = subprocess.run( argv, @@ -449,6 +452,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: timeout=spec.timeout, text=True, shell=False, + **_popen_kwargs, ) except subprocess.TimeoutExpired: result["timed_out"] = True -
agent/skill_preprocessing.py@@ -5,6 +5,8 @@ import subprocess from pathlib import Path +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + logger = logging.getLogger(__name__) # Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md. @@ -66,6 +68,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: Failures return a short ``[inline-shell error: ...]`` marker instead of raising, so one bad snippet can't wreck the whole skill message. """ + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: completed = subprocess.run( ["bash", "-c", command], @@ -75,6 +78,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: timeout=max(1, int(timeout)), check=False, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) except subprocess.TimeoutExpired: return f"[inline-shell timeout after {timeout}s: {command}]" -
gateway/status.py@@ -81,12 +81,18 @@ def terminate_pid(pid: int, *, force: bool = False) -> None: because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop. """ if force and _IS_WINDOWS: + # CREATE_NO_WINDOW: terminate_pid runs from the windowless pythonw.exe + # gateway/desktop backend, so a bare taskkill spawn would flash a + # conhost window on every force-kill. + from hermes_cli._subprocess_compat import windows_hide_flags + try: result = subprocess.run( ["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, timeout=10, + creationflags=windows_hide_flags(), ) except FileNotFoundError: os.kill(pid, signal.SIGTERM) -
hermes_cli/copilot_auth.py@@ -27,6 +27,8 @@ from pathlib import Path from typing import Optional +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + logger = logging.getLogger(__name__) # OAuth device code flow constants (same client ID as opencode/Copilot CLI) @@ -130,6 +132,7 @@ def _try_gh_cli_token() -> Optional[str]: clean_env = {k: v for k, v in os.environ.items() if k not in {"GITHUB_TOKEN", "GH_TOKEN"}} + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} for gh_path in _gh_cli_candidates(): cmd = [gh_path, "auth", "token"] if hostname: @@ -141,6 +144,7 @@ def _try_gh_cli_token() -> Optional[str]: text=True, timeout=5, env=clean_env, + **_popen_kwargs, ) except (FileNotFoundError, subprocess.TimeoutExpired) as exc: logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) -
hermes_cli/gateway.py@@ -384,6 +384,12 @@ def _matches_gateway_runtime(command: str) -> bool: # removed as part of the WMIC deprecation — fall back to # PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError # on missing wmic) trips the fallback. + # Hide the console window: this scan runs inside the windowless + # pythonw.exe gateway/desktop backend, so a bare wmic/powershell + # spawn would flash a conhost window on every watchdog probe. + from hermes_cli._subprocess_compat import windows_hide_flags + + _no_window = {"creationflags": windows_hide_flags()} wmic_path = shutil.which("wmic") used_fallback = False result = None @@ -402,6 +408,7 @@ def _matches_gateway_runtime(command: str) -> bool: encoding="utf-8", errors="ignore", timeout=10, + **_no_window, ) except (OSError, subprocess.TimeoutExpired): result = None @@ -427,6 +434,7 @@ def _matches_gateway_runtime(command: str) -> bool: encoding="utf-8", errors="ignore", timeout=15, + **_no_window, ) except (OSError, subprocess.TimeoutExpired): return [] -
hermes_cli/main.py@@ -5825,6 +5825,11 @@ def _find_stale_dashboard_pids( # here is errors="ignore": it prevents a reader-thread # UnicodeDecodeError from leaving result.stdout=None and turning # the later .split() into an AttributeError (#17049). + # CREATE_NO_WINDOW hides the conhost flash: this scan can run from + # the windowless pythonw.exe desktop/gateway backend during an + # update, where a bare wmic spawn would pop a console window. + from hermes_cli._subprocess_compat import windows_hide_flags + result = subprocess.run( ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], capture_output=True, @@ -5832,6 +5837,7 @@ def _find_stale_dashboard_pids( timeout=10, encoding="utf-8", errors="ignore", + creationflags=windows_hide_flags(), ) if result.returncode != 0 or result.stdout is None: return [] -
tests/gateway/test_status.py@@ -621,16 +621,22 @@ def test_force_uses_taskkill_on_windows(self, monkeypatch): calls = [] monkeypatch.setattr(status, "_IS_WINDOWS", True) - def fake_run(cmd, capture_output=False, text=False, timeout=None): - calls.append((cmd, capture_output, text, timeout)) + def fake_run(cmd, capture_output=False, text=False, timeout=None, creationflags=0): + calls.append((cmd, capture_output, text, timeout, creationflags)) return SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(status.subprocess, "run", fake_run) status.terminate_pid(123, force=True) + # taskkill is spawned with the no-window flag so the windowless + # pythonw.exe backend doesn't flash a conhost window on force-kill. + # windows_hide_flags() is 0 on the POSIX test host (a valid no-op + # creationflags value); on real Windows it is CREATE_NO_WINDOW. + from hermes_cli._subprocess_compat import windows_hide_flags + assert calls == [ - (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10) + (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10, windows_hide_flags()) ] def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch): -
tests/test_windows_subprocess_no_window_flags.py@@ -0,0 +1,263 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from types import SimpleNamespace + + +_CREATE_NO_WINDOW = 0x08000000 + + +class _Completed: + def __init__(self, stdout: str | bytes = "ok\n", returncode: int = 0): + self.stdout = stdout + self.stderr = "" + self.returncode = returncode + + +def test_tui_gateway_git_probe_hides_git_windows(monkeypatch): + from tui_gateway import git_probe + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(stdout="main\n") + + monkeypatch.setattr(git_probe, "IS_WINDOWS", True) + monkeypatch.setattr(git_probe, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(git_probe.subprocess, "run", fake_run) + + assert git_probe.run_git("C:/repo", "branch", "--show-current") == "main" + + assert captured == [ + ( + ["git", "-C", "C:/repo", "branch", "--show-current"], + { + "capture_output": True, + "text": True, + "encoding": "utf-8", + "errors": "replace", + "timeout": git_probe._GIT_TIMEOUT, + "check": False, + "stdin": subprocess.DEVNULL, + "creationflags": _CREATE_NO_WINDOW, + }, + ) + ] + + +def test_tui_gateway_fuzzy_file_listing_hides_git_windows(monkeypatch): + from hermes_cli import _subprocess_compat + from tui_gateway import server + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + if cmd[-1] == "--show-toplevel": + return _Completed(stdout=b"C:/repo\n") + return _Completed(stdout=b"src/main.py\0README.md\0") + + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(server.subprocess, "run", fake_run) + server._fuzzy_cache.clear() + + assert server._list_repo_files("C:/repo") == ["src/main.py", "README.md"] + + assert [kwargs["creationflags"] for _, kwargs in captured] == [ + _CREATE_NO_WINDOW, + _CREATE_NO_WINDOW, + ] + + +def test_coding_context_git_hides_git_windows(monkeypatch): + from agent import coding_context + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(stdout="clean\n") + + monkeypatch.setattr(coding_context, "IS_WINDOWS", True) + monkeypatch.setattr(coding_context, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(coding_context.subprocess, "run", fake_run) + + assert coding_context._git(Path("C:/repo"), "status", "--short") == "clean" + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_context_reference_git_and_rg_hide_windows(monkeypatch): + from agent import context_references + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + if cmd[0] == "rg": + return _Completed(stdout="src/main.py\n") + return _Completed(stdout="diff --git a/src/main.py b/src/main.py\n") + + monkeypatch.setattr(context_references, "IS_WINDOWS", True) + monkeypatch.setattr(context_references, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(context_references.subprocess, "run", fake_run) + + ref = context_references.ContextReference( + raw="@diff", + kind="diff", + target="", + start=0, + end=5, + ) + warning, block = context_references._expand_git_reference( + ref, + Path("C:/repo"), + ["diff"], + "git diff", + ) + assert warning is None + assert block is not None + assert "git diff" in block + assert context_references._rg_files(Path("C:/repo/src"), Path("C:/repo"), 10) == [ + Path("src/main.py") + ] + + assert [kwargs["creationflags"] for _, kwargs in captured] == [ + _CREATE_NO_WINDOW, + _CREATE_NO_WINDOW, + ] + + +def test_copilot_gh_cli_probe_hides_gh_windows(monkeypatch): + from hermes_cli import copilot_auth + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(stdout="gho_from_cli\n") + + monkeypatch.setattr(copilot_auth, "IS_WINDOWS", True) + monkeypatch.setattr(copilot_auth, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(copilot_auth, "_gh_cli_candidates", lambda: ["gh"]) + monkeypatch.setattr(copilot_auth.subprocess, "run", fake_run) + + assert copilot_auth._try_gh_cli_token() == "gho_from_cli" + assert captured[0][0] == ["gh", "auth", "token"] + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_gateway_pid_scan_hides_wmic_and_powershell_windows(monkeypatch): + from hermes_cli import gateway + from hermes_cli import _subprocess_compat + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + if cmd[0] == "wmic": + return _Completed(stdout="", returncode=1) + return _Completed(stdout="CommandLine=hermes gateway\nProcessId=123\n") + + monkeypatch.setattr(gateway, "is_windows", lambda: True) + monkeypatch.setattr(gateway.shutil, "which", lambda name: name) + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(gateway.subprocess, "run", fake_run) + + assert gateway._scan_gateway_pids(set()) == [123] + assert [kwargs["creationflags"] for _, kwargs in captured] == [ + _CREATE_NO_WINDOW, + _CREATE_NO_WINDOW, + ] + + +def test_stale_dashboard_windows_scan_hides_wmic(monkeypatch): + from hermes_cli import main + from hermes_cli import _subprocess_compat + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(stdout="CommandLine=hermes dashboard\nProcessId=123\n") + + monkeypatch.setattr(main.sys, "platform", "win32") + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(main.subprocess, "run", fake_run) + + assert main._find_stale_dashboard_pids() == [123] + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_gateway_force_kill_hides_taskkill_window(monkeypatch): + from gateway import status + from hermes_cli import _subprocess_compat + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(stdout="") + + monkeypatch.setattr(status, "_IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True) + monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(status.subprocess, "run", fake_run) + + status.terminate_pid(123, force=True) + + assert captured == [ + ( + ["taskkill", "/PID", "123", "/T", "/F"], + { + "capture_output": True, + "text": True, + "timeout": 10, + "creationflags": _CREATE_NO_WINDOW, + }, + ) + ] + + +def test_shell_hooks_hide_hook_command_windows(monkeypatch): + from agent import shell_hooks + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return SimpleNamespace(returncode=0, stdout="{}", stderr="") + + monkeypatch.setattr(shell_hooks, "IS_WINDOWS", True) + monkeypatch.setattr(shell_hooks, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(shell_hooks.subprocess, "run", fake_run) + + result = shell_hooks._spawn( + shell_hooks.ShellHookSpec(event="post_tool_call", command="hook-bin --flag"), + "{}", + ) + + assert result["returncode"] == 0 + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_inline_skill_shell_hides_bash_window(monkeypatch): + from agent import skill_preprocessing + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return SimpleNamespace(returncode=0, stdout="ok\n", stderr="") + + monkeypatch.setattr(skill_preprocessing, "IS_WINDOWS", True) + monkeypatch.setattr(skill_preprocessing, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(skill_preprocessing.subprocess, "run", fake_run) + + assert skill_preprocessing.run_inline_shell("echo ok", cwd=None, timeout=5) == "ok" + assert captured[0][0] == ["bash", "-c", "echo ok"] + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW -
tui_gateway/git_probe.py@@ -31,6 +31,8 @@ from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + _GIT_TIMEOUT = 1.5 _WARM_WORKERS = 8 @@ -45,14 +47,18 @@ def run_git(cwd: str, *args: str) -> str: """``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.""" if not cwd: return "" + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: result = subprocess.run( ["git", "-C", cwd, *args], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=_GIT_TIMEOUT, check=False, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) return result.stdout.strip() if result.returncode == 0 else "" except Exception: -
tui_gateway/server.py@@ -11668,6 +11668,9 @@ def _list_repo_files(root: str) -> list[str]: return cached[1] files: list[str] = [] + from hermes_cli._subprocess_compat import windows_hide_flags + + _creationflags = windows_hide_flags() try: top_result = subprocess.run( ["git", "-C", root, "rev-parse", "--show-toplevel"], @@ -11675,6 +11678,7 @@ def _list_repo_files(root: str) -> list[str]: timeout=2.0, check=False, stdin=subprocess.DEVNULL, + creationflags=_creationflags, ) if top_result.returncode == 0: top = top_result.stdout.decode("utf-8", "replace").strip() @@ -11693,6 +11697,7 @@ def _list_repo_files(root: str) -> list[str]: timeout=2.0, check=False, stdin=subprocess.DEVNULL, + creationflags=_creationflags, ) if list_result.returncode == 0: for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
-
-
#54417 fix(windows): cover remaining console-flash spawn legs
files:
gateway/run.py,gateway/status.py,hermes_cli/gateway.py,hermes_constants.py,tests/gateway/test_restart_drain.py,tests/gateway/test_status.py,tests/test_hermes_constants.py,tests/test_windows_subprocess_no_window_flags.py,tests/tools/test_browser_chromium_check.py,tests/tools/test_browser_homebrew_paths.py,tools/browser_tool.py,tui_gateway/server.pyShow the diff
-
gateway/run.py@@ -5501,6 +5501,19 @@ def _alive(p): # run as a self-restart loop guard and the gateway stays stopped. watcher_env.pop("_HERMES_GATEWAY", None) project_root = Path(__file__).resolve().parent.parent + watcher_python = sys.executable + try: + # Prefer a real GUI-subsystem interpreter for the watcher + # itself. With uv venvs, ``python.exe`` can re-exec the base + # console interpreter and flash even when the Popen carries + # CREATE_NO_WINDOW; pythonw.exe avoids console allocation. + from hermes_cli.gateway_windows import _resolve_detached_python + + watcher_python, _watcher_venv_dir, _watcher_site_packages = ( + _resolve_detached_python(sys.executable) + ) + except Exception: + watcher_python = sys.executable venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv") site_packages = venv_dir / "Lib" / "site-packages" if site_packages.exists(): @@ -5510,7 +5523,7 @@ def _alive(p): pythonpath.append(watcher_env["PYTHONPATH"]) watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) subprocess.Popen( - [sys.executable, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv], + [watcher_python, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=watcher_env, -
gateway/status.py@@ -171,17 +171,18 @@ def _read_process_cmdline(pid: int) -> Optional[str]: if raw: return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() - try: - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "command="], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - except (OSError, subprocess.TimeoutExpired): - pass + if not _IS_WINDOWS: + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass # Windows fallback: psutil (already used by _pid_exists) try: -
hermes_cli/gateway.py@@ -180,7 +180,11 @@ def _get_parent_pid(pid: int) -> int | None: pass except Exception: return None - # Fallback: shell out to ps (POSIX only — bare ``ps`` doesn't exist on Windows). + # Fallback: shell out to ps (POSIX only). Git Bash installs ``ps.exe`` on + # Windows; running it from the windowless desktop/gateway backend flashes a + # console, and psutil above is the authoritative Windows path anyway. + if is_windows(): + return None if not shutil.which("ps"): return None try: -
hermes_constants.py@@ -317,11 +317,14 @@ def node_tool_runnable(path: str | None) -> bool: import subprocess try: + from hermes_cli._subprocess_compat import windows_hide_flags + result = subprocess.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), + creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, ValueError): return False @@ -562,11 +565,14 @@ def agent_browser_runnable(path: str | None) -> bool: import subprocess try: + from hermes_cli._subprocess_compat import windows_hide_flags + result = subprocess.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), + creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, ValueError): return False -
tests/gateway/test_restart_drain.py@@ -353,6 +353,49 @@ def fake_popen(cmd, **kwargs): assert kwargs["stderr"] is subprocess.DEVNULL [redacted:email] +async def test_windows_detached_restart_uses_pythonw_for_watcher(monkeypatch, tmp_path): + runner, _adapter = make_restart_runner() + popen_calls = [] + venv_dir = tmp_path / "venv" + site_packages = venv_dir / "Lib" / "site-packages" + site_packages.mkdir(parents=True) + + monkeypatch.setattr(gateway_run.sys, "platform", "win32") + monkeypatch.setattr(gateway_run.sys, "executable", r"C:\venv\Scripts\python.exe") + monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"]) + monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) + monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) + + import hermes_cli._subprocess_compat as subprocess_compat + import hermes_cli.gateway_windows as gateway_windows + + monkeypatch.setattr( + gateway_windows, + "_resolve_detached_python", + lambda _python: (r"C:\Python311\pythonw.exe", venv_dir, [str(site_packages)]), + ) + monkeypatch.setattr( + subprocess_compat, + "windows_detach_popen_kwargs", + lambda: {"creationflags": 0x08000008}, + ) + + def fake_popen(cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + return MagicMock() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + await runner._launch_detached_restart_command() + + assert len(popen_calls) == 1 + cmd, kwargs = popen_calls[0] + assert cmd[0] == r"C:\Python311\pythonw.exe" + assert cmd[-3:] == ["hermes", "gateway", "restart"] + assert kwargs["creationflags"] == 0x08000008 + + # ── Shutdown notification tests ────────────────────────────────────── -
tests/gateway/test_status.py@@ -2,6 +2,7 @@ import json import os +import sys from pathlib import Path from types import SimpleNamespace @@ -1351,6 +1352,7 @@ class TestReadProcessCmdlinePsFallback: def test_ps_fallback_when_proc_unavailable(self, monkeypatch): monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr(status, "_IS_WINDOWS", False) monkeypatch.setattr( status.subprocess, "run", lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="/usr/libexec/bluetoothuserd\n"), @@ -1360,6 +1362,7 @@ def test_ps_fallback_when_proc_unavailable(self, monkeypatch): def test_ps_fallback_returns_none_on_failure(self, monkeypatch): monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr(status, "_IS_WINDOWS", False) monkeypatch.setattr( status.subprocess, "run", lambda args, **kwargs: SimpleNamespace(returncode=1, stdout=""), @@ -1381,6 +1384,7 @@ def fake_read_bytes(self): def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch): monkeypatch.setattr(status.Path, "read_bytes", lambda self: b"") + monkeypatch.setattr(status, "_IS_WINDOWS", False) monkeypatch.setattr( status.subprocess, "run", lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="python hermes_cli/main.py gateway run\n"), @@ -1388,6 +1392,34 @@ def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch): result = status._read_process_cmdline(12345) assert "hermes_cli/main.py" in result + def test_windows_skips_ps_fallback_and_uses_psutil(self, monkeypatch): + monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr(status, "_IS_WINDOWS", True) + ps_calls = [] + monkeypatch.setattr( + status.subprocess, + "run", + lambda args, **kwargs: ps_calls.append((args, kwargs)) or SimpleNamespace(returncode=0, stdout="ps should not run\n"), + ) + + class _Proc: + def __init__(self, pid): + self.pid = pid + + def cmdline(self): + return ["pythonw.exe", "-m", "hermes_cli.main", "gateway", "run"] + + monkeypatch.setitem( + sys.modules, + "psutil", + SimpleNamespace(Process=_Proc), + ) + + result = status._read_process_cmdline(12345) + + assert result == "pythonw.exe -m hermes_cli.main gateway run" + assert ps_calls == [] + class TestCorruptStatusFiles: """A status / pid file holding non-UTF-8 (binary) bytes must read as -
tests/test_hermes_constants.py@@ -2,6 +2,7 @@ import os from pathlib import Path +from types import SimpleNamespace import pytest @@ -611,6 +612,43 @@ def test_npx_fallback_form_accepted(self): assert agent_browser_runnable("npx agent-browser") is True assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True + def test_version_probe_uses_windows_hide_flags(self, tmp_path, monkeypatch): + good = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho hi\n") + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return SimpleNamespace(returncode=0) + + import hermes_cli._subprocess_compat as subprocess_compat + import subprocess as subprocess_mod + + monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: 0x08000000) + monkeypatch.setattr(subprocess_mod, "run", fake_run) + + assert agent_browser_runnable(str(good)) is True + assert captured[0][0] == [str(good), "--version"] + assert captured[0][1]["creationflags"] == 0x08000000 + + + def test_node_tool_probe_uses_windows_hide_flags(self, tmp_path, monkeypatch): + good = self._stub(tmp_path, "node", "#!/bin/sh\necho v22\n") + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return SimpleNamespace(returncode=0) + + import hermes_cli._subprocess_compat as subprocess_compat + import subprocess as subprocess_mod + + monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: 0x08000000) + monkeypatch.setattr(subprocess_mod, "run", fake_run) + + assert node_tool_runnable(str(good)) is True + assert captured[0][0] == [str(good), "--version"] + assert captured[0][1]["creationflags"] == 0x08000000 + class TestGetHermesDir: """Tests for ``get_hermes_dir(new_subpath, old_name)``. -
tests/test_windows_subprocess_no_window_flags.py@@ -299,3 +299,29 @@ def fake_run(cmd, **kwargs): assert captured[0][0][0] == "ffmpeg" assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + +def test_tui_slash_worker_hides_python_window(monkeypatch): + from tui_gateway import server + + captured = [] + + class _Proc: + stdin = SimpleNamespace() + stdout = [] + stderr = [] + + def fake_popen(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Proc() + + monkeypatch.setattr(server.subprocess, "Popen", fake_popen) + monkeypatch.setattr(server.threading, "Thread", lambda *a, **k: SimpleNamespace(start=lambda: None)) + + import hermes_cli._subprocess_compat as subprocess_compat + + monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + + server._SlashWorker("session-key", "model-x") + + assert captured[0][0][:3] == [server.sys.executable, "-m", "tui_gateway.slash_worker"] + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW -
tests/tools/test_browser_chromium_check.py@@ -76,7 +76,7 @@ class TestCheckBrowserRequirementsChromium: def test_local_mode_with_chromium_returns_true(self, monkeypatch, tmp_path): monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") + monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser") monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None) monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) @@ -93,7 +93,7 @@ def provider_name(self): return "browserbase" monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") + monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser") monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) monkeypatch.setattr(bt, "_get_cloud_provider", lambda: FakeProvider()) # Point chromium search at an empty dir — should not matter for cloud. @@ -102,6 +102,23 @@ def provider_name(self): assert bt.check_browser_requirements() is True + def test_startup_check_uses_lightweight_agent_browser_lookup(self, monkeypatch, tmp_path): + seen = [] + + def fake_find_agent_browser(**kwargs): + seen.append(kwargs) + return "/usr/local/bin/agent-browser" + + monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) + monkeypatch.setattr(bt, "_find_agent_browser", fake_find_agent_browser) + monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None) + monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) + (tmp_path / "chromium-1208").mkdir() + + assert bt.check_browser_requirements() is True + assert seen == [{"validate": False}] + def test_camofox_mode_does_not_require_chromium(self, monkeypatch, tmp_path): monkeypatch.setattr(bt, "_is_camofox_mode", lambda: True) # Even with no chromium on disk, camofox drives its own backend. -
tests/tools/test_browser_homebrew_paths.py@@ -222,7 +222,7 @@ def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monke monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False) monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None) - monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser") + monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser") assert check_browser_requirements() is False @@ -231,7 +231,7 @@ class TestRunBrowserCommandTermuxFallback: def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch): monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") - monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser") + monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser") monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None) result = _run_browser_command("task-1", "navigate", ["https://example.com"]) -
tools/browser_tool.py@@ -2002,7 +2002,15 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]: -def _find_agent_browser() -> str: +def _agent_browser_candidate_present(path: str | None) -> bool: + if not path: + return False + if " " in path and path.split()[0].endswith("npx"): + return True + return os.path.exists(path) and (os.name == "nt" or os.access(path, os.X_OK)) + + +def _find_agent_browser(*, validate: bool = True) -> str: """ Find the agent-browser CLI executable. @@ -2041,7 +2049,11 @@ def _find_agent_browser() -> str: # Check if it's in PATH (global install) which_result = shutil.which("agent-browser") - if which_result and agent_browser_runnable(which_result): + if which_result and ( + agent_browser_runnable(which_result) if validate else _agent_browser_candidate_present(which_result) + ): + if not validate: + return which_result _cached_agent_browser = which_result _agent_browser_resolved = True return which_result @@ -2051,7 +2063,11 @@ def _find_agent_browser() -> str: extended_path = _merge_browser_path("") if extended_path: which_result = shutil.which("agent-browser", path=extended_path) - if which_result and agent_browser_runnable(which_result): + if which_result and ( + agent_browser_runnable(which_result) if validate else _agent_browser_candidate_present(which_result) + ): + if not validate: + return which_result _cached_agent_browser = which_result _agent_browser_resolved = True return which_result @@ -2068,7 +2084,11 @@ def _find_agent_browser() -> str: local_bin_dir = repo_root / "node_modules" / ".bin" if local_bin_dir.is_dir(): local_which = shutil.which("agent-browser", path=str(local_bin_dir)) - if local_which and agent_browser_runnable(local_which): + if local_which and ( + agent_browser_runnable(local_which) if validate else _agent_browser_candidate_present(local_which) + ): + if not validate: + return local_which _cached_agent_browser = local_which _agent_browser_resolved = True return _cached_agent_browser @@ -2078,10 +2098,15 @@ def _find_agent_browser() -> str: if not npx_path and extended_path: npx_path = shutil.which("npx", path=extended_path) if npx_path: + if not validate: + return "npx agent-browser" _cached_agent_browser = "npx agent-browser" _agent_browser_resolved = True return _cached_agent_browser + if not validate: + raise FileNotFoundError("agent-browser CLI not found") + # Nothing found — try lazy installation before giving up. try: from hermes_cli.dep_ensure import ensure_dependency @@ -4206,8 +4231,12 @@ def check_browser_requirements() -> bool: return True # The agent-browser CLI is required for local launch and cloud-provider flows. + # Tool-schema assembly runs during Desktop startup; do not execute + # ``agent-browser --version`` here, because Windows .cmd shims route through + # cmd.exe and can flash a console before the user invokes any browser tool. + # Actual browser execution paths still validate the candidate before use. try: - browser_cmd = _find_agent_browser() + browser_cmd = _find_agent_browser(validate=False) except FileNotFoundError: return False -
tui_gateway/server.py@@ -278,6 +278,8 @@ def __init__(self, session_key: str, model: str): argv += ["--model", model] self._closed = False + from hermes_cli._subprocess_compat import windows_hide_flags + self.proc = subprocess.Popen( argv, stdin=subprocess.PIPE, @@ -289,6 +291,7 @@ def __init__(self, session_key: str, model: str): # slash_worker runs the Hermes agent → needs provider credentials. # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). env=hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment] + creationflags=windows_hide_flags(), ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start()
-
15 PRs closed without merging
From the close comments: superseded by another PR 5 · done elsewhere 4 · duplicate 3 · closed without comment 2 · other 1
-
#42605 fix(terminal): prevent Windows cmd flash (#42544) · done elsewhere →
aa2ae36 - #44105 fix(windows): hide console-window flash on gateway-runtime subprocess spawns · superseded by another PR
- #49931 fix(terminal): bypass Git Bash wrapper to prevent console flashes on Windows · closed without comment
-
#50183 fix(terminal): suppress console window flash on Windows via CREATE_NO_WINDOW (#49851, #42544) · done elsewhere →
aa2ae36,#54220,#42544,#49851 -
#53119 fix: add CREATE_NO_WINDOW to Windows subprocess Popen calls to prevent cmd.exe flashing · superseded by another PR →
#54220,#54236,#53892,#54417
Show 10 more
-
#53123 fix(windows): suppress console-window flashes from gh/git probes in desktop gateway · done elsewhere →
#53844,#53791,#53810,#53829 -
#53291 fix: suppress console window flashes on Windows via global subprocess patch (#53282) · superseded by another PR →
#54220,#54236,#53892,#54417 - #53344 fix(desktop): avoid uv venv pythonw launcher console flash on Windows · closed without comment
-
#53879 fix(windows): hide backend subprocess windows by default · superseded by another PR →
#54220,#54236,#53892,#54417 -
#54253 fix(win32): add CREATE_NO_WINDOW to all background subprocess calls · superseded by another PR →
cb982ad -
#55613 fix(cli): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags() · other →
0dbf639,aa2ae36,#70205 -
#55667 fix(cli): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags() · duplicate →
#55613,#55604 -
#55692 fix(cli): include breakaway flag for hidden Windows subprocesses · duplicate →
#55613,#55604,#55667 -
#55753 fix(windows): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags() · duplicate →
#55613,#55604,#55667,#55692 -
#64081 fix: add CREATE_NO_WINDOW to env_probe and lazy_deps subprocess calls on Windows · done elsewhere →
aa2ae36
Closed issues · 24
- Closed#24073[Feature]: Hidden Terminal exec on Windows Native
- Closed#42544Windows: terminal tool flashes cmd window on every command execution
- Closed#43848[Feature]: Set the "CREATE_NO_WINDOW" flag for Window terminal process
- Closed#49851Windows: terminal tool creates visible bash/cmd windows that steal focus
- Closed#52310[Bug]: Black console window flashes briefly whenever terminal spawns bash on Windows
- Closed#52788bug(desktop): CMD console window flashes on every terminal command on Windows
- Closed#52981[Bug]: Windows 11 Hermes Desktop — 频繁弹出黑色控制台窗口闪烁(git.exe / gh.exe / cmd.exe)
- Closed#52982[Bug]: Windows 11 Hermes Desktop — 频繁弹出黑色控制台窗口闪烁(git.exe / gh.exe / cmd.exe)
- Closed#53014Windows: terminal 工具强制使用 bash 导致频繁弹窗
- Closed#53016[Bug] Windows desktop app flashes cmd console window intermittently after update
- Closed#53053[Bug]: Subprocess calls flash black cmd.exe windows on Windows
- Closed#53273Windows Desktop GUI: CREATE_NO_WINDOW insufficient — cmd windows flash on every terminal() call
- Closed#53282Windows: Console window flashes on every subprocess call (missing CREATE_NO_WINDOW)
- Closed#53342[Bug]: The Hermes desktop client on Windows keeps flickering black command prompt windows nonstop, making the program completely inoperable. This is a critical blocking bug!
- Closed#53424[Bug] Windows: rapid conhost.exe window flashing due to missing CREATE_NO_WINDOW in subprocess calls
- Closed#53751Windows 10 desktop app triggers infinite PowerShell flash loop on launch
- Closed#54323windows_hide_flags() missing CREATE_BREAKAWAY_FROM_JOB — Desktop app console flash persists after #54236
- Closed#54506Command prompt keep blinking at screen when desktop app is open
- Closed#55604windows_hide_flags() missing CREATE_BREAKAWAY_FROM_JOB — cmd.exe windows flash in Electron desktop apps (Hermes One, etc.)
- Closed#55627[Bug]: OpenConsole.exe / conhost.exe / git.exe 进程无限闪烁弹窗 — Windows Electron 桌面版
- Closed#55633[Bug]: OpenConsole.exe / conhost.exe / git.exe processes infinitely flash and spawn — Windows Electron Desktop
- Closed#56503Windows subprocess console flashes — request for official fix
- Closed#56747[Windows] Blank terminal console windows flash when running agent or tools on desktop GUI
- Closed#63698[Bug]: Console windows flash/appear despite windows_hide_console: true
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab