hermes-agent/tests/test_no_visible_console_spawns.py
brooklyn! 5db1430af9
fix(windows): stop terminal-window popups from background spawns (#53810)
* fix(windows): stop terminal-window popups from background spawns

Native-Windows desktop/gateway users saw cmd/conhost windows flash on
gateway restart, image paste, the dashboard Projects tree, voice notes,
and ~5 min after closing the app (detached cron). Two root causes:

- Console-subsystem exes (taskkill, schtasks, wmic, netstat, tasklist,
  agent-browser, git, ffmpeg, powershell, git-bash) spawned via raw
  subprocess allocate a fresh console when the launching process has
  none (pythonw desktop backend / detached gateway) - even with output
  captured.
- uv venv pythonw shims re-exec console python.exe, so Python children
  get a console regardless of how they're launched.

Fixes:
- Single hidden-spawn primitive (_subprocess_compat.run/.popen) that ORs
  CREATE_NO_WINDOW on Windows, no-op on POSIX. Route every Hermes-owned
  console-exe spawn through it.
- FreeConsole() catch-all in hermes_bootstrap: any Python child that
  exclusively owns an auto-allocated console detaches it at startup
  (GetConsoleProcessList()==1 gate leaves shared interactive consoles
  untouched).
- Replace PowerShell/wmic gateway PID scans with in-process psutil.
- Skip schtasks queries on non-interactive desktop restarts.
- Prefer native agent-browser .exe over .cmd shims.
- Guard test bans raw subprocess spawns of the Windows-only console
  tools repo-wide so the popup class can't regress.

* fix(windows): scope FreeConsole to background entry points; fix merge fallout

Console detach review (per #53810 feedback): GetConsoleProcessList()==1 can't
tell a uv pythonw->python phantom console apart from a user opening the
interactive CLI/TUI in its own fresh console (double-click, shortcut, ConPTY) —
both report a single attached process with a tty. Running FreeConsole() in the
import-time bootstrap therefore risked detaching a legitimately-interactive
terminal.

- Extract FreeConsole into explicit hermes_bootstrap.detach_orphan_console();
  remove it from apply_windows_utf8_bootstrap() (import side effect).
- Call it only from known background mains: gateway run, dashboard backend
  (start_server, what the desktop spawns), cron standalone, tui_gateway entry,
  slash worker. Interactive CLI/TUI never calls it.
- Behavior-contract tests: frees only when solo owner, leaves shared console,
  no-op without console / on POSIX, and asserts it's not an import side effect.

Merge fallout from origin/main (#53791):
- local.py: 3-way merge left a dangling **_popen_kwargs (NameError crashing
  every terminal init). _subprocess_compat.popen already hides the window, so
  drop it.
- discord adapter: merge stacked an undefined windows_hide_flags() onto the
  primitive call; drop the redundant arg.
- test_gateway: scan now goes psutil-first (zero spawn); rewrite the
  case-variant test to drive that production path.

* test(claw): mock _subprocess_compat.run seam for Windows process scan

claw.py's Windows tasklist/powershell scan routes through the hidden-spawn
primitive; the tests still patched claw_mod.subprocess, so on win32 the mock
was never hit and real spawns returned nothing. Patch the actual seam.
2026-06-27 14:02:24 -07:00

121 lines
4.6 KiB
Python

"""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}"
)