diff --git a/hermes_cli/gateway_windows_legacy.py b/hermes_cli/gateway_windows_legacy.py index fd4ff67cd6..b2b646f4d6 100644 --- a/hermes_cli/gateway_windows_legacy.py +++ b/hermes_cli/gateway_windows_legacy.py @@ -20,9 +20,20 @@ def _w(): return gateway_windows +def _targets_this_home(text: str) -> bool: + """A bare-named launcher is OURS only when its action points into THIS home's ``gateway-service`` + dir. The bare name is also the live identity of the default ``~/.hermes`` profile, so a secondary + profile that classified every ``Hermes_Gateway`` object as its own stray would delete the default + profile's gateway autostart on ``uninstall`` / ``install --force``.""" + w = _w() + marker = w._normalize_windows_path(str(w._hermes_home() / "gateway-service")) + return marker in w._normalize_windows_path(text) + + def legacy_launcher_artifacts() -> list[tuple[str, str, Path | str]]: - """``(kind, label, target)`` for every pre-suffix launcher still present; ``kind`` is ``"file"`` - or ``"task"``. Empty when this home owns the bare name (its current objects ARE the bare ones).""" + """``(kind, label, target)`` for every pre-suffix launcher of THIS home still present; ``kind`` is + ``"file"`` or ``"task"``. Empty when this home owns the bare name (its current objects ARE the bare + ones). Bare-named objects belonging to another home (a sibling install) are left alone.""" w = _w() bare = w._TASK_NAME_DEFAULT if w.get_task_name() == bare: @@ -33,13 +44,20 @@ def legacy_launcher_artifacts() -> list[tuple[str, str, Path | str]]: for path, label in ( (startup / f"{bare}.vbs", "legacy pre-suffix Windows login item"), (startup / f"{bare}.cmd", "legacy pre-suffix Windows login item"), + ): + try: + if _targets_this_home(path.read_text(encoding="utf-8", errors="replace")): + found.append(("file", label, path)) + except OSError: + continue + for path, label in ( (service_dir / f"{bare}.vbs", "legacy pre-suffix task launcher"), (service_dir / f"{bare}.cmd", "legacy pre-suffix task script"), ): - if path.exists(): + if path.exists(): # inside this home by construction found.append(("file", label, path)) - code, _out, _err = w._exec_schtasks(["/Query", "/TN", bare]) - if code == 0: + code, out, _err = w._exec_schtasks(["/Query", "/TN", bare, "/XML"]) + if code == 0 and _targets_this_home(out): found.append(("task", "legacy pre-suffix Scheduled Task", bare)) return found diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index 89b3e7f45f..e243875ae6 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -448,11 +448,12 @@ def test_status_names_and_uninstall_removes_pre_suffix_launchers(monkeypatch, tm (home / "gateway-service").mkdir(parents=True) startup.mkdir() legacy_vbs = startup / "Hermes_Gateway.vbs" - legacy_vbs.write_text("legacy", encoding="utf-8") + legacy_vbs.write_text(gateway_windows._build_startup_launcher(home / "gateway-service" / "Hermes_Gateway.cmd"), encoding="utf-8") legacy_pair = home / "gateway-service" / "Hermes_Gateway.cmd" legacy_pair.write_text("legacy", encoding="utf-8") schtasks_calls = [] registered = {"Hermes_Gateway"} + task_xml = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", home / "gateway-service" / "Hermes_Gateway.vbs", None) def fake_schtasks(args): schtasks_calls.append(args) @@ -460,7 +461,7 @@ def test_status_names_and_uninstall_removes_pre_suffix_launchers(monkeypatch, tm if args[0] == "/Delete": registered.discard(name) return (0, "SUCCESS", "") - return (0, "", "") if name in registered else (1, "", "ERROR: The system cannot find the file specified.") + return (0, task_xml, "") if name in registered else (1, "", "ERROR: The system cannot find the file specified.") monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice") @@ -488,6 +489,44 @@ def test_status_names_and_uninstall_removes_pre_suffix_launchers(monkeypatch, tm assert "legacy pre-suffix" not in capsys.readouterr().out +def test_secondary_profile_leaves_default_profiles_bare_launchers_alone(monkeypatch, tmp_path, capsys): + """The bare ``Hermes_Gateway`` task and Startup entry are the LIVE identity of the default ``~/.hermes`` + profile. From a secondary profile they are a sibling install, not this home's pre-suffix stray: + ``uninstall`` / ``install --force`` must issue no ``schtasks /Delete`` and unlink nothing.""" + startup, home, default_home = tmp_path / "Startup", tmp_path / "profiles" / "work", tmp_path / "default" + (home / "gateway-service").mkdir(parents=True) + (default_home / "gateway-service").mkdir(parents=True) + startup.mkdir() + default_vbs = startup / "Hermes_Gateway.vbs" + default_vbs.write_text(gateway_windows._build_startup_launcher(default_home / "gateway-service" / "Hermes_Gateway.cmd"), encoding="utf-8") + task_xml = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", default_home / "gateway-service" / "Hermes_Gateway.vbs", None) + schtasks_calls = [] + + def fake_schtasks(args): + schtasks_calls.append(args) + if args[0] == "/Query" and args[args.index("/TN") + 1] == "Hermes_Gateway": + return (0, task_xml, "") + return (1, "", "ERROR: The system cannot find the file specified.") + + monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_work") + monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: home / "gateway-service" / "Hermes_Gateway_work.cmd") + monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: startup / "Hermes_Gateway_work.vbs") + monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: startup / "Hermes_Gateway_work.cmd") + monkeypatch.setattr(gateway_windows, "_startup_dir", lambda: startup) + monkeypatch.setattr(gateway_windows, "_hermes_home", lambda: home) + monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks) + monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda *a, **k: []) + monkeypatch.setattr(gateway_windows, "_print_start_attestation_warning", lambda: None) + + gateway_windows.status() + assert "legacy pre-suffix" not in capsys.readouterr().out + gateway_windows.uninstall() + capsys.readouterr() + assert default_vbs.exists() + assert not any(call[0] == "/Delete" and "Hermes_Gateway" in call for call in schtasks_calls) + + # Reporter's `Export-ScheduledTask` of a task registered before the hardened template (#113670). _PRE_HARDENING_TASK_XML = """ diff --git a/tests/hermes_cli/test_legacy_launchers_windows_live.py b/tests/hermes_cli/test_legacy_launchers_windows_live.py index c1eb238b94..375c43a518 100644 --- a/tests/hermes_cli/test_legacy_launchers_windows_live.py +++ b/tests/hermes_cli/test_legacy_launchers_windows_live.py @@ -28,14 +28,16 @@ def test_status_warns_and_uninstall_removes_pre_suffix_launchers(tmp_path, monke current = gateway_windows.get_task_name() assert current != "Hermes_Gateway", "a temp home must carry a profile suffix" - legacy_vbs = startup / "Hermes_Gateway.vbs" - legacy_vbs.write_text("' pre-suffix login item\r\n", encoding="utf-8") legacy_pair = home / "gateway-service" / "Hermes_Gateway.vbs" legacy_pair.write_text("' pre-suffix launcher\r\n", encoding="utf-8") + # Both objects must point INTO this home: a bare-named task/entry targeting another home is a + # sibling install (the default profile's live gateway) and is deliberately left alone. + legacy_vbs = startup / "Hermes_Gateway.vbs" + legacy_vbs.write_text(gateway_windows._build_startup_launcher(legacy_pair.with_suffix(".cmd")), encoding="utf-8") schtasks = shutil.which("schtasks") or "schtasks" create = subprocess.run( - [schtasks, "/Create", "/F", "/TN", "Hermes_Gateway", "/SC", "ONLOGON", "/TR", "cmd.exe /c exit 0"], + [schtasks, "/Create", "/F", "/TN", "Hermes_Gateway", "/SC", "ONLOGON", "/TR", f"wscript.exe //B {legacy_pair}"], capture_output=True, text=True, timeout=60, ) assert create.returncode == 0, create.stderr