mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
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:
parent
ef17cd204d
commit
5db1430af9
33 changed files with 693 additions and 107 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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=""),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)``.
|
||||
|
||||
|
|
|
|||
121
tests/test_no_visible_console_spawns.py
Normal file
121
tests/test_no_visible_console_spawns.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue