Windows gateway console window and launcher hardening
NousResearch/hermes-agent · cluster 30308 · agent/pet
Data as of
Nothing open on GitHub. Fix merged in #45610. Check it covers your case.
The short answer
- Known?
- Yes. 21 related items grouped here, 10 issues already closed.
- Fix event
- A fix was merged to main in PR #45610. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #45610. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes concern visible console/cmd windows appearing during Windows gateway startup, restart, and subprocess spawning, particularly involving uv venvs, pythonw vs python.exe selection, scheduled task configuration, and stale pre-existing autostart launchers.
Open issues0
Open PRs0
Fixes on record
-
#45610 fix(windows): harden gateway scheduled task
files:
hermes_cli/gateway_windows.py,tests/hermes_cli/test_gateway_windows.pyShow the diff
-
hermes_cli/gateway_windows.py@@ -38,6 +38,7 @@ import sys import time from pathlib import Path +from xml.sax.saxutils import escape # Short timeouts: schtasks occasionally wedges and we don't want to hang forever. _SCHTASKS_TIMEOUT_S = 15 @@ -51,6 +52,9 @@ _TASK_NAME_DEFAULT = "Hermes_Gateway" _TASK_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" +_TASK_LOGON_DELAY = "PT30S" +_TASK_RESTART_INTERVAL = "PT1M" +_TASK_RESTART_COUNT = 999 def _schtasks_encoding() -> str: @@ -358,12 +362,13 @@ def _build_gateway_cmd_script( lines.append(f'set "HERMES_HOME={hermes_home}"') lines.append('set "PYTHONIOENCODING=utf-8"') lines.append('set "HERMES_GATEWAY_DETACHED=1"') + pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) # VIRTUAL_ENV lets the gateway's own python detection find the venv # if someone imports hermes_constants-based logic during startup. - venv_dir = str(Path(python_path).resolve().parent.parent) lines.append(f'set "VIRTUAL_ENV={venv_dir}"') + pythonpath_entries = [str(Path(__file__).resolve().parent.parent), *extra_pythonpath] + lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"') - pythonw_path = _derive_venv_pythonw(python_path) prog_args = [pythonw_path, "-m", "hermes_cli.main"] if profile_arg: prog_args.extend(profile_arg.split()) @@ -379,6 +384,78 @@ def _build_gateway_cmd_script( return "\r\n".join(lines) + "\r\n" +def _quote_vbs_string(value: str) -> str: + """Quote a value as a VBScript double-quoted string literal. + + VBScript escapes an embedded double-quote by doubling it. A newline cannot + appear inside a literal, so refuse it (same guard as ``_quote_cmd_script_arg``). + """ + if "\r" in value or "\n" in value: + raise ValueError(f"refusing to quote VBScript value containing newline: {value!r}") + return '"' + value.replace('"', '""') + '"' + + +def _build_gateway_vbs_script( + python_path: str, + working_dir: str, + hermes_home: str, + profile_arg: str, +) -> str: + """Build a console-less ``gateway.vbs`` launcher (CRLF-terminated). + + The Scheduled Task runs this through ``wscript.exe`` instead of ``cmd.exe``. + + Why: issue #45599 root cause #1. Driving the gateway through ``cmd.exe`` + allocates a console, and during logon Windows broadcasts ``CTRL_CLOSE_EVENT`` + to console process groups — reaping cmd.exe and the half-initialized gateway + with ``STATUS_CONTROL_C_EXIT`` (``0xC000013A``). Task Scheduler treats that + code as a user cancel, so the ``RestartOnFailure`` policy never fires and the + gateway silently disappears on every reboot. + + ``wscript.exe`` and ``pythonw.exe`` are both GUI-subsystem executables with + no console, so this launcher receives no console control events. It mirrors + ``_build_gateway_cmd_script`` (same env + argv via ``_resolve_detached_python``) + but sets the environment on the WScript.Shell process and ``Run``s pythonw + directly — no cmd.exe anywhere in the chain. + """ + pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) + + prog_args = [pythonw_path, "-m", "hermes_cli.main"] + if profile_arg: + prog_args.extend(profile_arg.split()) + prog_args.extend(["gateway", "run"]) + # list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run. + command_line = subprocess.list2cmdline(prog_args) + + repo_root = str(Path(__file__).resolve().parent.parent) + static_pythonpath = os.pathsep.join([repo_root, *extra_pythonpath]) + + lines = [ + f"' {_TASK_DESCRIPTION}", + "Option Explicit", + "Dim sh, env, existing_pp", + 'Set sh = CreateObject("WScript.Shell")', + 'Set env = sh.Environment("PROCESS")', + f"env.Item({_quote_vbs_string('HERMES_HOME')}) = {_quote_vbs_string(hermes_home)}", + f"env.Item({_quote_vbs_string('PYTHONIOENCODING')}) = {_quote_vbs_string('utf-8')}", + f"env.Item({_quote_vbs_string('HERMES_GATEWAY_DETACHED')}) = {_quote_vbs_string('1')}", + f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(str(venv_dir))}", + # Mirror the cmd wrapper's ``PYTHONPATH=<static>;%PYTHONPATH%``: chain onto + # whatever PYTHONPATH the task environment already carries, at runtime. + f"existing_pp = env.Item({_quote_vbs_string('PYTHONPATH')})", + "If Len(existing_pp) > 0 Then", + f" env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath + os.pathsep)} & existing_pp", + "Else", + f" env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath)}", + "End If", + f"sh.CurrentDirectory = {_quote_vbs_string(working_dir)}", + # Window style 0 = hidden; bWaitOnReturn False = detached/async. pythonw is + # GUI-subsystem so no console is ever created for the gateway either. + f"sh.Run {_quote_vbs_string(command_line)}, 0, False", + ] + return "\r\n".join(lines) + "\r\n" + + def _build_startup_launcher(script_path: Path) -> str: """The tiny .cmd that goes in the Startup folder. Just minimizes and chains. @@ -425,6 +502,15 @@ def _write_task_script() -> Path: tmp = script_path.with_suffix(".tmp") tmp.write_text(content, encoding="utf-8", newline="") tmp.replace(script_path) + + # Also render the console-less .vbs launcher the Scheduled Task runs via + # wscript.exe (issue #45599 fix A). The .cmd above stays for the + # Startup-folder fallback and direct /Run paths. + vbs_content = _build_gateway_vbs_script(python_path, working_dir, hermes_home, profile_arg) + vbs_path = script_path.with_suffix(".vbs") + vbs_tmp = vbs_path.with_name(vbs_path.name + ".tmp") + vbs_tmp.write_text(vbs_content, encoding="utf-8", newline="") + vbs_tmp.replace(vbs_path) return script_path @@ -443,6 +529,74 @@ def _resolve_task_user() -> str | None: return f"{domain}\\{username}" if domain else username +def _build_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> str: + """Render a Task Scheduler XML definition with safe long-running defaults. + + ``launcher_path`` is the console-less ``.vbs`` the task runs via + ``wscript.exe`` — not the ``.cmd`` (see ``_build_gateway_vbs_script`` / + issue #45599 root cause #1). + """ + user_principal = f"\n <UserId>{escape(user)}</UserId>" if user else "" + return f"""<?xml version="1.0" encoding="UTF-16"?> +<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> + <RegistrationInfo> + <Description>{escape(_TASK_DESCRIPTION)}</Description> + </RegistrationInfo> + <Triggers> + <LogonTrigger> + <Enabled>true</Enabled> + <Delay>{_TASK_LOGON_DELAY}</Delay> + </LogonTrigger> + </Triggers> + <Principals> + <Principal id="Author">{user_principal} + <LogonType>InteractiveToken</LogonType> + <RunLevel>LeastPrivilege</RunLevel> + </Principal> + </Principals> + <Settings> + <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> + <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> + <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> + <AllowHardTerminate>true</AllowHardTerminate> + <StartWhenAvailable>true</StartWhenAvailable> + <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable> + <IdleSettings> + <StopOnIdleEnd>false</StopOnIdleEnd> + <RestartOnIdle>false</RestartOnIdle> + </IdleSettings> + <AllowStartOnDemand>true</AllowStartOnDemand> + <Enabled>true</Enabled> + <Hidden>false</Hidden> + <RunOnlyIfIdle>false</RunOnlyIfIdle> + <WakeToRun>false</WakeToRun> + <ExecutionTimeLimit>PT0S</ExecutionTimeLimit> + <Priority>7</Priority> + <RestartOnFailure> + <Interval>{_TASK_RESTART_INTERVAL}</Interval> + <Count>{_TASK_RESTART_COUNT}</Count> + </RestartOnFailure> + </Settings> + <Actions Context="Author"> + <Exec> + <Command>wscript.exe</Command> + <Arguments>//B //Nologo "{escape(str(launcher_path))}"</Arguments> + </Exec> + </Actions> +</Task> +""" + + +def _write_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> Path: + xml_path = launcher_path.with_suffix(".task.xml") + xml_path.write_text( + _build_scheduled_task_xml(task_name, launcher_path, user), + encoding="utf-16", + newline="", + ) + return xml_path + + def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]: """Create or replace the Scheduled Task. Returns (success, detail). @@ -451,8 +605,6 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st preserves those stale triggers and can make the gateway relaunch every minute. Delete+create gives us a clean ONLOGON task every install. """ - quoted_script = _quote_schtasks_arg(str(script_path)) - delete_code, delete_out, delete_err = _exec_schtasks(["/Delete", "/F", "/TN", task_name]) delete_detail = (delete_err or delete_out or "").strip() if delete_code != 0 and delete_detail and "cannot find" not in delete_detail.lower(): @@ -460,32 +612,28 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st return (False, f"schtasks /Delete failed (code {delete_code}): {delete_detail}") # Non-fatal: /Create /F below may still replace it. Keep the detail in # the final error if creation also fails. - # password" variant; if that fails, retry without /RU /NP /IT. - base = [ - "/Create", - "/F", - "/SC", - "ONLOGON", - "/RL", - "LIMITED", - "/TN", - task_name, - "/TR", - quoted_script, - ] user = _resolve_task_user() - variants = [] - if user: - variants.append([*base, "/RU", user, "/NP", "/IT"]) + # The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not + # the .cmd. The .cmd stays for the Startup-folder fallback and direct /Run. + launcher_path = script_path.with_suffix(".vbs") + xml_path = _write_scheduled_task_xml(task_name, launcher_path, user) + base = ["/Create", "/F", "/TN", task_name, "/XML", str(xml_path)] + variants = [[*base, "/RU", user, "/NP", "/IT"]] if user else [] variants.append(base) last_code = 1 last_err = "" - for argv in variants: - code, out, err = _exec_schtasks(argv) - if code == 0: - return (True, f"Created Scheduled Task {task_name!r}") - last_code, last_err = code, (err or out or "") + try: + for argv in variants: + code, out, err = _exec_schtasks(argv) + if code == 0: + return (True, f"Created Scheduled Task {task_name!r}") + last_code, last_err = code, (err or out or "") + finally: + try: + xml_path.unlink(missing_ok=True) + except OSError: + pass if delete_detail and "cannot find" not in delete_detail.lower(): last_err = f"{last_err.strip()} (delete detail: {delete_detail})" return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}") -
tests/hermes_cli/test_gateway_windows.py@@ -190,7 +190,11 @@ def fake_install_startup_entry(path: Path) -> Path: def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypatch): """Scheduled Task wrapper should launch pythonw once and avoid replace loops.""" - monkeypatch.setattr(gateway_windows, "_derive_venv_pythonw", lambda exe: exe.replace("python.exe", "pythonw.exe")) + monkeypatch.setattr( + gateway_windows, + "_resolve_detached_python", + lambda exe: (exe.replace("python.exe", "pythonw.exe"), r"C:\\Hermes\\hermes-agent\\venv", []), + ) content = gateway_windows._build_gateway_cmd_script( r"C:\\Hermes\\hermes-agent\\venv\\Scripts\\python.exe", @@ -206,6 +210,41 @@ def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypa assert "exit /b 0" in content +def test_gateway_cmd_script_uses_uv_safe_base_pythonw(monkeypatch, tmp_path): + """Scheduled Task wrapper should share the detached uv-venv workaround.""" + project = tmp_path / "project" + scripts = project / "venv" / "Scripts" + site_packages = project / "venv" / "Lib" / "site-packages" + hermes_home = tmp_path / "hermes-home" + base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none" + scripts.mkdir(parents=True) + site_packages.mkdir(parents=True) + hermes_home.mkdir() + base.mkdir(parents=True) + + venv_python = scripts / "python.exe" + venv_pythonw = scripts / "pythonw.exe" + base_pythonw = base / "pythonw.exe" + for exe in (venv_python, venv_pythonw, base_pythonw): + exe.write_text("", encoding="utf-8") + (project / "venv" / "pyvenv.cfg").write_text( + f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n", + encoding="utf-8", + ) + + content = gateway_windows._build_gateway_cmd_script( + str(venv_python), + str(hermes_home), + str(hermes_home), + "", + ) + + assert str(base_pythonw) in content + assert f'set "VIRTUAL_ENV={project / "venv"}"' in content + assert str(site_packages) in content + assert str(venv_pythonw) not in content + + def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch): """UAC handoff should not leave a second elevated cmd.exe window open.""" calls = [] @@ -239,14 +278,18 @@ def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_pat """Install must delete+create so stale minute-repeat task settings are not preserved.""" calls = [] script_path = tmp_path / "Hermes_Gateway_alice.cmd" + xml_seen = {} monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"DOMAIN\\alice") def fake_schtasks(args): calls.append(tuple(args)) if args[0] == "/Delete": return (0, "SUCCESS", "") if args[0] == "/Create": + xml_path = Path(args[args.index("/XML") + 1]) + xml_seen["text"] = xml_path.read_text(encoding="utf-16") return (0, "SUCCESS", "") raise AssertionError(f"unexpected schtasks args: {args}") @@ -257,8 +300,88 @@ def fake_schtasks(args): assert "/Change" not in [arg for call in calls for arg in call] assert calls[0][:4] == ("/Delete", "/F", "/TN", "Hermes_Gateway_alice") assert calls[1][0] == "/Create" - assert "/SC" in calls[1] - assert "ONLOGON" in calls[1] + assert "/XML" in calls[1] + assert "/SC" not in calls[1] + assert "<Delay>PT30S</Delay>" in xml_seen["text"] + assert "<StartWhenAvailable>true</StartWhenAvailable>" in xml_seen["text"] + assert "<StopOnIdleEnd>false</StopOnIdleEnd>" in xml_seen["text"] + assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml_seen["text"] + assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml_seen["text"] + assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml_seen["text"] + assert "<RestartOnFailure>" in xml_seen["text"] + assert "<Count>999</Count>" in xml_seen["text"] + # Scheduled Task launches the console-less .vbs via wscript.exe, never cmd.exe + # (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A). + assert "<Command>wscript.exe</Command>" in xml_seen["text"] + assert "//B //Nologo" in xml_seen["text"] + assert "Hermes_Gateway_alice.vbs" in xml_seen["text"] + assert "cmd.exe" not in xml_seen["text"] + + +def test_gateway_vbs_script_is_console_less(monkeypatch): + """The .vbs launcher must avoid cmd.exe entirely and Run pythonw hidden + (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A).""" + monkeypatch.setattr( + gateway_windows, + "_resolve_detached_python", + lambda exe: (r"C:\venv\Scripts\pythonw.exe", Path(r"C:\venv"), []), + ) + content = gateway_windows._build_gateway_vbs_script( + r"C:\venv\Scripts\python.exe", + r"C:\Hermes", + r"C:\Hermes", + "--profile work", + ) + assert "cmd.exe" not in content.lower() + assert 'CreateObject("WScript.Shell")' in content + assert "pythonw.exe" in content + assert "hermes_cli.main" in content + assert "gateway run" in content + assert ", 0, False" in content # hidden window, detached/async + for var in ("HERMES_HOME", "PYTHONIOENCODING", "HERMES_GATEWAY_DETACHED", "VIRTUAL_ENV", "PYTHONPATH"): + assert var in content + assert "--profile" in content and "work" in content + assert content.endswith("\r\n") + + +def test_gateway_vbs_script_quotes_spaced_paths(monkeypatch): + """Spaced exe/dir paths stay correctly quoted through the VBScript literal.""" + monkeypatch.setattr( + gateway_windows, + "_resolve_detached_python", + lambda exe: (r"C:\Program Files\Py\pythonw.exe", Path(r"C:\v env"), []), + ) + content = gateway_windows._build_gateway_vbs_script( + r"C:\Program Files\Py\python.exe", + r"C:\work dir", + r"C:\h home", + "", + ) + # list2cmdline quotes the spaced exe; _quote_vbs_string doubles those quotes. + assert '""C:\\Program Files\\Py\\pythonw.exe""' in content + assert 'sh.CurrentDirectory = "C:\\work dir"' in content + + +def test_gateway_vbs_script_pythonpath_chains_runtime_value(monkeypatch): + """PYTHONPATH chains onto the task env's existing value, like ;%PYTHONPATH%.""" + monkeypatch.setattr( + gateway_windows, + "_resolve_detached_python", + lambda exe: (r"C:\v\pythonw.exe", Path(r"C:\v"), [r"C:\v\Lib\site-packages"]), + ) + content = gateway_windows._build_gateway_vbs_script( + r"C:\v\python.exe", r"C:\w", r"C:\h", "", + ) + assert 'existing_pp = env.Item("PYTHONPATH")' in content + assert "If Len(existing_pp) > 0 Then" in content + assert r"C:\v\Lib\site-packages" in content + + +def test_quote_vbs_string_doubles_quotes_and_rejects_newlines(): + assert gateway_windows._quote_vbs_string("plain") == '"plain"' + assert gateway_windows._quote_vbs_string('a"b') == '"a""b"' + with pytest.raises(ValueError): + gateway_windows._quote_vbs_string("line1\nline2") def test_install_scheduled_task_success_start_now_uses_direct_spawn_not_task_run(monkeypatch, tmp_path, capsys):
-
-
#52239 fix(gateway,windows): respawn gateway windowless after GUI update (no persistent console window)
files:
hermes_cli/gateway.py,hermes_cli/gateway_windows.py,tests/tools/test_windows_native_support.pyShow the diff
-
hermes_cli/gateway.py@@ -5,6 +5,7 @@ """ import asyncio +import json import logging import os import shlex @@ -717,6 +718,38 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: windows_detach_popen_kwargs, ) + # On Windows the incoming ``run_argv`` leads with the venv's console + # ``python.exe`` (from ``get_python_path()``). Respawning the gateway + # with that interpreter — even under CREATE_NO_WINDOW — leaves a + # persistent console window, because uv's venv launcher re-execs the + # base console interpreter, which allocates its own conhost. Rewrite + # the argv to the windowless ``pythonw.exe`` (mirroring the clean-start + # ``_spawn_detached`` path) and capture the cwd + env overlay the base + # interpreter needs to resolve imports without the venv launcher. + # No-op on POSIX. See gateway_windows.windowless_gateway_restart_spec. + respawn_cwd = "" + respawn_env_overlay: dict[str, str] = {} + if sys.platform == "win32": + try: + from hermes_cli.gateway_windows import ( + windowless_gateway_restart_spec, + ) + + run_argv, respawn_cwd, respawn_env_overlay = ( + windowless_gateway_restart_spec(list(run_argv)) + ) + except Exception: + # Best-effort: if the rewrite fails for any reason, fall back to + # the original argv. A visible window is worse than nothing, but + # a failed respawn is worse still — keep the gateway coming back. + respawn_cwd = "" + respawn_env_overlay = {} + + # Serialized as JSON literals embedded in the watcher source so the + # inner respawn can apply cwd= / env= without extra argv plumbing. + respawn_cwd_literal = json.dumps(respawn_cwd) + respawn_env_literal = json.dumps(respawn_env_overlay) + watcher = textwrap.dedent( """ import os @@ -726,6 +759,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: pid = int(sys.argv[1]) cmd = sys.argv[2:] + _respawn_cwd = {respawn_cwd_literal} + _respawn_env_overlay = {respawn_env_literal} deadline = time.monotonic() + 120 while time.monotonic() < deadline: # ``os.kill(pid, 0)`` is not a no-op on Windows — use the @@ -742,10 +777,18 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: # been spawned inside a job object (Electron/Tauri parent), and # without breakaway the respawned gateway would die when that job # tears down. See _subprocess_compat.windows_detach_flags(). - _popen_kwargs = { + _popen_kwargs = {{ "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, - } + }} + # Anchor the respawned gateway at the stable working dir and overlay + # the env (VIRTUAL_ENV / PYTHONPATH / HERMES_HOME) the windowless + # base interpreter needs to import hermes_cli. Empty on POSIX, where + # the venv python resolves imports without help. + if _respawn_cwd: + _popen_kwargs["cwd"] = _respawn_cwd + if _respawn_env_overlay: + _popen_kwargs["env"] = {{**os.environ, **_respawn_env_overlay}} if sys.platform == "win32": _CREATE_NEW_PROCESS_GROUP = 0x00000200 _DETACHED_PROCESS = 0x00000008 @@ -772,7 +815,10 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: _popen_kwargs["start_new_session"] = True subprocess.Popen(cmd, **_popen_kwargs) """ - ).strip() + ).strip().format( + respawn_cwd_literal=respawn_cwd_literal, + respawn_env_literal=respawn_env_literal, + ) watcher_argv = [ sys.executable, -
hermes_cli/gateway_windows.py@@ -753,6 +753,77 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: return argv, working_dir, env_overlay +def windowless_gateway_restart_spec( + run_argv: list[str], +) -> tuple[list[str], str, dict[str, str]]: + """Rewrite a console-``python.exe`` gateway argv into a windowless one. + + The post-update restart paths build their respawn command from + ``get_python_path()`` which returns the venv's console ``python.exe``. + On Windows — especially with uv-created venvs — launching that + interpreter (even with ``CREATE_NO_WINDOW``) leaves a persistent + console window: ``venv\\Scripts\\python.exe`` is a launcher shim that + re-execs the *base* console interpreter, which allocates its own + conhost. ``CREATE_NO_WINDOW`` cannot suppress that second window. + See ``_resolve_detached_python`` for the gory details. + + This mirrors what ``_build_gateway_argv`` / ``_spawn_detached`` do for + a clean start: swap the interpreter for the windowless ``pythonw.exe`` + (base interpreter for uv venvs) and return the cwd + env overlay + (VIRTUAL_ENV, PYTHONPATH) the base interpreter needs to resolve the + ``hermes_cli`` package without the venv launcher's site config. + + Returns ``(new_argv, working_dir, env_overlay)``. ``new_argv`` + preserves every argument after the interpreter (``-m hermes_cli.main + [--profile X] gateway run [--replace]``) verbatim. On non-Windows, or + if ``run_argv`` doesn't start with a resolvable python, the argv is + returned unchanged with an empty overlay. + """ + if not run_argv: + return run_argv, "", {} + if sys.platform != "win32": + return run_argv, "", {} + + from hermes_cli.config import get_hermes_home + from hermes_cli.gateway import PROJECT_ROOT + + python_exe = run_argv[0] + rest = run_argv[1:] + + # Only rewrite when the leading token actually looks like a python + # interpreter we can find a windowless sibling for. If a caller passed + # something else (a captured argv whose argv[0] is already pythonw, or a + # non-python launcher), leave it alone. + try: + windowless_python, venv_dir, extra_pythonpath = _resolve_detached_python( + python_exe + ) + except Exception: + return run_argv, "", {} + + new_argv = [windowless_python, *rest] + + working_dir = _stable_gateway_working_dir(PROJECT_ROOT) + project_root = str(PROJECT_ROOT) + try: + hermes_home = str(Path(get_hermes_home()).resolve()) + except Exception: + hermes_home = "" + + env_overlay: dict[str, str] = { + "PYTHONIOENCODING": "utf-8", + "HERMES_GATEWAY_DETACHED": "1", + "VIRTUAL_ENV": str(venv_dir), + } + if hermes_home: + env_overlay["HERMES_HOME"] = hermes_home + _prepend_pythonpath( + env_overlay, + [project_root, *extra_pythonpath] if extra_pythonpath else [project_root], + ) + return new_argv, working_dir, env_overlay + + def _spawn_detached(script_path: Path | None = None) -> int: """Launch the gateway as a fully detached background process. -
tests/tools/test_windows_native_support.py@@ -15,6 +15,7 @@ import signal import sys from pathlib import Path +from unittest import mock from unittest.mock import MagicMock import pytest @@ -1007,3 +1008,116 @@ def test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_f "CreateProcess and retry without the breakaway bit, matching " "gateway_windows._spawn_detached's fallback pattern." ) + + def test_watcher_rewrites_console_python_to_windowless(self): + """The post-update respawn must NOT relaunch the gateway with the + venv's console ``python.exe``. + + Regression for the "terminal window stays open permanently after a + GUI update" report: ``_gateway_run_args_for_profile`` builds the + respawn argv from ``get_python_path()`` (console ``python.exe``). + On Windows, launching that interpreter — even under + CREATE_NO_WINDOW — leaves a persistent console window because uv's + venv launcher re-execs the base console interpreter. The watcher + must route the argv through + ``gateway_windows.windowless_gateway_restart_spec`` so it becomes + ``pythonw.exe`` with the cwd + PYTHONPATH overlay the base + interpreter needs. + + Static check: the watcher build (in ``_spawn_gateway_restart_watcher``) + must invoke the rewrite helper and thread the cwd / env overlay into + the inlined respawn ``Popen``. + """ + root = Path(__file__).resolve().parents[2] + text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8") + assert "windowless_gateway_restart_spec" in text, ( + "_spawn_gateway_restart_watcher must rewrite the respawn argv via " + "gateway_windows.windowless_gateway_restart_spec so the gateway " + "comes back as windowless pythonw.exe, not console python.exe." + ) + marker = "watcher = textwrap.dedent(" + idx = text.find(marker) + end = text.find(".strip()", idx) + block = text[idx:end] + # The inlined respawn must apply the cwd + env overlay the base + # interpreter needs — without them the windowless pythonw can't + # import hermes_cli. + assert '_popen_kwargs["cwd"]' in block, ( + "Inlined respawn must set cwd from the windowless spec so the " + "base interpreter starts in the stable gateway working dir." + ) + assert '_popen_kwargs["env"]' in block, ( + "Inlined respawn must overlay env (VIRTUAL_ENV / PYTHONPATH / " + "HERMES_HOME) so the windowless base pythonw resolves hermes_cli." + ) + + +class TestWindowlessGatewayRestartSpec: + """gateway_windows.windowless_gateway_restart_spec — the helper that + converts a console-python gateway argv into a windowless pythonw one.""" + + def test_noop_on_non_windows(self): + import hermes_cli.gateway_windows as gw + + argv = ["/path/venv/bin/python", "-m", "hermes_cli.main", "gateway", "run"] + with mock.patch.object(gw.sys, "platform", "linux"): + new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv)) + assert new_argv == argv + assert cwd == "" + assert env == {} + + def test_empty_argv_is_safe(self): + import hermes_cli.gateway_windows as gw + + new_argv, cwd, env = gw.windowless_gateway_restart_spec([]) + assert new_argv == [] + assert cwd == "" + assert env == {} + + def test_windows_rewrites_to_pythonw_and_preserves_tail(self): + """On Windows the interpreter is swapped for its windowless sibling + while every subsequent argument is preserved verbatim.""" + import hermes_cli.gateway_windows as gw + + # Pre-import on the (Linux) host so the function's lazy + # ``from hermes_cli.gateway import PROJECT_ROOT`` resolves from + # sys.modules instead of re-importing under the win32 platform + # patch below — a fresh import would run gateway/status.py's + # ``if sys.platform == "win32": import msvcrt`` branch and crash on + # Linux CI with ModuleNotFoundError. + import hermes_cli.config # noqa: F401 + import hermes_cli.gateway # noqa: F401 + + argv = [ + "C:/venv/Scripts/python.exe", + "-m", + "hermes_cli.main", + "--profile", + "work", + "gateway", + "run", + "--replace", + ] + + def fake_resolve(python_exe): + return ("C:/base/pythonw.exe", Path("C:/venv"), ["C:/venv/Lib/site-packages"]) + + # Mock get_hermes_home too: the real one calls Path.resolve(), which + # consults sysconfig and raises ModuleNotFoundError under the win32 + # platform patch on a Linux host. + with mock.patch.object(gw.sys, "platform", "win32"), mock.patch.object( + gw, "_resolve_detached_python", side_effect=fake_resolve + ), mock.patch.object( + gw, "_stable_gateway_working_dir", return_value="C:/hermes" + ), mock.patch( + "hermes_cli.config.get_hermes_home", return_value="C:/hermes" + ): + new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv)) + + assert new_argv[0] == "C:/base/pythonw.exe" + # Everything after the interpreter is byte-for-byte preserved. + assert new_argv[1:] == argv[1:] + assert cwd == "C:/hermes" + assert env["VIRTUAL_ENV"] == str(Path("C:/venv")) + assert "PYTHONPATH" in env + assert "site-packages" in env["PYTHONPATH"]
-
-
#70205 fix(windows): hidden-console daemons — extend the parent-console flash fix to every detached spawn path
files:
gateway/run.py,hermes_cli/_subprocess_compat.py,hermes_cli/gateway.py,hermes_cli/gateway_windows.py,hermes_cli/main.py,hermes_cli/web_server.py,tests/gateway/test_restart_drain.py,tests/hermes_cli/test_gateway_windows.py,tests/tools/test_windows_native_support.py
8 PRs closed without merging
From the close comments: done elsewhere 4 · superseded by another PR 2 · stale or abandoned 1 · other 1
- #30312 fix(gateway): resolve uv-managed pythonw for Windows scheduled-task wrapper · stale or abandoned
-
#30314 fix(gateway): use _resolve_detached_python for uv venvs in .cmd generation · done elsewhere →
433db17,#45610 -
#38605 fix(gateway): resolve uv pythonw for task launcher · done elsewhere →
433db17,#45610,#52239 -
#41028 fix(gateway): avoid Windows uv pythonw launcher console · done elsewhere →
433db17 -
#41184 fix(gateway): hide scheduled-task cmd wrapper on Windows · other →
433db17
Show 3 more
-
#53321 fix(windows): reconcile stale pre-#45610 cmd.exe gateway autostart launchers · done elsewhere →
#53321,#81362,#80569 -
#58152 fix(gateway): launch restart watcher windowless on Windows (empty python.exe console after GUI update) · superseded by another PR →
0dbf639,#70205 -
#68472 fix(windows): prevent helper process console flashes · superseded by another PR →
0dbf639,#70205,#54220,#56747
Closed issues · 10
- Closed#30308hermes gateway start pops up cmd.exe console window on Windows (uv venv + pythonw shim issue)
- Closed#38387Windows gateway Scheduled Task can leave blank console window due to uv venv pythonw redirecting to python.exe
- Closed#39646Windows Gateway update/restart path relaunches console python.exe and shows conhost window
- Closed#40696[Setup]: Hermes Gateway Scheduled Task shows visible cmd window on Windows — with fix
- Closed#45599[Windows] Gateway does not survive reboot - .cmd wrapper killed by console control event + schtasks missing critical XML settings
- Closed#51661Windows: get_python_path() returns python.exe instead of pythonw.exe, causing visible console window on gateway restart after update
- Closed#51715Hindsight daemon fails silently on Windows with uv venv: _windows_gui_interpreter() has no fallback, ensure_running() times out in non-TTY
- Closed#53173[Windows] hermes update/doctor don't migrate pre-#45610 gateway autostart launchers — stale .cmd Scheduled Task/Startup entries respawn console windows & die at logon
- Closed#54282Windows: Use pythonw.exe for cron script execution to prevent console window flash
- Closed#68457Windows: uv venv launcher shim re-exec causes console window flashes on every subprocess spawn (SlashWorker, gateway restart watcher)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab