Files
hermes-agent/tests/hermes_cli/test_uninstall_leftovers.py
Austin Pickett 6846c6c882 fix(uninstall): sweep both LaunchAgent labels and per-user app leftovers
The uninstaller only removed the current launchd label's plist, so an
older io.nousresearch.hermes-agent.gateway* agent kept respawning the
gateway after the code tree was gone. Boot out (gui and user domains) and
unload every plist matching either label pattern before unlinking.

Also remove per-user leftovers earlier uninstalls left behind: macOS
Library Caches/Logs/WebKit/HTTPStorages/Saved Application State entries
named *hermes* and the XDG cache dir; the XDG data dir
(~/.local/share/hermes) is removed only on a full uninstall and is
explicitly reported as preserved otherwise. ~/.hermes stays intentional
in keep-data mode and is already reported.

Fixes #62209
2026-09-26 21:00:12 -04:00

125 lines
4.6 KiB
Python

"""Uninstall sweeps every leftover: both LaunchAgent label patterns, macOS
Library app-store entries and the XDG cache/data dirs outside HERMES_HOME
(#62209 — uninstalls reported success yet launchd respawned the gateway and
Library/XDG dirs survived until manually removed)."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
from hermes_cli import uninstall
@pytest.fixture
def fake_home(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
return home
# ── LaunchAgent sweep: both label patterns ──────────────────────────────
@pytest.fixture
def launch_agents(fake_home, monkeypatch):
agents = fake_home / "Library" / "LaunchAgents"
agents.mkdir(parents=True)
monkeypatch.setattr(
uninstall, "get_launchd_plist_path",
lambda: agents / "ai.hermes.gateway.plist", raising=False,
)
# _remove_launchd_gateway imports it from hermes_cli.gateway at call time.
import hermes_cli.gateway as gateway
monkeypatch.setattr(gateway, "get_launchd_plist_path", lambda: agents / "ai.hermes.gateway.plist")
return agents
def _fake_launchctl(monkeypatch, calls):
def fake_run(cmd, **kwargs):
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
monkeypatch.setattr(uninstall.subprocess, "run", fake_run)
def test_remove_launchd_gateway_sweeps_both_label_patterns(launch_agents, monkeypatch):
current = launch_agents / "ai.hermes.gateway.plist"
profiled = launch_agents / "ai.hermes.gateway-work.plist"
legacy = launch_agents / "io.nousresearch.hermes-agent.gateway.plist"
unrelated = launch_agents / "com.other.agent.plist"
for p in (current, profiled, legacy, unrelated):
p.write_text("<plist/>", encoding="utf-8")
calls: list[list[str]] = []
_fake_launchctl(monkeypatch, calls)
assert uninstall._remove_launchd_gateway() is True
assert not current.exists() and not profiled.exists() and not legacy.exists()
assert unrelated.exists() # never touch another app's agent
labels = {c[-1].rsplit("/", 1)[-1] for c in calls if c[1] == "bootout"}
assert labels == {"ai.hermes.gateway", "ai.hermes.gateway-work",
"io.nousresearch.hermes-agent.gateway"}
assert all(c[1] == "unload" for c in calls if c[1] == "unload")
def test_remove_launchd_gateway_returns_false_when_no_plists(launch_agents, monkeypatch):
calls: list[list[str]] = []
_fake_launchctl(monkeypatch, calls)
assert uninstall._remove_launchd_gateway() is False
assert calls == []
# ── macOS Library + XDG leftovers ────────────────────────────────────────
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS Library layout")
def test_remove_desktop_app_leftovers_removes_hermes_library_entries(fake_home):
library = fake_home / "Library"
ours = []
for parent, name in (
("Caches", "com.nousresearch.hermes"),
("Logs", "Hermes"),
("WebKit", "com.nousresearch.hermes"),
("HTTPStorages", "com.nousresearch.hermes"),
("Saved Application State", "com.nousresearch.hermes.savedState"),
):
d = library / parent / name
d.mkdir(parents=True)
ours.append(d)
(library / "Caches" / "com.other.vendor").mkdir(parents=True) # untouched
removed = uninstall.remove_desktop_app_leftovers(full_uninstall=False)
assert all(not d.exists() for d in ours)
assert (library / "Caches" / "com.other.vendor").exists()
assert len(removed) == len(ours)
def test_xdg_cache_removed_and_data_mode_semantics(fake_home):
cache = fake_home / ".cache" / "hermes"
data = fake_home / ".local" / "share" / "hermes"
cache.mkdir(parents=True)
data.mkdir(parents=True)
assert uninstall._xdg_leftover_paths(full_uninstall=False) == ([cache], [data])
assert uninstall._xdg_leftover_paths(full_uninstall=True) == ([cache, data], [])
removed = uninstall.remove_desktop_app_leftovers(full_uninstall=False)
assert cache in removed and not cache.exists()
assert data.exists() # user data is preserved in keep-data mode
def test_xdg_data_removed_on_full_uninstall(fake_home):
cache = fake_home / ".cache" / "hermes"
data = fake_home / ".local" / "share" / "hermes"
cache.mkdir(parents=True)
data.mkdir(parents=True)
removed = uninstall.remove_desktop_app_leftovers(full_uninstall=True)
assert not cache.exists() and not data.exists()
assert cache in removed and data in removed