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 433db17c0a (#45610) and stays a documented note.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "<RestartOnFailure>" 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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 `<RestartOnFailure>` 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 <task>`.
|
||||
:::
|
||||
|
||||
`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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user