mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
* 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.
121 lines
4.6 KiB
Python
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}"
|
|
)
|