diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 496f7e90742e..834345e9b931 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -301,6 +301,53 @@ def _check_s6_supervision(issues: list[str]) -> None: ) +def _check_windows_gateway_launcher(issues: list[str]) -> None: + """Migrate a pre-#45610 Windows gateway autostart launcher to wscript/.vbs. + + #45610 moved the autostart from a ``cmd.exe`` launcher to a console-less + ``.vbs`` run via ``wscript.exe``, but only for fresh installs. Machines + installed earlier and later updated keep the old ``cmd.exe`` Scheduled-Task + action and a legacy Startup ``.cmd``, which respawn visible console windows + at logon and let the half-started gateway be killed by the logon + console-close event. Detect and reconcile via the idempotent, + elevation-safe ``reconcile_autostart_launchers``. + """ + if os.name != "nt": + return + + try: + from hermes_cli import gateway_windows + except Exception as e: + check_warn("Windows gateway launcher", f"(could not import gateway_windows: {e})") + return + + if not gateway_windows.is_installed(): + return + + _section("Windows Gateway Launcher") + + try: + if not gateway_windows.has_stale_autostart_launcher(): + check_ok("Gateway autostart uses the console-less wscript.exe/.vbs launcher") + return + changed, detail = gateway_windows.reconcile_autostart_launchers() + except Exception as e: + check_warn("Windows gateway launcher check skipped", str(e)) + return + + if changed: + check_ok("Migrated stale cmd.exe gateway autostart launcher to wscript.exe/.vbs", detail) + else: + issues.append( + "Stale Windows gateway autostart launcher — reinstall from an " + "elevated prompt with 'hermes gateway install'" + ) + check_warn( + "Stale cmd.exe gateway autostart launcher could not be migrated automatically", + detail, + ) + + def check_certificates() -> None: """Verify the certifi CA bundle is loadable. @@ -1320,6 +1367,7 @@ def run_doctor(args): _check_gateway_service_linger(issues) _check_s6_supervision(issues) + _check_windows_gateway_launcher(issues) if sys.platform != "win32": _section("Command Installation") diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 55ed976433da..61823e842529 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -1274,6 +1274,88 @@ def query_task_status() -> dict[str, str]: return info +def _scheduled_task_action_is_stale() -> bool: + """True when the registered task's action is the legacy ``cmd.exe`` form. + + #45610 migrated the Scheduled-Task action to the console-less + ``wscript.exe`` + ``.vbs`` launcher (see ``_build_scheduled_task_xml``). + Machines installed before that keep a ``cmd.exe`` / ``.cmd`` action, which + respawns a visible console at logon and lets the half-started gateway be + killed by the logon console-close event. + + Detection reads the task XML directly (``/Query /XML``) and keys off the + action command: stale when the definition does not run ``wscript.exe`` but + does reference ``cmd.exe`` or a ``.cmd`` launcher. Returns ``False`` on any + query failure so a transient schtasks error never triggers a false repair. + """ + code, out, _err = _exec_schtasks(["/Query", "/TN", get_task_name(), "/XML"]) + if code != 0 or not out: + return False + lowered = out.lower() + if "wscript.exe" in lowered: + return False + return "cmd.exe" in lowered or ".cmd" in lowered + + +def has_stale_autostart_launcher() -> bool: + """True when an installed autostart launcher is the pre-#45610 form. + + Detection precedence: a legacy Startup-folder ``.cmd`` entry present > + a Scheduled-Task action that is not the console-less ``wscript.exe`` form. + """ + if not is_installed(): + return False + if _legacy_startup_entry_path().exists(): + return True + return is_task_registered() and _scheduled_task_action_is_stale() + + +def reconcile_autostart_launchers() -> tuple[bool, str]: + """Rewrite a stale pre-#45610 autostart launcher to the console-less form. + + Idempotent and elevation-safe: + - no-op (returns ``(False, "already current")``) when nothing is stale; + - regenerates the ``.cmd`` wrapper + console-less ``.vbs`` launcher; + - re-points the Scheduled Task at ``wscript.exe``/``.vbs`` when one is + registered (degrades gracefully if schtasks needs elevation — the + Startup ``.vbs`` fallback still reconciles); + - rewrites the Startup ``.vbs`` entry and removes the legacy ``.cmd``. + + Returns ``(changed, detail)``. ``changed`` reflects whether the launcher is + now current: if the Scheduled-Task rewrite needed elevation we don't have + (Access Denied), the task stays stale, so we re-probe and return + ``(False, ...)`` to let ``hermes doctor`` warn and prompt an elevated rerun + rather than falsely reporting success. + """ + _assert_windows() + if not is_installed(): + return (False, "gateway autostart is not installed") + if not has_stale_autostart_launcher(): + return (False, "already current") + + script_path = _write_task_script() + notes: list[str] = [] + if is_task_registered(): + ok, detail = _install_scheduled_task(get_task_name(), script_path) + if ok: + notes.append("re-pointed Scheduled Task at wscript.exe/.vbs") + else: + # Access-denied (no elevation) leaves the Scheduled Task pointing at + # cmd.exe. The Startup .vbs fallback below still removes the legacy + # console-spawning launcher, but the task itself remains stale; the + # re-probe below catches this so doctor doesn't report a false green. + notes.append(f"Scheduled Task not updated ({detail})") + _install_startup_entry(script_path) + notes.append("rewrote Startup launcher (.vbs) and removed legacy .cmd") + + if has_stale_autostart_launcher(): + notes.append( + "launcher still stale after reconcile — rerun from an elevated prompt" + ) + return (False, "; ".join(notes)) + return (True, "; ".join(notes)) + + def _gateway_pids() -> list[int]: """Reuse the cross-platform PID scanner in gateway.py.""" from hermes_cli.gateway import find_gateway_pids diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index d52ad7d59da4..85c15f0fc4ab 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -910,3 +910,169 @@ def fake_write(target_pid): # Returns True because _pid_exists immediately says "gone". assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True + + +_STALE_TASK_XML = ( + '' + '' + "cmd.exe" + '/c "C:\\Hermes\\Hermes_Gateway.cmd"' + "" +) + +_CURRENT_TASK_XML = ( + '' + '' + "wscript.exe" + '//B //Nologo "C:\\Hermes\\Hermes_Gateway.vbs"' + "" +) + + +class TestReconcileStaleAutostartLauncher: + """pre-#45610 cmd.exe autostart launchers are detected and reconciled.""" + + def _arrange(self, monkeypatch, tmp_path, *, task_xml, legacy_cmd_present): + """Wire a fake Windows install. Returns (env, calls). + + ``env["state"]["task_xml"]`` is what ``/Query /XML`` returns and what a + ``/Create`` re-points it to, so the test can assert the action moved + from cmd.exe to wscript.exe. + """ + script_path = tmp_path / "Hermes_Gateway.cmd" + vbs_path = tmp_path / "Hermes_Gateway.vbs" + startup_vbs = tmp_path / "startup" / "Hermes_Gateway.vbs" + legacy_cmd = tmp_path / "startup" / "Hermes_Gateway.cmd" + startup_vbs.parent.mkdir(parents=True, exist_ok=True) + if legacy_cmd_present: + legacy_cmd.write_text("@echo off\nstart Hermes_Gateway\n") + + state = {"task_xml": task_xml, "task_registered": True} + calls: list[tuple] = [] + + monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway") + monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: legacy_cmd) + monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: startup_vbs) + monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script_path) + monkeypatch.setattr( + gateway_windows, "is_task_registered", lambda: state["task_registered"] + ) + + def fake_write_task_script(): + script_path.write_text("@echo off\n") + vbs_path.write_text("' vbs launcher\n") + return script_path + + monkeypatch.setattr(gateway_windows, "_write_task_script", fake_write_task_script) + + def fake_exec_schtasks(args): + calls.append(tuple(args)) + if args[0] == "/Query": + if "/XML" in args: + return (0, state["task_xml"], "") + return (0, "", "") + if args[0] == "/Delete": + return (0, "SUCCESS", "") + if args[0] == "/Create": + # The real install re-points the action at wscript.exe/.vbs. + state["task_xml"] = _CURRENT_TASK_XML + return (0, "SUCCESS", "") + raise AssertionError(f"unexpected schtasks args: {args}") + + monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_exec_schtasks) + monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: None) + + return { + "state": state, + "legacy_cmd": legacy_cmd, + "startup_vbs": startup_vbs, + "vbs_path": vbs_path, + }, calls + + def test_stale_cmd_launcher_is_detected_and_reconciled(self, monkeypatch, tmp_path): + env, calls = self._arrange( + monkeypatch, tmp_path, task_xml=_STALE_TASK_XML, legacy_cmd_present=True + ) + + assert gateway_windows.has_stale_autostart_launcher() is True + + changed, _detail = gateway_windows.reconcile_autostart_launchers() + + assert changed is True + # Legacy console-spawning Startup .cmd removed. + assert not env["legacy_cmd"].exists() + # Console-less Startup .vbs written. + assert env["startup_vbs"].exists() + # Scheduled Task action re-pointed at wscript.exe (not cmd.exe). + assert "wscript.exe" in env["state"]["task_xml"].lower() + assert "cmd.exe" not in env["state"]["task_xml"].lower() + assert any(c[0] == "/Create" for c in calls) + + def test_reconcile_is_idempotent_no_op_when_current(self, monkeypatch, tmp_path): + env, _calls = self._arrange( + monkeypatch, tmp_path, task_xml=_STALE_TASK_XML, legacy_cmd_present=True + ) + + first_changed, _ = gateway_windows.reconcile_autostart_launchers() + assert first_changed is True + + # Second call: launcher is now current, so nothing is stale. + assert gateway_windows.has_stale_autostart_launcher() is False + second_changed, detail = gateway_windows.reconcile_autostart_launchers() + assert second_changed is False + assert detail == "already current" + + def test_current_wscript_launcher_is_not_flagged(self, monkeypatch, tmp_path): + env, _calls = self._arrange( + monkeypatch, tmp_path, task_xml=_CURRENT_TASK_XML, legacy_cmd_present=False + ) + + assert gateway_windows._scheduled_task_action_is_stale() is False + assert gateway_windows.has_stale_autostart_launcher() is False + changed, detail = gateway_windows.reconcile_autostart_launchers() + assert changed is False + assert detail == "already current" + + def test_query_failure_does_not_false_positive(self, monkeypatch, tmp_path): + self._arrange( + monkeypatch, tmp_path, task_xml=_CURRENT_TASK_XML, legacy_cmd_present=False + ) + # schtasks query wedges/errors -> must NOT report stale. + monkeypatch.setattr( + gateway_windows, "_exec_schtasks", lambda args: (124, "", "timed out") + ) + assert gateway_windows._scheduled_task_action_is_stale() is False + + def test_residual_stale_task_after_failed_update_reports_unchanged( + self, monkeypatch, tmp_path + ): + """No-elevation schtasks update leaves the task stale -> changed=False. + + When the Scheduled-Task rewrite fails (Access Denied / no elevation) the + task keeps pointing at cmd.exe. Even after the Startup .vbs fallback + runs, the launcher is still stale, so reconcile must report + ``changed=False`` (not a false green) and ``doctor`` must warn the user + to rerun from an elevated prompt. + """ + env, _calls = self._arrange( + monkeypatch, tmp_path, task_xml=_STALE_TASK_XML, legacy_cmd_present=True + ) + # Scheduled-Task update cannot elevate: it fails and leaves the action + # pointing at cmd.exe (state["task_xml"] is NOT re-pointed). + monkeypatch.setattr( + gateway_windows, + "_install_scheduled_task", + lambda *args, **kwargs: (False, "Access is denied."), + ) + + assert gateway_windows.has_stale_autostart_launcher() is True + + changed, detail = gateway_windows.reconcile_autostart_launchers() + + # The registered task still points at cmd.exe -> still stale. + assert changed is False + assert "still stale" in detail + assert gateway_windows.has_stale_autostart_launcher() is True + # The startup fallback still ran (legacy console .cmd removed). + assert not env["legacy_cmd"].exists()