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:
Hermes Agent
2026-09-25 12:24:03 -05:00
committed by brooklyn!
parent 7131fe3f03
commit a975615869
2 changed files with 230 additions and 0 deletions

View File

@@ -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:

View 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