From 084befaea780d0c39f3d2298ec59e3e2a4c3bd65 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:39:17 -0700 Subject: [PATCH] fix: re-register a drifted Windows gateway Scheduled Task from start and update `hermes gateway status` (previous commit) only reported that a registered task predates the current XML template; nothing rewrote it, so RestartOnFailure / the logon Delay only ever reached fresh installs (#113670). Mirror gateway.py::refresh_systemd_unit_if_needed: `reconcile_scheduled_task` runs the same allowlist compare and, on drift, delete+creates the task from the current template. Called from the Windows `start()` path and from the `hermes update` launcher refresh (`_refresh_windows_gateway_launchers`). A refused re-register (Access Denied) prints the detail and points at the elevating `hermes gateway install`. RestartOnFailure remaining unreachable through the non-waiting wscript launcher is the deliberate design of 433db17c0a8d (#45610) and stays a documented note. --- hermes_cli/gateway_windows.py | 36 +++++++++++++++---- hermes_cli/update_cmd_windows.py | 3 ++ tests/hermes_cli/test_gateway_windows.py | 34 ++++++++++++++++++ .../test_update_gateway_launcher_refresh.py | 18 +++++++--- website/docs/user-guide/messaging/index.md | 6 ++-- 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index c65dbdfe85..098c506720 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -1264,17 +1264,39 @@ def compare_scheduled_task_drift(registered_xml: str, template_xml: str) -> list return drift -def _print_scheduled_task_drift(task_name: str) -> None: - """Warn when the registered task predates ``_build_scheduled_task_xml``; silent when it cannot be - queried. Reports only — re-registration stays behind the explicit ``hermes gateway install``.""" +def scheduled_task_drift(task_name: str) -> list[str]: + """Drift fragments between the registered task and ``_build_scheduled_task_xml``; empty when + aligned or when the task cannot be queried.""" registered = _query_scheduled_task_xml(task_name) if registered is None: - return + return [] template = _build_scheduled_task_xml(task_name, get_task_script_path().with_suffix(".vbs"), _resolve_task_user()) - drift = compare_scheduled_task_drift(registered, template) + return compare_scheduled_task_drift(registered, template) + + +def _print_scheduled_task_drift(task_name: str) -> None: + """Warn when the registered task predates the current template (status is read-only; the + repair runs from ``start()`` / ``hermes update`` via ``reconcile_scheduled_task``).""" + drift = scheduled_task_drift(task_name) if drift: print(f"⚠ Scheduled Task registration predates the current template ({'; '.join(drift)})") - print(" Repair: hermes gateway install") + print(" Repair: hermes gateway start (or: hermes gateway install)") + + +def reconcile_scheduled_task(task_name: str) -> bool: + """Re-register the task from the current template when it drifts (#113670) — the Windows sibling + of ``gateway.py::refresh_systemd_unit_if_needed``. Template hardening (``RestartOnFailure``, logon + ``Delay``) otherwise only ever reaches fresh installs. False when aligned/unqueryable or when + ``schtasks`` refused (typically Access Denied — the elevating ``hermes gateway install`` is the fallback).""" + drift = scheduled_task_drift(task_name) + if not drift: + return False + print(f"↻ Repairing outdated Scheduled Task registration ({'; '.join(drift)})") + ok, detail = _install_scheduled_task(task_name, _write_task_script()) + print(f"{'✓' if ok else '⚠'} {detail}") + if not ok: + print(" Repair manually: hermes gateway install") + return ok def is_installed() -> bool: @@ -1482,6 +1504,8 @@ def start() -> None: print("⚠ Gateway install did not complete in this process.") print(" If a UAC prompt opened, approve it, then run: hermes gateway start") return + elif is_task_registered(): + reconcile_scheduled_task(get_task_name()) # like systemd's regenerate-on-stale before a start # Manual starts use the same console-less direct spawn as restart() and install --start-now; # Scheduled Task / Startup entries are only login persistence. diff --git a/hermes_cli/update_cmd_windows.py b/hermes_cli/update_cmd_windows.py index b213122c21..9abc2fe7f6 100644 --- a/hermes_cli/update_cmd_windows.py +++ b/hermes_cli/update_cmd_windows.py @@ -1069,6 +1069,9 @@ def _refresh_windows_gateway_launchers() -> None: if gateway_windows.is_installed(): gateway_windows._write_task_script() print(" ✓ Refreshed Windows gateway launcher scripts") + if gateway_windows.is_task_registered(): + # A task registered by an older build never picks up template hardening otherwise (#113670). + gateway_windows.reconcile_scheduled_task(gateway_windows.get_task_name()) def _refresh_bootstrap_cache_scripts(branch: str = "main") -> None: diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index ba370b2641..b0e1117742 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -447,6 +447,40 @@ def test_scheduled_task_drift_is_silent_when_aligned_or_unqueryable(monkeypatch) assert printed == [] +def test_reconcile_scheduled_task_reregisters_only_on_drift(monkeypatch, tmp_path): + """The Windows sibling of ``refresh_systemd_unit_if_needed`` (#113670): a pre-hardening + registration is deleted and re-created from the current template (so ``RestartOnFailure`` and the + logon ``Delay`` reach existing installs), while an aligned one is left alone.""" + script_path = tmp_path / "gateway.cmd" + launcher = script_path.with_suffix(".vbs") + template = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", launcher, r"PC\me") + calls: list[list[str]] = [] + registered = {"xml": _PRE_HARDENING_TASK_XML} + + def fake_schtasks(args): + calls.append(list(args)) + if "/XML" in args and "/Query" in args: + return (0, registered["xml"], "") + if "/Create" in args: + registered["xml"] = Path(args[args.index("/XML") + 1]).read_text(encoding="utf-16") + return (0, "", "") + + monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks) + monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path) + monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script_path) + monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"PC\me") + monkeypatch.setattr("builtins.print", lambda *a, **k: None) + + assert gateway_windows.reconcile_scheduled_task("Hermes_Gateway") is True + assert [c[0] for c in calls if c[0] in ("/Delete", "/Create")] == ["/Delete", "/Create"] + assert "" in registered["xml"] + assert gateway_windows.compare_scheduled_task_drift(registered["xml"], template) == [] + + calls.clear() + assert gateway_windows.reconcile_scheduled_task("Hermes_Gateway") is False + assert not any(c[0] in ("/Delete", "/Create") for c in calls) + + diff --git a/tests/hermes_cli/test_update_gateway_launcher_refresh.py b/tests/hermes_cli/test_update_gateway_launcher_refresh.py index 83394e2b43..d3a99c9583 100644 --- a/tests/hermes_cli/test_update_gateway_launcher_refresh.py +++ b/tests/hermes_cli/test_update_gateway_launcher_refresh.py @@ -87,9 +87,19 @@ def test_restart_spec_normalizes_legacy_pythonw_argv(tmp_path): # --------------------------------------------------------------------------- +def test_update_launcher_refresh_reregisters_drifted_scheduled_task(monkeypatch): + """``hermes update`` must not only rewrite the launcher scripts but also re-register a Scheduled + Task that predates the current template (#113670) — otherwise template hardening never reaches + existing installs.""" + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(gateway_windows, "is_installed", lambda: True) + monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: True) + monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway") + monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: Path("gateway.cmd")) + reconciled: list[str] = [] + monkeypatch.setattr(gateway_windows, "reconcile_scheduled_task", lambda name: reconciled.append(name) or True) + monkeypatch.setattr("builtins.print", lambda *a, **k: None) + update_cmd._refresh_windows_gateway_launchers() - - - - + assert reconciled == ["Hermes_Gateway"] diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 63af6e0616..9a06c58388 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -666,14 +666,14 @@ The Scheduled Task runs `wscript.exe` on a generated `.vbs` launcher under `%USE Because the launcher returns as soon as the gateway is spawned, Task Scheduler only ever sees the launcher's exit code. The `` policy in the registered task therefore fires only when `wscript.exe` itself fails to start the gateway — it does **not** restart a gateway that crashes or is killed later. Gateway auto-restart on Windows relies on the gateway's own in-process restart path (`/restart`, updates, and the `hermes gateway restart` command); a gateway killed from outside stays down until `hermes gateway start` or `schtasks /Run /TN `. ::: -`hermes gateway install` writes the task from the current template, but an existing registration is never rewritten automatically — a task registered by an older build keeps its old settings (no `RestartOnFailure`, no logon `Delay`, an older launcher command line) indefinitely. `hermes gateway status` compares the registered task with the current template and warns when it predates it: +`hermes gateway install` writes the task from the current template; a task registered by an older build would otherwise keep its old settings (no `RestartOnFailure`, no logon `Delay`, an older launcher command line) indefinitely. `hermes gateway status` compares the registered task with the current template and warns when it predates it: ``` ⚠ Scheduled Task registration predates the current template (missing: RestartOnFailure, LogonTrigger Delay; version 1.3 vs 1.4) - Repair: hermes gateway install + Repair: hermes gateway start (or: hermes gateway install) ``` -Re-running `hermes gateway install` deletes and re-creates the task from the current template. The check is silent when the task cannot be queried, and it only inspects a few settings Hermes owns (task version, `RestartOnFailure`, the logon trigger delay and the launcher arguments), so deliberate local edits elsewhere in the task are not flagged. +`hermes gateway start` and `hermes update` run the same comparison and re-register a drifted task from the current template automatically (like the systemd unit refresh on Linux); when `schtasks` refuses without elevation, re-run `hermes gateway install`, which can request administrator approval. The check is silent when the task cannot be queried, and it only inspects a few settings Hermes owns (task version, `RestartOnFailure`, the logon trigger delay and the launcher arguments), so deliberate local edits elsewhere in the task are not flagged. ## Platform-Specific Toolsets