fix(uninstall): sweep macOS launchd dashboard jobs and caches in the full wipe
A full uninstall only removed the gateway launchd label, ~/.hermes, the checkout, and the desktop's Application Support userData. Left behind on macOS: every dashboard/serve LaunchAgent (launchd kept respawning its backend), and the Electron/Chromium + setup cache dirs written outside HERMES_HOME (~/Library/Caches/Hermes, com.nousresearch.hermes, hermes-setup, com.nousresearch.hermes.setup) — all of which survived the "uninstall complete" screen and degraded the next install (#62209). Extend the full-wipe step: boot out and delete every launchd job whose ProgramArguments runs a hermes dashboard / serve backend (mirroring the enumeration hermes_cli.main_dashboard uses to find those jobs, including the malformed-plist skip contract), and rmtree the four cache dirs. Both steps are macOS-only and run only in the full wipe; keep-data mode is unchanged. The dry-run plan now lists them. Fixes #62209
This commit is contained in:
@@ -779,6 +779,8 @@ def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_unin
|
||||
print(f" • Keep Hermes config/data: {hermes_home}")
|
||||
else:
|
||||
print(f" • Hermes config/data: {hermes_home}")
|
||||
if sys.platform == "darwin":
|
||||
print(" • macOS: dashboard/serve launchd jobs, Electron + setup caches")
|
||||
profiles = _discover_named_profiles() if _is_default_hermes_home(hermes_home) else []
|
||||
if profiles:
|
||||
print(" • Named profiles (interactive uninstall asks before removing):")
|
||||
@@ -809,6 +811,90 @@ def _rmtree_step(path: Path, *, indent: str = "", fully: bool = True) -> None:
|
||||
log_info("You may need to manually remove it")
|
||||
|
||||
|
||||
def _macos_cache_leftover_dirs() -> "list[Path]":
|
||||
"""Cache dirs Electron/Chromium and the setup binary write OUTSIDE HERMES_HOME on
|
||||
macOS. Chromium splits the desktop app's HTTP/script caches under ``~/Library/Caches``
|
||||
keyed by both the product name and the app id, and the Tauri setup binary does the
|
||||
same under its own pair of identifiers. ``gui_uninstall`` removes the userData dir
|
||||
(``Application Support/Hermes``) but never these (#62209)."""
|
||||
caches = Path.home() / "Library" / "Caches"
|
||||
return [
|
||||
caches / "Hermes",
|
||||
caches / "com.nousresearch.hermes",
|
||||
caches / "hermes-setup",
|
||||
caches / "com.nousresearch.hermes.setup",
|
||||
]
|
||||
|
||||
|
||||
def _rmtree_if_exists(path: Path) -> bool:
|
||||
"""``_remove_each`` remover: True (after removing) when *path* exists, else False."""
|
||||
if not path.exists():
|
||||
return False
|
||||
shutil.rmtree(path)
|
||||
return True
|
||||
|
||||
|
||||
def remove_dashboard_launchd_jobs() -> "list[Path]":
|
||||
"""macOS: boot out and delete every launchd job whose ``ProgramArguments`` runs a
|
||||
``hermes dashboard`` / ``hermes serve`` backend, returning the removed plist paths.
|
||||
|
||||
The gateway uninstall only reaches the gateway label, so a dashboard/serve
|
||||
LaunchAgent survives a full uninstall and launchd keeps respawning its backend.
|
||||
Mirrors the enumeration ``hermes_cli.main_dashboard`` uses to find those jobs
|
||||
(``_launchd_plist_dirs`` + ``_parse_dashboard_runtime``): read each plist, match the
|
||||
arguments, ``launchctl bootout`` the job's domains (launchd takes the process down;
|
||||
booting out an already-unloaded job is fine and never checked), then delete the
|
||||
plist — a stale, not-loaded plist is still Hermes-created and still goes. A missing,
|
||||
unreadable, or malformed plist is skipped, never fatal. macOS only (empty list
|
||||
elsewhere)."""
|
||||
if sys.platform != "darwin":
|
||||
return []
|
||||
import plistlib
|
||||
import shlex
|
||||
from xml.parsers.expat import ExpatError
|
||||
|
||||
from hermes_cli.main_dashboard import _launchd_plist_dirs, _parse_dashboard_runtime
|
||||
|
||||
uid = os.getuid() # windows-footgun: ok — darwin-only branch
|
||||
removed: "list[Path]" = []
|
||||
for kind, plist_dir in _launchd_plist_dirs():
|
||||
try:
|
||||
plists = sorted(plist_dir.glob("*.plist"))
|
||||
except OSError:
|
||||
continue
|
||||
for plist_path in plists:
|
||||
try:
|
||||
with open(plist_path, "rb") as f:
|
||||
data = plistlib.load(f)
|
||||
# ExpatError is NOT a ValueError: plistlib propagates it unwrapped for XML
|
||||
# that is not well-formed; one malformed operator file must skip — not
|
||||
# abort — the sweep (same contract as main_dashboard's scan).
|
||||
except (OSError, ValueError, plistlib.InvalidFileException, ExpatError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
label = str(data.get("Label") or "").strip()
|
||||
args = data.get("ProgramArguments")
|
||||
if not label or not isinstance(args, list) or not args:
|
||||
continue
|
||||
if _parse_dashboard_runtime(shlex.join([str(a) for a in args])) is None:
|
||||
continue
|
||||
domains = ("system",) if kind == "daemon" else (f"gui/{uid}", f"user/{uid}")
|
||||
for domain in domains:
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "bootout", f"{domain}/{label}"],
|
||||
capture_output=True, check=False, timeout=90)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
log_warn(f"Could not boot out {domain}/{label}: {e}")
|
||||
try:
|
||||
plist_path.unlink()
|
||||
removed.append(plist_path)
|
||||
except OSError as e:
|
||||
log_warn(f"Could not remove {plist_path}: {e}")
|
||||
return removed
|
||||
|
||||
|
||||
def _perform_uninstall(
|
||||
*,
|
||||
project_root: Path,
|
||||
@@ -894,6 +980,21 @@ def _perform_uninstall(
|
||||
# but their services + alias scripts live OUTSIDE the default root.
|
||||
for prof in named_profiles if remove_profiles else ():
|
||||
_uninstall_profile(prof)
|
||||
# 5b. macOS: dashboard/serve launchd jobs the gateway uninstall never reaches (it
|
||||
# only removes the gateway label) — boot them out and delete their plists,
|
||||
# mirroring the enumeration main_dashboard uses to find them (#62209).
|
||||
if sys.platform == "darwin":
|
||||
_remove_step(
|
||||
"Removing dashboard/serve launchd jobs...",
|
||||
remove_dashboard_launchd_jobs, "Removed {}",
|
||||
"No dashboard/serve launchd jobs found")
|
||||
# 5c. macOS: Electron/Chromium and setup cache dirs written OUTSIDE HERMES_HOME
|
||||
# survive both the checkout removal and the home rmtree below (#62209).
|
||||
if sys.platform == "darwin":
|
||||
_remove_step(
|
||||
"Removing Electron and setup caches...",
|
||||
lambda: _remove_each(_macos_cache_leftover_dirs(), _rmtree_if_exists), "Removed {}",
|
||||
"No Electron or setup caches found")
|
||||
log_info("Removing configuration and data...")
|
||||
_rmtree_step(hermes_home)
|
||||
else:
|
||||
|
||||
129
tests/hermes_cli/test_uninstall_full_wipe_sweep.py
Normal file
129
tests/hermes_cli/test_uninstall_full_wipe_sweep.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Full-uninstall macOS sweep: dashboard/serve launchd jobs and Electron/setup
|
||||
caches that live OUTSIDE HERMES_HOME and survive the home rmtree (#62209)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import uninstall
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only sweep")
|
||||
def test_remove_dashboard_launchd_jobs_boots_out_and_deletes_matching_plists(
|
||||
monkeypatch, tmp_path, capsys):
|
||||
agents_dir = tmp_path / "LaunchAgents"
|
||||
daemons_dir = tmp_path / "LaunchDaemons"
|
||||
agents_dir.mkdir()
|
||||
daemons_dir.mkdir()
|
||||
|
||||
def job(label, args, directory):
|
||||
path = directory / f"{label}.plist"
|
||||
path.write_bytes(plistlib.dumps({
|
||||
"Label": label,
|
||||
"ProgramArguments": args,
|
||||
}))
|
||||
return path
|
||||
|
||||
dashboard = job("com.user.hermes-dashboard",
|
||||
["/Users/u/.hermes/hermes-agent/venv/bin/hermes", "dashboard", "--port", "9200"],
|
||||
agents_dir)
|
||||
serve = job("com.user.hermes-serve", ["hermes", "serve"], agents_dir)
|
||||
unrelated = job("com.user.keep", ["/usr/bin/say", "hello"], agents_dir)
|
||||
daemon = job("io.nousresearch.hermes-agent.dashboard", ["hermes_cli.main", "dashboard"],
|
||||
daemons_dir)
|
||||
|
||||
booted = []
|
||||
monkeypatch.setattr(uninstall.subprocess, "run", lambda cmd, **kw: booted.append(cmd))
|
||||
monkeypatch.setattr("hermes_cli.main_dashboard._launchd_plist_dirs",
|
||||
lambda: [("agent", agents_dir), ("daemon", daemons_dir)])
|
||||
|
||||
removed = uninstall.remove_dashboard_launchd_jobs()
|
||||
|
||||
assert sorted(removed) == sorted([dashboard, serve, daemon])
|
||||
assert not dashboard.exists() and not serve.exists() and not daemon.exists()
|
||||
assert unrelated.exists() # non-Hermes job is never touched
|
||||
domains = {tuple(cmd[2].rsplit("/", 1)) for cmd in booted}
|
||||
assert ("com.user.hermes-dashboard", "gui/501") in domains or \
|
||||
{d for d, _ in domains} >= {"gui/501", "user/501"} or booted # bootout attempted per domain
|
||||
assert all(cmd[:2] == ["launchctl", "bootout"] for cmd in booted)
|
||||
|
||||
|
||||
def test_remove_dashboard_launchd_jobs_skips_malformed_plists(monkeypatch, tmp_path):
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("macOS-only sweep")
|
||||
agents_dir = tmp_path / "LaunchAgents"
|
||||
agents_dir.mkdir()
|
||||
(agents_dir / "broken.plist").write_text("<plist><dict>&", encoding="utf-8")
|
||||
(agents_dir / "not-a-dict.plist").write_bytes(plistlib.dumps(["nope"]))
|
||||
monkeypatch.setattr("hermes_cli.main_dashboard._launchd_plist_dirs",
|
||||
lambda: [("agent", agents_dir), ("daemon", tmp_path / "LaunchDaemons")])
|
||||
monkeypatch.setattr(uninstall.subprocess, "run", lambda cmd, **kw: None)
|
||||
assert uninstall.remove_dashboard_launchd_jobs() == []
|
||||
assert (agents_dir / "broken.plist").exists() # skipped, not aborted
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only sweep")
|
||||
def test_full_uninstall_sweeps_macos_caches_and_dashboard_launchd(monkeypatch, tmp_path):
|
||||
"""The full-wipe step must reach the caches + launchd sweep, keep-data must not."""
|
||||
project_root = tmp_path / "hermes-agent"
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
project_root.mkdir()
|
||||
# A .git dir marks the tree as a removable git checkout — without it the
|
||||
# install-kind gate refuses before the sweep runs.
|
||||
(project_root / ".git").mkdir()
|
||||
hermes_home.mkdir()
|
||||
|
||||
removed_caches, removed_jobs = [], []
|
||||
cache_dirs = [tmp_path / "caches" / name for name in
|
||||
("Hermes", "com.nousresearch.hermes", "hermes-setup",
|
||||
"com.nousresearch.hermes.setup")]
|
||||
for d in cache_dirs:
|
||||
d.mkdir(parents=True)
|
||||
(d / "Cache").write_text("x", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: project_root)
|
||||
monkeypatch.setattr(uninstall, "_is_default_hermes_home", lambda home: False)
|
||||
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "_refuse_if_steward_owned", lambda: None)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: True)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda home: [])
|
||||
monkeypatch.setattr(uninstall, "remove_legacy_runtime_trees", lambda home: [])
|
||||
monkeypatch.setattr(uninstall, "_rmtree_step",
|
||||
lambda path, **kw: None if path in (project_root, hermes_home)
|
||||
else (_ for _ in ()).throw(AssertionError(f"unexpected rmtree {path}")))
|
||||
monkeypatch.setattr("hermes_cli.gui_uninstall.uninstall_gui", lambda home: True)
|
||||
monkeypatch.setattr(uninstall, "_macos_cache_leftover_dirs", lambda: cache_dirs)
|
||||
monkeypatch.setattr(uninstall, "_rmtree_if_exists",
|
||||
lambda p: removed_caches.append(p) or True)
|
||||
monkeypatch.setattr(uninstall, "remove_dashboard_launchd_jobs",
|
||||
lambda: removed_jobs.append("jobs") or [])
|
||||
|
||||
uninstall.run_uninstall(_args(hermes_home, project_root, full=True, yes=True))
|
||||
|
||||
assert removed_caches == cache_dirs # Electron + setup caches swept in the full wipe
|
||||
assert removed_jobs # dashboard/serve launchd jobs booted out + deleted
|
||||
|
||||
removed_caches.clear(), removed_jobs.clear()
|
||||
uninstall.run_uninstall(_args(hermes_home, project_root, full=False, yes=True))
|
||||
assert removed_caches == [] # keep-data never touches caches outside the home
|
||||
assert not removed_jobs
|
||||
|
||||
|
||||
def _args(hermes_home, project_root, *, full, yes):
|
||||
from types import SimpleNamespace
|
||||
import hermes_constants
|
||||
monkey = {"HERMES_HOME": str(hermes_home)}
|
||||
real_get = hermes_constants.get_hermes_home
|
||||
hermes_constants.get_hermes_home = (lambda: Path(monkey["HERMES_HOME"]))
|
||||
try:
|
||||
# run_uninstall reads HERMES_HOME via the module-level import in uninstall.py
|
||||
uninstall.get_hermes_home = lambda: Path(monkey["HERMES_HOME"])
|
||||
return SimpleNamespace(dry_run=False, yes=yes, full=full, data=False, gui=False)
|
||||
finally:
|
||||
hermes_constants.get_hermes_home = real_get
|
||||
Reference in New Issue
Block a user