Files
hermes-agent/hermes_cli/gateway_windows.py

1727 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Windows gateway service backend (Scheduled Task + Startup-folder fallback).
This mirrors the contract exposed by ``launchd_install`` / ``launchd_start`` / ``launchd_status``
etc. on macOS and ``systemd_install`` / ``systemd_start`` on Linux.
Design notes ------------ * ``schtasks /Create /SC ONLOGON /RL LIMITED`` means the task runs at the
CURRENT USER's next logon without any elevation prompt. Manual starts and install ``--start-now``
use the direct hidden-console launcher instead of ``schtasks /Run`` so start/restart behavior is
consistent.
"""
from __future__ import annotations
import ctypes
import locale
import logging
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from pathlib import Path
from xml.sax.saxutils import escape
from hermes_cli._subprocess_compat import (
_WINDOWS_GATEWAY_BREAKAWAY_ENV,
windows_detach_flags,
windows_detach_flags_without_breakaway,
windows_hide_flags,
)
logger = logging.getLogger(__name__)
# Short timeouts: schtasks occasionally wedges and we don't want to hang forever.
_SCHTASKS_TIMEOUT_S = 15
# Patterns in schtasks stderr that mean "fall back to the Startup folder".
_FALLBACK_PATTERNS = re.compile(
r"(access is denied|acceso denegado|přístup byl odepřen|schtasks timed out|schtasks produced no output)",
re.IGNORECASE,
)
_ACCESS_DENIED_PATTERN = re.compile(r"(access is denied|acceso denegado)", re.IGNORECASE)
# Set by _spawn_detached() when the breakaway spawn failed and it had to
# retry WITHOUT CREATE_BREAKAWAY_FROM_JOB — meaning the child stays inside
# the parent's Job Object and may be killed when this shell exits (#91675).
# Dict (not bare bool) so the flag is mutable without ``global``.
_LAST_SPAWN_BREAKAWAY_FALLBACK: dict = {"fallback": False}
_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:
"""Best-effort console encoding for decoding ``schtasks.exe`` output.
On localized Windows (e.g. Chinese), ``schtasks`` emits text in the OEM/ANSI code page rather
than UTF-8. Decoding with the wrong codec raised ``UnicodeDecodeError`` inside ``subprocess``'
reader threads. Prefer the locale's preferred encoding and fall back to UTF-8.
"""
try:
return locale.getpreferredencoding(False) or "utf-8"
except Exception:
return "utf-8"
# ---------------------------------------------------------------------------
# Platform guard
# ---------------------------------------------------------------------------
def _assert_windows() -> None:
if sys.platform != "win32":
raise RuntimeError("gateway_windows is Windows-only")
def _preserve_hermes_home_path(path: str | Path) -> str:
r"""Render Hermes-owned paths under the configured HERMES_HOME spelling.
Windows installs may keep ``%LOCALAPPDATA%\hermes`` as a symlink/junction to another drive.
Runtime state should still identify itself by the configured AppData path, so launcher files
must not bake in the resolved target when a path lives under HERMES_HOME.
"""
candidate = Path(path)
try:
from hermes_cli.config import get_hermes_home
home = Path(get_hermes_home())
resolved_home = home.resolve()
resolved_candidate = candidate.resolve()
home_key = os.path.normcase(str(resolved_home))
candidate_key = os.path.normcase(str(resolved_candidate))
if os.path.commonpath([home_key, candidate_key]) == home_key:
rel = os.path.relpath(str(resolved_candidate), str(resolved_home))
return str(home / rel)
except Exception:
pass
return str(candidate)
# ---------------------------------------------------------------------------
# Quoting helpers. cmd.exe (.cmd body), VBScript literals and schtasks /TR are
# three DIFFERENT parsers — never reuse one helper for another. The task XML
# path avoids /TR quoting entirely (see _build_scheduled_task_xml).
# ---------------------------------------------------------------------------
def _quote_cmd_script_arg(value: str) -> str:
"""Quote a single argument for use INSIDE a .cmd file, for cmd.exe parsing.
cmd.exe splits on spaces/tabs outside of double quotes. Embedded quotes are doubled. We also
refuse line breaks because they'd terminate the logical command line mid-script.
"""
if "\r" in value or "\n" in value:
raise ValueError(f"refusing to quote value containing newline: {value!r}")
if not value:
return '""'
if not re.search(r'[ \t"]', value):
return value
return '"' + value.replace('"', '""') + '"'
# ---------------------------------------------------------------------------
# schtasks.exe wrapper
# ---------------------------------------------------------------------------
def _exec_schtasks(args: list[str]) -> tuple[int, str, str]:
"""Run ``schtasks.exe`` with a hard timeout. Return (code, stdout, stderr).
If schtasks wedges, returns code=124 with a synthetic stderr string — same convention OpenClaw
uses, so the fallback detection regex matches.
"""
_assert_windows()
schtasks = shutil.which("schtasks")
if schtasks is None:
return (1, "", "schtasks.exe not found on PATH")
try:
proc = subprocess.run(
[schtasks, *args],
capture_output=True,
text=True,
# Localized Windows emits schtasks output in the console code page,
# not UTF-8. Decode with the locale encoding and replace undecodable
# bytes so a non-UTF-8 status line never surfaces a UnicodeDecodeError
# traceback from subprocess' reader threads (issue #38172).
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:
return (124, "", f"schtasks timed out after {_SCHTASKS_TIMEOUT_S}s")
except OSError as e:
return (1, "", f"schtasks invocation failed: {e}")
def _should_fall_back(code: int, detail: str) -> bool:
return code == 124 or bool(_FALLBACK_PATTERNS.search(detail or ""))
def _is_access_denied(detail: str) -> bool:
return bool(_ACCESS_DENIED_PATTERN.search(detail or ""))
def _is_running_as_admin() -> bool:
"""Return True when the current Windows process is elevated."""
_assert_windows()
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def _current_profile_cli_args() -> list[str]:
"""Return CLI args that preserve the current Hermes profile."""
from hermes_cli.gateway import _profile_arg
profile_arg = _profile_arg()
return shlex.split(profile_arg) if profile_arg else []
def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None = None) -> bool:
"""Launch an elevated gateway subcommand via UAC and return True on handoff.
The elevated child is the console ``python.exe`` launched with ``SW_HIDE``: ShellExecuteW
applies the show-command to a console app's console window, so the child owns a single *hidden*
console that its own subprocess spawns (schtasks, taskkill, …) inherit — no visible window after
the UAC approval, and no per-descendant conhost flashes (the console-less pythonw.exe
alternative re-created #54220/#56747 for every console-subsystem child).
"""
_assert_windows()
args = ["-m", "hermes_cli.main", *_current_profile_cli_args(), "gateway", command]
if extra_args:
args.extend(extra_args)
params = subprocess.list2cmdline(args)
cwd = str(Path(__file__).resolve().parent.parent)
elevated_python = sys.executable
try:
result = ctypes.windll.shell32.ShellExecuteW(
None,
"runas",
elevated_python,
params,
cwd,
0, # SW_HIDE: the child's console exists but is never shown.
)
except Exception as exc:
print(f"⚠ Could not launch elevated gateway {command} prompt: {exc}")
return False
if result <= 32:
print(f"⚠ Elevated gateway {command} prompt was not started (ShellExecuteW={result})")
return False
return True
def _launch_elevated_install(
force: bool = False,
*,
start_now: bool | None = None,
start_on_login: bool | None = None,
) -> bool:
"""Launch an elevated gateway install via UAC and return True on handoff."""
overrides = {"HERMES_GATEWAY_ELEVATED_HANDOFF": "1"}
extra_args = ["--elevated-handoff"]
if force:
extra_args.append("--force")
if start_now is not None:
overrides["HERMES_GATEWAY_INSTALL_START_NOW"] = "1" if start_now else "0"
extra_args.append("--start-now" if start_now else "--no-start-now")
if start_on_login is not None:
overrides["HERMES_GATEWAY_INSTALL_START_ON_LOGIN"] = "1" if start_on_login else "0"
extra_args.append("--start-on-login" if start_on_login else "--no-start-on-login")
saved = {key: os.environ.get(key) for key in overrides}
try:
os.environ.update(overrides)
return _launch_elevated_gateway_command("install", extra_args)
finally:
for key, old in saved.items():
if old is None:
os.environ.pop(key, None)
else:
os.environ[key] = old
def _launch_elevated_uninstall() -> bool:
"""Launch an elevated gateway uninstall via UAC and return True on handoff."""
return _launch_elevated_gateway_command("uninstall")
# ---------------------------------------------------------------------------
# Paths: where we stash our task script and where Startup lives
# ---------------------------------------------------------------------------
def get_task_name() -> str:
"""Scheduled Task name, scoped per profile."""
_assert_windows()
# Local import to avoid circular module initialization during hermes_cli boot.
from hermes_cli.gateway import _profile_suffix
suffix = _profile_suffix()
if not suffix:
return _TASK_NAME_DEFAULT
return f"{_TASK_NAME_DEFAULT}_{suffix}"
def _sanitize_filename(value: str) -> str:
"""Remove characters illegal in Windows filenames."""
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", value)
def get_task_script_path() -> Path:
"""The generated ``gateway.cmd`` wrapper kept beside the VBS launcher.
Lives under ``%LOCALAPPDATA%\\hermes\\gateway-service\\`` (or ``<HERMES_HOME>/gateway-service/``
so per-profile installs stay self-contained).
"""
_assert_windows()
from hermes_cli.config import get_hermes_home
script_dir = Path(get_hermes_home()) / "gateway-service"
script_dir.mkdir(parents=True, exist_ok=True)
return script_dir / f"{_sanitize_filename(get_task_name())}.cmd"
def _startup_dir() -> Path:
appdata = os.environ.get("APPDATA", "").strip()
if appdata:
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
userprofile = os.environ.get("USERPROFILE", "").strip() or os.environ.get("HOME", "").strip()
if not userprofile:
raise RuntimeError("neither APPDATA nor USERPROFILE is set — cannot resolve Startup folder")
return Path(userprofile).joinpath(
"AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup"
)
def get_startup_entry_path() -> Path:
_assert_windows()
return _startup_dir() / f"{_sanitize_filename(get_task_name())}.vbs"
def _legacy_startup_entry_path() -> Path:
_assert_windows()
return _startup_dir() / f"{_sanitize_filename(get_task_name())}.cmd"
# ---------------------------------------------------------------------------
# Stable working directory
# ---------------------------------------------------------------------------
def _stable_gateway_working_dir(project_root: Path) -> str:
"""Return a stable cwd for detached/startup gateway runs.
Mirror the POSIX service invariant: anchor at ``HERMES_HOME`` whenever it exists so Scheduled
Task / Startup launches do not fail at the ``cd`` step after a transient checkout or worktree is
moved away. Fall back to the source checkout only if ``HERMES_HOME`` cannot be used yet.
"""
from hermes_cli.config import get_hermes_home
try:
home = get_hermes_home()
if home:
home_path = Path(home)
if home_path.is_dir():
return str(home_path)
except Exception:
pass
return str(project_root)
# ---------------------------------------------------------------------------
# Script rendering
# ---------------------------------------------------------------------------
def _gateway_run_argv(python_exe: str, profile_arg: str) -> list[str]:
"""``python -m hermes_cli.main [--profile X] gateway run`` — shared by every launcher renderer."""
argv = [python_exe, "-m", "hermes_cli.main"]
if profile_arg:
argv.extend(profile_arg.split())
argv.extend(["gateway", "run"])
return argv
def _launcher_settings() -> tuple[str, str, str, str]:
"""Return (python_path, working_dir, hermes_home, profile_arg) for generated launchers."""
# Local imports to avoid circular-init at module load time.
from hermes_cli.config import get_hermes_home
from hermes_cli.gateway import PROJECT_ROOT, _profile_arg, get_python_path
hermes_home = str(Path(get_hermes_home()))
return (
_preserve_hermes_home_path(get_python_path()),
_stable_gateway_working_dir(PROJECT_ROOT),
hermes_home,
_profile_arg(hermes_home),
)
def _build_gateway_cmd_script(
python_path: str,
working_dir: str,
hermes_home: str,
profile_arg: str,
) -> str:
"""Build the ``gateway.cmd`` wrapper content (CRLF-terminated).
We intentionally do NOT inline PATH overrides here — cmd.exe inherits the per-user PATH the
Scheduled Task was created with, and forcibly rewriting PATH tends to break Homebrew/nvm-style
installations.
"""
lines = ["@echo off", f"rem {_TASK_DESCRIPTION}"]
lines.append(f"cd /d {_quote_cmd_script_arg(working_dir)}")
lines.append(f'set "HERMES_HOME={hermes_home}"')
lines.append('set "PYTHONIOENCODING=utf-8"')
lines.append('set "HERMES_GATEWAY_DETACHED=1"')
lines.append('set "HERMES_SUPERVISED_CHILD=1"')
python_exe_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.
lines.append(f'set "VIRTUAL_ENV={_preserve_hermes_home_path(venv_dir)}"')
pythonpath_entries = [
_preserve_hermes_home_path(Path(__file__).resolve().parent.parent),
*[_preserve_hermes_home_path(entry) for entry in extra_pythonpath],
]
lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"')
# Do NOT use `start` here; that creates an extra wrapper process and made
# gateway lifecycle/status harder to reason about.
# Do NOT use `--replace` for service-managed starts; repeated /Run calls
# should be idempotent, not churn parent/child takeover loops.
lines.append(" ".join(_quote_cmd_script_arg(a) for a in _gateway_run_argv(python_exe_path, profile_arg)))
lines.append("exit /b 0")
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 hidden-console ``gateway.vbs`` launcher (CRLF-terminated).
The task runs this via ``wscript.exe``, not ``cmd.exe``: cmd.exe allocates a console, and at
logon Windows broadcasts ``CTRL_CLOSE_EVENT`` to console groups, killing the half-started
gateway with ``STATUS_CONTROL_C_EXIT``, which Task Scheduler treats as a user cancel so
``RestartOnFailure`` never fires. wscript has no console; it runs ``python.exe`` with window
style 0 so the gateway owns one hidden console that descendants (git, gh, node) inherit
instead of flashing their own conhost. Mirrors ``_build_gateway_cmd_script`` for env + argv.
"""
python_exe_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
# list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run.
command_line = subprocess.list2cmdline(_gateway_run_argv(python_exe_path, profile_arg))
repo_root = _preserve_hermes_home_path(Path(__file__).resolve().parent.parent)
static_pythonpath = os.pathsep.join(
[repo_root, *[_preserve_hermes_home_path(entry) for entry in 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('HERMES_SUPERVISED_CHILD')}) = {_quote_vbs_string('1')}",
f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(_preserve_hermes_home_path(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. The
# console python's one console is created hidden and inherited by all
# descendants, so nothing ever flashes.
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 .vbs that goes in the Startup folder and chains hidden.
Bails out silently (``WScript.Quit 0``) if the target script is gone: stale Startup entries
pointing at vanished directories would otherwise error on every Windows login.
"""
target = str(script_path.with_suffix(".vbs"))
command = subprocess.list2cmdline(["wscript.exe", target])
lines = [
f"' {_TASK_DESCRIPTION}",
"Option Explicit",
"Dim fso, sh, target",
f"target = {_quote_vbs_string(target)}",
'Set fso = CreateObject("Scripting.FileSystemObject")',
"If Not fso.FileExists(target) Then WScript.Quit 0",
'Set sh = CreateObject("WScript.Shell")',
f"sh.Run {_quote_vbs_string(command)}, 0, False",
]
return "\r\n".join(lines) + "\r\n"
def _write_task_script() -> Path:
"""Generate and write the gateway.cmd wrapper. Return its absolute path."""
_assert_windows()
settings = _launcher_settings()
script_path = get_task_script_path()
_atomic_write(script_path, _build_gateway_cmd_script(*settings), script_path.with_suffix(".tmp"))
# Also render the console-less .vbs launcher used by Scheduled Task and the
# Startup-folder fallback via wscript.exe (issue #45599 fix A). The .cmd
# wrapper stays as a generated helper/compatibility artifact.
vbs_path = script_path.with_suffix(".vbs")
_atomic_write(vbs_path, _build_gateway_vbs_script(*settings), vbs_path.with_name(vbs_path.name + ".tmp"))
return script_path
def _atomic_write(path: Path, content: str, tmp: Path) -> None:
"""Write ``content`` verbatim (no newline translation) via ``tmp`` then rename over ``path``."""
tmp.write_text(content, encoding="utf-8", newline="")
tmp.replace(path)
# ---------------------------------------------------------------------------
# Install / uninstall
# ---------------------------------------------------------------------------
def _resolve_task_user() -> str | None:
"""Return ``DOMAIN\\USER`` if available, else bare USERNAME, else None."""
username = os.environ.get("USERNAME") or os.environ.get("USER") or os.environ.get("LOGNAME")
if not username:
return None
if "\\" in username:
return username
domain = os.environ.get("USERDOMAIN")
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`` run via ``wscript.exe``, not the ``.cmd``
(see ``_build_gateway_vbs_script`` for why cmd.exe must stay out of the chain).
"""
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 _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]:
"""Create or replace the Scheduled Task. Returns (success, detail).
Always recreate instead of ``/Change``. Older Hermes builds and failed experiments may have left
repeat/restart settings on the task; ``/Change`` preserves those stale triggers and can make the
gateway relaunch every minute. Delete+create gives us a clean ONLOGON task every install.
"""
delete_code, delete_out, delete_err = _exec_schtasks(["/Delete", "/F", "/TN", task_name])
delete_detail = (delete_err or delete_out or "").strip()
if delete_detail and "cannot find" in delete_detail.lower():
delete_detail = ""
if delete_code != 0 and delete_detail and _is_access_denied(delete_detail):
return (False, f"schtasks /Delete failed (code {delete_code}): {delete_detail}")
# Other /Delete failures are non-fatal: /Create /F below may still replace
# it. Keep the detail for the final error if creation also fails.
user = _resolve_task_user()
# The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not
# the .cmd. Immediate manual starts use _spawn_detached().
launcher_path = script_path.with_suffix(".vbs")
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=""
)
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 = ""
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:
last_err = f"{last_err.strip()} (delete detail: {delete_detail})"
return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}")
def _install_startup_entry(script_path: Path) -> Path:
"""Write the Startup-folder fallback launcher. Returns its path."""
entry = get_startup_entry_path()
entry.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(entry, _build_startup_launcher(script_path), entry.with_suffix(".tmp"))
legacy_entry = _legacy_startup_entry_path()
try:
if legacy_entry.exists():
legacy_entry.unlink()
except OSError:
pass
return entry
def _resolve_detached_python(python_exe: str) -> tuple[str, Path, list[str]]:
"""Return (hidden_console_python, venv_dir, extra_pythonpath) for detached runs.
``extra_pythonpath`` is always empty now; the tuple shape is kept so the call sites (argv
builders, cmd/vbs renderers, restart-spec rewriter, gateway watcher) stay unchanged.
"""
p = Path(python_exe)
if p.name.lower() in ("pythonw.exe", "pythonw"):
sibling = p.with_name("python.exe" if p.suffix else "python")
try:
if sibling.exists():
p = sibling
python_exe = str(sibling)
except OSError:
# Can't stat the sibling — keep the original interpreter. A
# console-less gateway is worse than a hidden-console one, but a
# failed respawn is worse still.
pass
venv_dir = p.parent.parent
return (python_exe, venv_dir, [])
def _prepend_pythonpath(env_overlay: dict[str, str], entries: list[str]) -> None:
clean_entries = [entry for entry in entries if entry]
if not clean_entries:
return
existing = os.environ.get("PYTHONPATH", "")
if existing:
clean_entries.append(existing)
env_overlay["PYTHONPATH"] = os.pathsep.join(clean_entries)
def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
"""Build (argv, working_dir, env_overlay) for the gateway subprocess.
Same logical command as gateway.cmd, but assembled as a native argv for direct
``subprocess.Popen`` so no cmd.exe layer sits in between.
"""
_assert_windows()
from hermes_cli.gateway import PROJECT_ROOT
python_path, working_dir, hermes_home, profile_arg = _launcher_settings()
python_exe, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
project_root = _preserve_hermes_home_path(PROJECT_ROOT)
argv = _gateway_run_argv(python_exe, profile_arg)
env_overlay = {
"HERMES_HOME": hermes_home,
"PYTHONIOENCODING": "utf-8",
"HERMES_GATEWAY_DETACHED": "1",
"HERMES_SUPERVISED_CHILD": "1",
"VIRTUAL_ENV": _preserve_hermes_home_path(venv_dir),
}
_prepend_pythonpath(
env_overlay,
[project_root, *[_preserve_hermes_home_path(entry) for entry in extra_pythonpath]],
)
return argv, working_dir, env_overlay
def windowless_gateway_restart_spec(
run_argv: list[str],
) -> tuple[list[str], str, dict[str, str]]:
"""Return the (argv, cwd, env overlay) for a hidden-console gateway respawn.
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:]
# Normalize the leading interpreter token and derive the venv layout.
# If a caller passed something other than a python path (a non-python
# launcher), leave the argv alone.
try:
hidden_console_python, venv_dir, extra_pythonpath = _resolve_detached_python(
python_exe
)
except Exception:
return run_argv, "", {}
new_argv = [hidden_console_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])
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.
We spawn ``python.exe -m hermes_cli.main gateway run`` directly — NOT through a cmd.exe shim —
because on Windows a cmd.exe child inherits the parent session's console handle and tends to get
reaped when the spawning shell exits.
Arg ``script_path`` is accepted for API symmetry with older callers but ignored — we don't need
it now that we go direct.
"""
_assert_windows()
argv, working_dir, env_overlay = _build_gateway_argv()
# Inherit PATH etc. from the current env, overlay our required vars.
env = {**os.environ, **env_overlay}
primary_env = {**env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "1"}
# CREATE_NEW_PROCESS_GROUP 0x00000200 — child gets its own group, won't
# receive Ctrl+C from our group
# CREATE_NO_WINDOW 0x08000000 — child owns a hidden console:
# detached from our console's
# lifetime AND inheritable by its
# descendants (no conhost flashes)
# CREATE_BREAKAWAY_FROM_JOB 0x01000000 — escape any job object the
# parent is in (prevents parent-
# job teardown from reaping us;
# some Windows Terminal versions
# wrap their children in a job).
flags = windows_detach_flags()
# Redirect any stray stdout/stderr output to a sidecar log. Python's
# logging module writes to gateway.log through a FileHandler, so the
# real gateway logs still land there — this just captures anything
# that goes to print() or native stderr.
from hermes_cli.config import get_hermes_home
log_dir = Path(get_hermes_home()) / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
stray_log = log_dir / "gateway-stdio.log"
try:
with open(stray_log, "ab", buffering=0) as log_fh:
proc = subprocess.Popen(
argv,
cwd=working_dir,
env=primary_env,
creationflags=flags,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=log_fh,
)
_LAST_SPAWN_BREAKAWAY_FALLBACK["fallback"] = False
except OSError as exc:
# CREATE_BREAKAWAY_FROM_JOB can fail with "access denied" when the
# parent's job object doesn't permit breakaway (some Windows
# Terminal configs). Retry without the breakaway flag — in most
# setups the hidden-console CREATE_NO_WINDOW spawn is enough on
# its own.
error_code = getattr(exc, "winerror", None)
if error_code is None:
error_code = exc.errno
logger.warning(
"Gateway breakaway spawn failed (error=%s); retrying without "
"CREATE_BREAKAWAY_FROM_JOB",
error_code,
)
with open(stray_log, "ab", buffering=0) as log_fh:
proc = subprocess.Popen(
argv,
cwd=working_dir,
env={**env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "0"},
creationflags=windows_detach_flags_without_breakaway(),
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=log_fh,
)
_LAST_SPAWN_BREAKAWAY_FALLBACK["fallback"] = True
return proc.pid
def _install_choice_from_env(name: str) -> bool | None:
raw = os.environ.get(name)
if raw is None:
return None
value = raw.strip().lower()
if value in {"1", "true", "yes", "y", "on"}:
return True
if value in {"0", "false", "no", "n", "off"}:
return False
return None
def _prompt_install_choices(
start_now: bool | None = None,
start_on_login: bool | None = None,
) -> tuple[bool, bool]:
"""Return (start_now, start_on_login), asking before any UAC escalation."""
env_start_now = _install_choice_from_env("HERMES_GATEWAY_INSTALL_START_NOW")
env_start_on_login = _install_choice_from_env("HERMES_GATEWAY_INSTALL_START_ON_LOGIN")
if start_now is None:
start_now = env_start_now
if start_on_login is None:
start_on_login = env_start_on_login
if start_now is not None and start_on_login is not None:
return start_now, start_on_login
from hermes_cli.setup import prompt_yes_no
if start_now is None:
start_now = prompt_yes_no("Start the gateway now after install?", True)
if start_on_login is None:
start_on_login = prompt_yes_no(
"Start the gateway automatically on Windows login with a Scheduled Task?",
True,
)
return start_now, start_on_login
def _start_or_report_running() -> None:
"""Spawn the gateway unless one is already running for this profile."""
running_pids = _gateway_pids()
if running_pids:
print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})")
else:
pid = _spawn_detached()
_report_gateway_start(f"direct spawn (PID {pid})")
def _install_startup_fallback(script_path: Path, start_now: bool, detail: str) -> None:
"""Install the Startup-folder fallback and optionally start once."""
print(f"↻ Scheduled Task install blocked ({detail.splitlines()[0]}) — using Startup folder fallback")
entry = _install_startup_entry(script_path)
print(f"✓ Installed Windows login item: {entry}")
print(f" Task script: {script_path}")
# Re-running `hermes -p <profile> gateway install` must be safe.
# Startup-folder fallback only installs login persistence. Starting is
# controlled by the pre-UAC start_now answer so all user decisions happen
# before any elevation prompt.
running_pids = _gateway_pids()
if running_pids:
print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})")
elif start_now:
pid = _spawn_detached()
_report_gateway_start(f"direct spawn (PID {pid})")
else:
from hermes_cli.gateway import _profile_arg
profile_arg = _profile_arg()
start_cmd = f"hermes {profile_arg} gateway start" if profile_arg else "hermes gateway start"
print("ℹ Startup fallback installed; gateway not started now.")
print(f" Start manually with: {start_cmd}")
_print_next_steps()
def _offer_elevated_install(
headline: str, force: bool, start_now: bool, start_on_login: bool
) -> bool:
"""Offer the UAC prompt for a Scheduled Task install. True when handed off to an elevated child."""
from hermes_cli.setup import prompt_yes_no
print(headline)
print(" UAC is Windows' admin approval prompt; it is needed to create/update the Scheduled Task.")
if prompt_yes_no(" Open the UAC prompt now?", False):
if _launch_elevated_install(force=force, start_now=start_now, start_on_login=start_on_login):
print("✓ Launched elevated Hermes gateway install prompt.")
if start_now:
print(" Approve the Windows UAC prompt; the elevated install will start the gateway afterwards.")
else:
print(" Approve the Windows UAC prompt, then run: hermes gateway status")
return True
print("⚠ Falling back to Startup folder because elevation was unavailable or cancelled.")
else:
print(" Skipped elevation. Falling back to Startup folder.")
return False
def install(
force: bool = False,
*,
start_now: bool | None = None,
start_on_login: bool | None = None,
elevated_handoff: bool = False,
) -> None:
"""Install the gateway as a Windows Scheduled Task (with Startup fallback).
Idempotent: re-running updates the task to point at the current python/ project paths. ``force``
is accepted for API parity with ``launchd_install`` / ``systemd_install`` but isn't needed — we
always reconcile.
"""
_assert_windows()
start_now, start_on_login = _prompt_install_choices(start_now, start_on_login)
if not start_on_login:
print("ℹ Skipped Windows login auto-start install.")
if start_now:
_start_or_report_running()
else:
print("ℹ Gateway not started and no auto-start service installed.")
print(" Run later with: hermes gateway start")
return
task_name = get_task_name()
script_path = _write_task_script()
# On machines where the current user's scheduled-task ACL is locked down,
# schtasks /Create or /Change can sit for the timeout before returning
# Access Denied. We already collected all intent questions above, so avoid
# a mysterious post-question pause: ask for UAC before touching schtasks.
if not _is_running_as_admin() and not elevated_handoff:
if _offer_elevated_install(
"↻ Scheduled Task install may need administrator approval on this Windows account.",
force, start_now, start_on_login,
):
return
_install_startup_fallback(script_path, start_now, "administrator approval was not used")
return
ok, detail = _install_scheduled_task(task_name, script_path)
if ok:
print(f"✓ {detail}")
print(f" Task script: {script_path}")
print("ℹ Gateway auto-start installed for Windows login.")
if start_now:
_start_or_report_running()
else:
print("ℹ Gateway not started now.")
print(" Start manually with: hermes gateway start")
_print_next_steps()
return
# schtasks create didn't work. Prefer a real Scheduled Task over the
# Startup-folder fallback when the only blocker is elevation. This gives
# users a UAC prompt instead of silently installing a less reliable login
# item, and keeps the fallback for locked-down boxes / cancelled prompts.
if _is_access_denied(detail) and not _is_running_as_admin() and _offer_elevated_install(
f"↻ Scheduled Task install needs administrator approval ({detail.splitlines()[0]})",
force, start_now, start_on_login,
):
return
# schtasks create didn't work. See if it's a "fall back to startup" case.
if _should_fall_back(1, detail):
_install_startup_fallback(script_path, start_now, detail)
return
# Unknown schtasks error — surface it and bail.
raise RuntimeError(f"Windows gateway install failed: {detail}")
def _confirm_gateway_stable(
initial_pids: list[int], confirm_s: float, interval_s: float,
all_profiles: bool = False,
) -> list[int]:
"""Re-check a freshly detected gateway for ``confirm_s`` seconds.
A single process-table hit only proves the child was *created*, not that it survived startup — a
gateway that crashes moments after spawn (or is reaped by the parent shell's Job Object,
#91675/#84185) passes a first-hit poll and then dies.
"""
if confirm_s <= 0:
return initial_pids
from hermes_cli.gateway import find_gateway_pids
pids = initial_pids
confirm_deadline = time.monotonic() + confirm_s
while time.monotonic() < confirm_deadline:
time.sleep(interval_s)
pids = list(find_gateway_pids(all_profiles=all_profiles))
if not pids:
return []
return pids
def _wait_for_gateway_ready(
timeout_s: float = 6.0,
interval_s: float = 0.4,
confirm_s: float = 2.0,
all_profiles: bool = False,
) -> list[int]:
"""Poll for a live gateway process for up to ``timeout_s`` seconds.
A first process-table hit is treated as *provisional*: the gateway must then stay visible for
``confirm_s`` more seconds before we report it ready (see :func:`_confirm_gateway_stable` — a
child that dies right after spawn must not earn a ✓, #91675).
"""
from hermes_cli.gateway import find_gateway_pids
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
pids = list(find_gateway_pids(all_profiles=all_profiles))
if pids:
confirmed = _confirm_gateway_stable(
pids, confirm_s, interval_s, all_profiles=all_profiles
)
if confirmed:
return confirmed
continue # died during confirmation — keep polling until deadline
time.sleep(interval_s)
return []
# ---------------------------------------------------------------------------
# Start attestation — honest reporting for deaths AFTER the liveness poll
# ---------------------------------------------------------------------------
#
# The liveness poll (even with the confirmation window above) cannot observe
# a death that happens after this CLI process exits — the exact #91675
# failure mode, where the parent shell's Job Object tears the gateway down
# on CLI exit. So every ✓ persists a small attestation marker recording
# which PIDs we vouched for. The NEXT gateway CLI invocation checks the
# marker: if those PIDs are gone and the lifecycle ledger shows no clean
# exit for them, the earlier ✓ was a lie and we say so — once — with the
# schtasks recovery hint.
_START_ATTESTATION_RELATIVE = ("state", "gateway.start-attestation.json")
def _start_attestation_path() -> Path:
from hermes_cli.config import get_hermes_home
return Path(get_hermes_home()).joinpath(*_START_ATTESTATION_RELATIVE)
def _write_start_attestation(pids: list[int], via: str) -> None:
"""Persist the PIDs a ✓ vouched for. Best-effort, never raises."""
import json as _json
from datetime import datetime, timezone
try:
path = _start_attestation_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"pids": [int(p) for p in pids],
"via": via,
"ts": datetime.now(timezone.utc).isoformat(),
}
tmp = path.with_suffix(".json.tmp")
tmp.write_text(_json.dumps(payload), encoding="utf-8")
tmp.replace(path)
except Exception:
logger.debug("Failed to write gateway start attestation", exc_info=True)
def _clear_start_attestation() -> None:
try:
_start_attestation_path().unlink(missing_ok=True)
except OSError:
pass
def _attested_pid_exited_cleanly(pid: int) -> bool:
"""True when the lifecycle ledger shows a clean exit for ``pid``."""
import json as _json
try:
from gateway.lifecycle_ledger import get_lifecycle_sentinel_path
from hermes_cli.config import get_hermes_home
sentinel_path = get_lifecycle_sentinel_path(Path(get_hermes_home()))
data = _json.loads(sentinel_path.read_text(encoding="utf-8"))
except Exception:
return False
return (
isinstance(data, dict)
and data.get("phase") == "exited"
and data.get("pid") == pid
)
def check_start_attestation(current_pids: list[int] | None = None) -> str | None:
"""Surface (once) a gateway that died after a ✓ was printed for it.
Reads the attestation marker from the last successful-looking start. Gateway running: the
start held, clear silently. PIDs gone with a clean-exit ledger record: planned stop, clear
silently. PIDs gone with NO clean-exit record: the ✓ was false (parent Job Object teardown
killed the child after the poll); return a warning and consume the marker so it prints once.
Never raises.
"""
import json as _json
try:
path = _start_attestation_path()
data = _json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict):
_clear_start_attestation()
return None
attested = [p for p in data.get("pids", []) if isinstance(p, int)]
if not attested:
_clear_start_attestation()
return None
if current_pids is None:
try:
from hermes_cli.gateway import find_gateway_pids
current_pids = list(find_gateway_pids())
except Exception:
return None
if current_pids:
# Gateway is up — the previous start held, or something restarted it.
_clear_start_attestation()
return None
if any(_attested_pid_exited_cleanly(pid) for pid in attested):
_clear_start_attestation()
return None
_clear_start_attestation()
via = data.get("via") or "direct spawn"
ts = data.get("ts") or "unknown time"
lines = [
f"⚠ The previous gateway start ({via}, {ts}) reported success, but the "
f"process (PID {', '.join(map(str, attested))}) died without a clean "
"shutdown record.",
" This usually means the shell that ran `hermes gateway start` was inside "
"a Windows Job Object that killed the gateway on exit (#91675).",
]
hint = _task_run_hint(
" Recovery: schtasks /Run /TN {} (Task Scheduler starts the gateway outside any Job Object)"
)
if hint:
lines.append(hint)
return "\n".join(lines)
def _task_run_hint(fmt: str) -> str | None:
"""``fmt`` with the task name filled in, when a Scheduled Task is registered. Never raises."""
try:
if is_task_registered():
return fmt.format(get_task_name())
except Exception:
pass
return None
def _print_start_attestation_warning() -> None:
"""Print the stale-attestation warning if one is pending. Never raises."""
try:
warning = check_start_attestation()
except Exception:
return
if warning:
print(warning)
def _report_gateway_start(via: str) -> None:
pids = _wait_for_gateway_ready()
if pids:
print(f"✓ Gateway started via {via} (PID: {', '.join(map(str, pids))})")
if _LAST_SPAWN_BREAKAWAY_FALLBACK.get("fallback"):
print(
"⚠ The gateway could not break away from this shell's Job Object; "
"it may be killed when this shell exits."
)
hint = _task_run_hint(" If it dies, start it with: schtasks /Run /TN {}")
if hint:
print(hint)
_write_start_attestation(pids, via)
else:
print(
f"✗ Gateway start via {via} FAILED — no stable gateway process "
"detected within the verification window."
)
print(
" (The process may have been created and then killed — e.g. by a "
"parent Job Object, #91675.)"
)
print(" Check the log for startup errors:")
from hermes_cli.config import get_hermes_home
print(f" type {Path(get_hermes_home())}\\logs\\gateway.log")
print(f" type {Path(get_hermes_home())}\\logs\\gateway-stdio.log")
hint = _task_run_hint(
" Recovery: schtasks /Run /TN {} (starts the gateway outside any Job Object)"
)
if hint:
print(hint)
def _print_next_steps() -> None:
from hermes_cli.config import get_hermes_home
hermes_home = Path(get_hermes_home())
print()
print("Next steps:")
print(" hermes gateway status # Check status")
print(f" type {hermes_home}\\logs\\gateway.log # View logs")
def uninstall() -> None:
"""Remove both the Scheduled Task and the Startup-folder fallback, if present."""
_assert_windows()
task_name = get_task_name()
script_path = get_task_script_path()
vbs_script_path = script_path.with_suffix(".vbs")
startup_entry = get_startup_entry_path()
legacy_startup_entry = _legacy_startup_entry_path()
scheduled_task_removed = False
if is_task_registered():
code, _out, err = _exec_schtasks(["/Delete", "/F", "/TN", task_name])
detail = err.strip()
if code == 0:
scheduled_task_removed = True
print(f"✓ Removed Scheduled Task {task_name!r}")
elif _is_access_denied(detail) and not _is_running_as_admin():
from hermes_cli.setup import prompt_yes_no
print(f"↻ Scheduled Task uninstall needs administrator approval ({detail or 'access denied'})")
print(" UAC is Windows' admin approval prompt; it is needed to remove the Scheduled Task.")
if prompt_yes_no(" Open the UAC prompt now?", False):
if _launch_elevated_uninstall():
print("✓ Launched elevated Hermes gateway uninstall prompt.")
print(" Approve the Windows UAC prompt, then run: hermes gateway status")
return
print("⚠ Elevated uninstall prompt was unavailable or cancelled.")
else:
print(" Skipped elevation. Scheduled Task was not removed.")
else:
print(f"⚠ schtasks /Delete returned code {code}: {detail}")
for path, label in [
(startup_entry, "Windows login item"),
(legacy_startup_entry, "legacy Windows login item"),
(script_path, "Task script"),
(vbs_script_path, "Task launcher"),
]:
try:
path.unlink()
print(f"✓ Removed {label}: {path}")
except FileNotFoundError:
pass
if is_task_registered() and not scheduled_task_removed:
print(f"⚠ Scheduled Task still registered: {task_name}")
# ---------------------------------------------------------------------------
# Status / start / stop / restart
# ---------------------------------------------------------------------------
def is_task_registered() -> bool:
code, _out, _err = _exec_schtasks(["/Query", "/TN", get_task_name()])
return code == 0
def is_startup_entry_installed() -> bool:
return get_startup_entry_path().exists() or _legacy_startup_entry_path().exists()
def is_installed() -> bool:
"""True when either the schtasks entry or the Startup fallback is present."""
return is_task_registered() or is_startup_entry_installed()
def query_task_status() -> dict[str, str]:
"""Parse ``schtasks /Query /V /FO LIST`` and pull the interesting keys."""
code, out, err = _exec_schtasks(["/Query", "/TN", get_task_name(), "/V", "/FO", "LIST"])
if code != 0:
return {}
info: dict[str, str] = {}
for raw in out.splitlines():
line = raw.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip().lower()
value = value.strip()
# Some Windows locales emit "Last Result" instead of "Last Run Result".
if key in {"status", "last run time", "last run result", "last result"}:
if key == "last result":
info.setdefault("last run result", value)
else:
info[key] = value
return info
def _gateway_pids() -> list[int]:
"""Reuse the cross-platform PID scanner in gateway.py."""
from hermes_cli.gateway import find_gateway_pids
return list(find_gateway_pids())
def _print_deep_probes() -> None:
"""Print PASS/FAIL per individual probe of gateway liveness.
The default ``status`` output collapses several signals into one ✓ / ✗ line, which is great when
they agree and confusing when they don't. The deep-probe block shows each underlying check
independently so the user can see exactly which signal is wrong.
Probes: [1] PID file present [2] Lock file present and held by some process [3]
gateway.status.get_running_pid() returns a PID [4] _pid_exists(pid) — OS confirms the process is
alive [5] gateway_state.json exists and parses (and is fresh-ish) [6] Last lifecycle event in
gateway-exit-diag.log
"""
import json
from datetime import datetime, timezone
from hermes_cli.config import get_hermes_home
home = Path(get_hermes_home())
pid_path = home / "gateway.pid"
lock_path = home / "gateway.lock"
state_path = home / "gateway_state.json"
diag_path = home / "logs" / "gateway-exit-diag.log"
print()
print("Deep probes:")
def _probe(index: int, ok: bool, message: str) -> None:
print(f" [{index}] {'PASS' if ok else 'FAIL':4s} {message}")
# [1] PID file
pid_value: int | None = None
if pid_path.exists():
try:
data = json.loads(pid_path.read_text(encoding="utf-8"))
pid_value = int(data.get("pid")) if data.get("pid") is not None else None
_probe(1, True, f"PID file present: {pid_path} (pid={pid_value})")
except Exception as exc:
_probe(1, False, f"PID file present but unreadable: {exc}")
else:
_probe(1, False, f"PID file missing: {pid_path}")
# [2] Lock file present + held
if lock_path.exists():
try:
from gateway.status import is_gateway_runtime_lock_active
_probe(2, is_gateway_runtime_lock_active(lock_path), f"Lock file held by a live process: {lock_path}")
except Exception as exc:
_probe(2, False, f"Could not probe lock: {exc}")
else:
_probe(2, False, f"Lock file missing: {lock_path}")
# [3] get_running_pid()
running_pid: int | None = None
try:
from gateway.status import get_running_pid
running_pid = get_running_pid(cleanup_stale=False)
_probe(3, running_pid is not None, f"get_running_pid() => {running_pid}")
except Exception as exc:
_probe(3, False, f"get_running_pid() raised: {exc!r}")
# [4] _pid_exists() on the probed PID
candidate_pid = running_pid if running_pid is not None else pid_value
if candidate_pid is not None:
try:
from gateway.status import _pid_exists
alive = bool(_pid_exists(candidate_pid))
_probe(4, alive, f"_pid_exists({candidate_pid}) => {alive}")
except Exception as exc:
_probe(4, False, f"_pid_exists raised: {exc!r}")
else:
_probe(4, False, "No candidate PID to verify")
# [5] runtime status file
if state_path.exists():
try:
state_data = json.loads(state_path.read_text(encoding="utf-8"))
gateway_state = state_data.get("gateway_state")
updated_at = state_data.get("updated_at")
age_str = ""
if updated_at:
try:
updated_dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
age_seconds = int((datetime.now(timezone.utc) - updated_dt).total_seconds())
age_str = f" (updated {age_seconds}s ago)"
except Exception:
pass
_probe(5, gateway_state == "running", f"gateway_state.json state={gateway_state!r}{age_str}")
except Exception as exc:
_probe(5, False, f"gateway_state.json present but unreadable: {exc}")
else:
_probe(5, False, f"gateway_state.json missing: {state_path}")
# [6] Last lifecycle event from the exit-diag log
if diag_path.exists():
try:
with open(diag_path, "rb") as fh:
# Read last ~4KB; one event is well under 500 bytes.
fh.seek(0, 2)
size = fh.tell()
fh.seek(max(0, size - 4096))
tail = fh.read().decode("utf-8", errors="replace").splitlines()
last_event = next((ln for ln in reversed(tail) if ln.strip()), "")
if last_event:
try:
event = json.loads(last_event)
tag = event.get("tag", "?")
_probe(
6, tag in ("gateway.start",),
f"Last lifecycle event: tag={tag} pid={event.get('pid', '?')} ts={event.get('ts', '?')}",
)
except Exception:
_probe(6, False, f"Last lifecycle line not JSON: {last_event[:120]}")
else:
_probe(6, False, f"exit-diag log empty: {diag_path}")
except Exception as exc:
_probe(6, False, f"exit-diag log unreadable: {exc}")
else:
_probe(6, False, f"exit-diag log missing: {diag_path}")
def status(deep: bool = False) -> None:
"""Print a status report for the Windows gateway service."""
_assert_windows()
# Surface (once) any gateway that died after a previous start printed ✓
# — the poll can't see deaths that happen after the CLI exits (#91675).
_print_start_attestation_warning()
task_name = get_task_name()
task_installed = is_task_registered()
startup_installed = is_startup_entry_installed()
pids = _gateway_pids()
if task_installed:
print(f"✓ Scheduled Task registered: {task_name}")
info = query_task_status()
if info:
for key in ("status", "last run time", "last run result"):
if key in info:
print(f" {key.title()}: {info[key]}")
elif startup_installed:
entry = get_startup_entry_path()
if not entry.exists():
entry = _legacy_startup_entry_path()
print(f"✓ Windows login item installed: {entry}")
else:
print("✗ Gateway service not installed")
if pids:
print(f"✓ Gateway process running (PID: {', '.join(map(str, pids))})")
else:
print("✗ No gateway process detected")
if deep:
print()
print(f" Task name: {task_name}")
print(f" Task script: {get_task_script_path()}")
print(f" Startup entry: {get_startup_entry_path()}")
# Surface the per-probe truth so the user can see *which* signal
# is lying when the high-level summary disagrees with reality.
_print_deep_probes()
if not task_installed and not startup_installed and not pids:
print()
print("To install:")
print(" hermes gateway install")
def start() -> None:
"""Start the gateway using the canonical detached Windows launch path."""
_assert_windows()
# Report (once) if the LAST start's ✓ turned out to be false — the child
# died after the poll window, e.g. parent Job Object teardown (#91675).
_print_start_attestation_warning()
running_pids = _gateway_pids()
if running_pids:
print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})")
return
task_installed = is_task_registered()
startup_installed = is_startup_entry_installed()
if not task_installed and not startup_installed:
from hermes_cli.setup import prompt_yes_no
print("✗ Gateway service is not installed")
if not prompt_yes_no(" Install it now so the gateway starts on login?", True):
print(" Run: hermes gateway install")
return
install(force=False)
task_installed = is_task_registered()
startup_installed = is_startup_entry_installed()
if not task_installed and not startup_installed:
print("⚠ Gateway install did not complete in this process.")
print(" If a UAC prompt opened, approve it, then run: hermes gateway start")
return
# Manual starts use the same console-less direct spawn path as restart()
# and install --start-now. Scheduled Task / Startup entries are only login
# persistence mechanisms.
pid = _spawn_detached()
_report_gateway_start(f"direct spawn (PID {pid})")
def _drain_gateway_pid(pid: int, drain_timeout: float) -> bool:
"""Write the planned-stop marker and wait for the gateway PID to exit.
Windows cannot deliver POSIX signals to a Python asyncio loop (``loop.add_signal_handler``
raises NotImplementedError), so writing the marker is the ONLY way to ask a running gateway to
drain in-flight agents and persist ``resume_pending`` before exit.
"""
if pid <= 0:
return False
try:
from gateway.status import write_planned_stop_marker, _pid_exists
except ImportError:
return False
try:
write_planned_stop_marker(pid)
except Exception:
# Best-effort: if the marker can't be written, we have no choice
# but to fall through to a hard kill. Caller decides escalation.
pass
deadline = time.monotonic() + max(drain_timeout, 1.0)
while time.monotonic() < deadline:
if not _pid_exists(pid):
return True
time.sleep(0.5)
return False
def _windows_stop_drain_timeout() -> float:
"""Return a bounded Windows gateway stop grace period."""
try:
from hermes_cli.gateway import _get_restart_drain_timeout
configured = float(_get_restart_drain_timeout() or 30.0)
except Exception:
configured = 30.0
# Windows CLI stop must not wedge forever. Give the gateway a real
# graceful-drain window, then escalate to the known PID.
return max(1.0, min(configured, 30.0))
def _force_terminate_known_gateway_pids(pids: list[int]) -> int:
"""Force-kill known gateway PIDs without a broad process sweep."""
try:
from gateway.status import _pid_exists, get_process_start_time, terminate_pid
except ImportError:
return 0
own_pid = os.getpid()
killed = 0
seen: set[int] = set()
for pid in pids:
if pid <= 0 or pid == own_pid or pid in seen:
continue
seen.add(pid)
try:
if not _pid_exists(pid):
continue
terminate_pid(
pid,
force=True,
expected_start_time=get_process_start_time(pid),
)
killed += 1
except ProcessLookupError:
continue
except PermissionError:
print(f"⚠ Permission denied to kill PID {pid}")
except OSError as exc:
print(f"Failed to kill PID {pid}: {exc}")
return killed
def _collect_gateway_stop_pids(primary_pid: int | None = None) -> list[int]:
"""Collect gateway PIDs for the active profile, preserving primary first."""
pids: list[int] = []
if primary_pid is not None and primary_pid > 0:
pids.append(primary_pid)
try:
for pid in _gateway_pids():
if pid > 0 and pid not in pids:
pids.append(pid)
except Exception:
pass
return pids
def stop() -> None:
"""Stop the gateway.
Writes the planned-stop marker first so the gateway can drain in-flight agents and persist
``resume_pending`` before exit (the gateway's marker-watcher thread picks this up — Windows
asyncio can't deliver SIGTERM to the loop, so the marker is our only IPC).
"""
_assert_windows()
from gateway.status import get_running_pid
# A user-initiated stop is a planned death: the attestation from the
# last start must not later be reported as a silent crash (#91675).
_clear_start_attestation()
# Phase 1: ask the running gateway (if any) to drain itself by writing
# the planned-stop marker, then wait briefly for it to exit cleanly.
# On clean exit, sessions land with resume_pending=True and the next
# boot will auto-resume them.
pid = get_running_pid()
stop_pids = _collect_gateway_stop_pids(pid)
drained = False
if pid is not None:
drained = _drain_gateway_pid(pid, _windows_stop_drain_timeout())
stopped_any = drained
if 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:
stopped_any = True
elif "not running" not in (err or "").lower():
print(f"⚠ schtasks /End returned code {code}: {err.strip()}")
# Phase 3: hard-kill any still-known gateway processes. Avoid the generic
# process sweep here: Windows direct-spawn starts are profile-scoped, and a
# stop command must be bounded even if the scanner or shutdown path is wedged.
stop_pids.extend(pid for pid in _collect_gateway_stop_pids() if pid not in stop_pids)
killed = _force_terminate_known_gateway_pids(stop_pids)
if killed:
stopped_any = True
print(f"✓ Killed {killed} gateway process(es)")
if stopped_any:
if drained:
print("✓ Gateway stopped (drained cleanly)")
else:
print("✓ Gateway stopped")
else:
print("✗ No gateway was running")
def _wait_for_gateway_absent(timeout_s: float = 30.0, interval_s: float = 0.5) -> bool:
"""Block until no gateway process is detectable, or the timeout elapses.
``stop()`` can return while the old gateway is still draining in-flight agents. Uses the
authoritative ``get_running_pid()`` plus the strict ``_gateway_pids()`` scan so a relaunch
never races a still-alive old process.
"""
from gateway.status import get_running_pid
deadline = time.monotonic() + max(timeout_s, interval_s)
while time.monotonic() < deadline:
if get_running_pid() is None and not _gateway_pids():
return True
time.sleep(interval_s)
return get_running_pid() is None and not _gateway_pids()
def restart() -> None:
"""Stop the gateway then start it again.
Waits for the old gateway to be authoritatively gone first; otherwise ``start()``'s
"already running" guard sees the draining process and no-ops, and when it exits nothing
replaces it (a silent outage). Fails loudly if the process can't be cleared or the relaunch
doesn't produce a running gateway.
"""
_assert_windows()
stop()
if not _wait_for_gateway_absent(timeout_s=30.0):
print("⚠ Gateway still present after stop; forcing termination before restart...")
_force_terminate_known_gateway_pids(_collect_gateway_stop_pids())
if not _wait_for_gateway_absent(timeout_s=10.0):
raise RuntimeError(
"Gateway process still detected after force kill; refusing to "
"start a duplicate. Investigate stray PIDs before retrying."
)
# Give Windows a moment to release the listening port.
time.sleep(1.0)
start()
if not _wait_for_gateway_ready(timeout_s=15.0):
raise RuntimeError(
"Gateway restart did not produce a running gateway process. "
"Check logs/gateway.log and run `hermes gateway status`."
)