diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5d859bca649..b5bb04b876d 100644 --- a/apps/desktop/electron/main.cjs +++ b/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, diff --git a/cron/scheduler.py b/cron/scheduler.py index 410e9d7dc77..a755532dec6 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -3046,4 +3046,11 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i 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) diff --git a/gateway/run.py b/gateway/run.py index 77d0c5d088b..11f0c5056e6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18593,6 +18593,13 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = 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: diff --git a/gateway/status.py b/gateway/status.py index 8998c7a7a64..2b22e41222e 100644 --- a/gateway/status.py +++ b/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, diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index ae23cc97629..ef1752404d1 100644 --- a/hermes_bootstrap.py +++ b/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. diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 607a9a3e6a4..8ceec8fecc0 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -28,12 +28,15 @@ guarantee. 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 diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 792e35c1683..4e7d95c0537 100644 --- a/hermes_cli/claw.py +++ b/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, ) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index a6b6da7c06a..f048b11b261 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -198,7 +198,9 @@ _POWERSHELL_EXTRACT_IMAGE_SCRIPTS = ( 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, ) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 83f598dff1a..ed0caacc777 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -380,6 +380,24 @@ def _scan_gateway_pids( 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 _scan_gateway_pids( result = None if wmic_path is not None: try: - result = subprocess.run( + result = _subprocess_compat.run( [ wmic_path, "process", @@ -421,7 +439,7 @@ def _scan_gateway_pids( "}" ) 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 diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 55ed976433d..0a5cf100ea4 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -39,10 +39,10 @@ import time 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( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0ca290a8f3c..77e35486d97 100644 --- a/hermes_cli/main.py +++ b/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, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3b45e305abd..f1b66afa73b 100644 --- a/hermes_cli/web_server.py +++ b/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 (`` 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: diff --git a/hermes_constants.py b/hermes_constants.py index 274bed4b003..c74fba4e3f6 100644 --- a/hermes_constants.py +++ b/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 diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index de4d1887cb8..03dca07ee68 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -665,7 +665,9 @@ class VoiceReceiver: 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 @@ class VoiceReceiver: check=True, timeout=10, stdin=subprocess.DEVNULL, - creationflags=windows_hide_flags(), ) finally: try: diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index dc4361213e5..6644c0c6aa3 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/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, diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 42165b74803..5bc80371a2c 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -618,20 +618,25 @@ class TestGetProcessStartTime: 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 = [] diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index 96817320a08..cb06a913668 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -725,8 +725,9 @@ class TestDetectOpenclawProcesses: 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 @@ class TestDetectOpenclawProcesses: 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 @@ class TestDetectOpenclawProcesses: 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=""), diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 9fb3e99caca..171fc06cf9b 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/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] diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index d52ad7d59da..6020a5dcdd3 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/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" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 2377661aa1d..9cfcdae6811 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -215,6 +215,75 @@ class TestSessionTokenInjection: 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) # --------------------------------------------------------------------------- diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py index 50a582bf998..4aabebddaa7 100644 --- a/tests/test_hermes_bootstrap.py +++ b/tests/test_hermes_bootstrap.py @@ -21,6 +21,7 @@ from __future__ import annotations import io import os +import platform import subprocess import sys import textwrap @@ -231,6 +232,82 @@ class TestStdioReconfigureErrorHandling: 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 diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 46ff5a3ce6b..8875430b828 100644 --- a/tests/test_hermes_constants.py +++ b/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 @@ class TestAgentBrowserRunnable: 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)``. diff --git a/tests/test_no_visible_console_spawns.py b/tests/test_no_visible_console_spawns.py new file mode 100644 index 00000000000..fd0d13ab3b1 --- /dev/null +++ b/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 + + +@pytest.mark.parametrize("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}" + ) diff --git a/tests/tools/test_browser_hardening.py b/tests/tools/test_browser_hardening.py index d004fd84316..0481ed86615 100644 --- a/tests/tools/test_browser_hardening.py +++ b/tests/tools/test_browser_hardening.py @@ -89,6 +89,28 @@ class TestFindAgentBrowserCache: 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 diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 2e6606ead5b..8bfb30f947b 100644 --- a/tests/tools/test_windows_native_support.py +++ b/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 @@ class TestTerminatePidRoutingOnWindows: 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 @@ class TestTerminatePidRoutingOnWindows: 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 diff --git a/tools/browser_tool.py b/tools/browser_tool.py index d679997d3bf..f4447c5cd34 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -68,7 +68,7 @@ from agent.auxiliary_client import call_llm 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_chrome_fallback_command( # 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, diff --git a/tools/environments/local.py b/tools/environments/local.py index 71ba4e97f43..24fafe0e8ae 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -13,7 +13,7 @@ import time 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 @@ class LocalEnvironment(BaseEnvironment): _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 @@ class LocalEnvironment(BaseEnvironment): 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: diff --git a/tools/process_registry.py b/tools/process_registry.py index e21c68af993..0d99b601249 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -42,6 +42,7 @@ import uuid _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 @@ class ProcessRegistry: 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): diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index d0712c81e1e..60b3ea7c0d4 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -37,6 +37,7 @@ from pathlib import Path 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")) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 8e8f22697d6..fca854cf601 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -52,6 +52,7 @@ from pathlib import Path 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 @@ if __name__ == "__main__": # Registry # --------------------------------------------------------------------------- from tools.registry import registry, tool_error -from hermes_cli._subprocess_compat import windows_hide_flags TTS_SCHEMA = { "name": "text_to_speech", diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 0a39e69766e..8b6b7539f46 100644 --- a/tui_gateway/entry.py +++ b/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 diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py index 01b7998ad14..5ff242f5c12 100644 --- a/tui_gateway/git_probe.py +++ b/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, diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py index fce8ec3e26b..0eef247cc7e 100644 --- a/tui_gateway/slash_worker.py +++ b/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="")