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()