fix(windows): cover remaining console-flash spawn legs (#54417)

This commit is contained in:
Teknium 2026-06-28 13:49:08 -07:00 committed by GitHub
parent b31b0b9d95
commit 9a0010fd46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 234 additions and 22 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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 ──────────────────────────────────────

View file

@ -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

View file

@ -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)``.

View file

@ -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

View file

@ -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.

View file

@ -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"])

View file

@ -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

View file

@ -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()