diff --git a/gateway/run.py b/gateway/run.py index 429f1ce0b54..c6eaabf922a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5501,6 +5501,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # 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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew 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, diff --git a/gateway/status.py b/gateway/status.py index 80c0f8286f0..9b8a1b6f83c 100644 --- a/gateway/status.py +++ b/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: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 2624b43253d..a39ef54ad56 100644 --- a/hermes_cli/gateway.py +++ b/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: diff --git a/hermes_constants.py b/hermes_constants.py index 274bed4b003..526bb0ed473 100644 --- a/hermes_constants.py +++ b/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 diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 56c0ce7aeba..e9cae47ac9f 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -353,6 +353,49 @@ async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_p assert kwargs["stderr"] is subprocess.DEVNULL +@pytest.mark.asyncio +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 ────────────────────────────────────── diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 7301656f6bd..ab4c9415743 100644 --- a/tests/gateway/test_status.py +++ b/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 @@ class TestReadProcessCmdlinePsFallback: 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 @@ class TestReadProcessCmdlinePsFallback: 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 @@ class TestReadProcessCmdlinePsFallback: 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 diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 46ff5a3ce6b..8635c6827c8 100644 --- a/tests/test_hermes_constants.py +++ b/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 @@ class TestAgentBrowserRunnable: 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)``. diff --git a/tests/test_windows_subprocess_no_window_flags.py b/tests/test_windows_subprocess_no_window_flags.py index 3f877bc32bb..7768f5fec08 100644 --- a/tests/test_windows_subprocess_no_window_flags.py +++ b/tests/test_windows_subprocess_no_window_flags.py @@ -299,3 +299,29 @@ def test_local_stt_audio_prep_hides_ffmpeg_window(monkeypatch, tmp_path): 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 diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index 33df88735d5..f6641e7951e 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/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 @@ class TestCheckBrowserRequirementsChromium: 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 @@ class TestCheckBrowserRequirementsChromium: 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. diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index fbb1749d4d6..6994250bfd0 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -222,7 +222,7 @@ class TestBrowserRequirements: 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"]) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index ffd69e6bdef..8cd191c07c5 100644 --- a/tools/browser_tool.py +++ b/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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ea4ae4fbbf9..a654d690496 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -278,6 +278,8 @@ class _SlashWorker: 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 @@ class _SlashWorker: # 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=True), + creationflags=windows_hide_flags(), ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start()