Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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")
Expand Down
82 changes: 82 additions & 0 deletions hermes_cli/gateway_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 166 additions & 0 deletions tests/hermes_cli/test_gateway_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<?xml version="1.0" encoding="UTF-16"?>'
'<Task version="1.2"><Actions Context="Author"><Exec>'
"<Command>cmd.exe</Command>"
'<Arguments>/c "C:\\Hermes\\Hermes_Gateway.cmd"</Arguments>'
"</Exec></Actions></Task>"
)

_CURRENT_TASK_XML = (
'<?xml version="1.0" encoding="UTF-16"?>'
'<Task version="1.4"><Actions Context="Author"><Exec>'
"<Command>wscript.exe</Command>"
'<Arguments>//B //Nologo "C:\\Hermes\\Hermes_Gateway.vbs"</Arguments>'
"</Exec></Actions></Task>"
)


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