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.
This commit is contained in:
brooklyn! 2026-06-27 16:02:24 -05:00 committed by GitHub
parent ef17cd204d
commit 5db1430af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 693 additions and 107 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (``<profile> 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:

View file

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

View file

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

View file

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

View file

@ -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 = []

View file

@ -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=""),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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