diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 4528ec3c..0237adc8 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -6,57 +6,163 @@ # -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. set -euo pipefail -ensure_hermes_stopped() { - python3 - "$HOME" <<'PY' -import os -from pathlib import Path -import sys +unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" -home = Path(sys.argv[1]) +# Only systemd's own word counts as "stopped": a non-zero exit from is-active +# also covers an unreachable user manager, which says nothing about whether +# the process is alive. +unit_stopped() { + local state + state=$(systemctl --user is-active "$1" 2>/dev/null) || true + [[ $state == "inactive" || $state == "failed" ]] +} + +# Hermes is closed here rather than asked to be: the agent in its terminal, +# the desktop app and its helpers, a gateway run by hand -- every process +# whose program, by its executable or by what it was started as, lives in the +# runtime about to be deleted or in the package; for an interpreter that is +# the script it runs, so a gateway started by hand as `python .../hermes` is +# found too. Judged by the program alone, never by a later argument: an +# editor opened on a file in the runtime is the user's. `check` refuses first if anything else has Hermes's files open, by +# its executable, its working directory or a descriptor, that editor or a +# shell sitting in ~/.hermes, so a refusal touches nothing; that +# one is the user's to close, because removing an open SQLite WAL leaves a +# live writer on a deleted generation. `stop` then ends Hermes's own and +# refuses again over whatever is left. +hermes_holders() { + python3 - "$1" "$HOME" <<'PY' +import os +import re +import signal +import sys +import time +from pathlib import Path + +mode, home = sys.argv[1], Path(sys.argv[2]) roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')] roots.append('/opt/hermes-desktop') +runtime = [str((home / relative).resolve()) for relative in ('.hermes/hermes-agent', '.hermes/node', '.hermes/bin')] +runtime.append('/opt/hermes-desktop') -def belongs_to_hermes(target): +def under(target, directories): target = target.removesuffix(' (deleted)') - return any(target == root or target.startswith(root + '/') for root in roots) + return any(target == directory or target.startswith(directory + '/') for directory in directories) -holders = [] -for process in Path('/proc').iterdir(): - if not process.name.isdigit() or int(process.name) == os.getpid(): - continue - try: - if process.stat().st_uid != os.getuid(): +def processes(): + found = [] + for process in Path('/proc').iterdir(): + if not process.name.isdigit() or int(process.name) == os.getpid(): continue - targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')] - entries = [process / 'exe', process / 'cwd'] try: - entries.extend((process / 'fd').iterdir()) - except PermissionError: - pass - for entry in entries: + if process.stat().st_uid != os.getuid(): + continue + argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')] + program = [] try: - targets.append(os.readlink(entry)) + program.append(os.readlink(process / 'exe')) except OSError: pass - if any(belongs_to_hermes(target) for target in targets): - holders.append(process.name) - except (FileNotFoundError, ProcessLookupError, PermissionError): - continue + touched = list(program) + try: + touched.append(os.readlink(process / 'cwd')) + except OSError: + pass + try: + for entry in (process / 'fd').iterdir(): + try: + touched.append(os.readlink(entry)) + except OSError: + pass + except PermissionError: + pass + started_as = argv[:1] + if started_as and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0])): + started_as = argv[:2] + ours = any(under(target, runtime) for target in started_as + program) + if ours or any(under(target, roots) for target in touched): + try: + name = (process / 'comm').read_text().strip() + except OSError: + name = '?' + found.append((int(process.name), ours, name)) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + return found -if holders: - print('Close Hermes and processes using its files before removing it (PIDs: ' - + ', '.join(holders) + '). Then try again.', file=sys.stderr) +def named(entries): + return ', '.join(f'{pid} {name}' for pid, ours, name in entries) + +def refuse(entries): + print('Something other than Hermes still has its files open (PIDs: ' + named(entries) + + '). Close it, then run the removal again.', file=sys.stderr) sys.exit(1) + +strangers = [entry for entry in processes() if not entry[1]] +if strangers: + refuse(strangers) +if mode == 'check': + sys.exit(0) + +mine = [entry for entry in processes() if entry[1]] +if mine: + print('Stopping Hermes (PIDs: ' + named(mine) + ')...') + for signum, patience in ((signal.SIGTERM, 10), (signal.SIGKILL, 3)): + for pid, ours, name in mine: + try: + os.kill(pid, signum) + except ProcessLookupError: + pass + deadline = time.monotonic() + patience + while mine and time.monotonic() < deadline: + time.sleep(0.2) + mine = [entry for entry in processes() if entry[1]] + if not mine: + break + if mine: + print('Hermes did not stop (PIDs: ' + named(mine) + '). Close it, then run the removal again.', file=sys.stderr) + sys.exit(1) +left = processes() +if left: + refuse(left) PY } -# Removing an open SQLite WAL leaves a live writer on a deleted generation. -ensure_hermes_stopped -omarchy-pkg-drop hermes-desktop +hermes_holders check -# The installer leaves a unit waiting to hand the app the Omarchy theme. +# The gateway unit that upstream's `hermes gateway install` writes starts the +# runtime about to be deleted, so it goes with it, and it is stopped before +# the processes are, or systemd would start the gateway again the moment it +# was killed. Judged by what the unit starts, as the processes are: a unit +# whose ExecStart runs this runtime is its own whatever home it serves, and +# one that runs a Hermes kept elsewhere is somebody else's arrangement even +# when its home sits under ~/.hermes. A unit that will not stop aborts the +# removal: dropping the package would strand a live gateway on deleted code +# with no way to restart it cleanly. +for unit_file in "$unit_dir"/hermes-gateway*.service; do + [[ -f $unit_file ]] || continue + grep '^ExecStart=' "$unit_file" | grep -qF "$HOME/.hermes/hermes-agent/" || continue + unit=${unit_file##*/} + if systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then + # .bak is what `gateway install --force` leaves behind when it rewrites a + # unit, so it goes with the unit. + rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit" + systemctl --user daemon-reload 2>/dev/null || true + # A unit that had been failing stays listed as "not-found failed" after + # its file is gone until its failed state is reset. + systemctl --user reset-failed "$unit" 2>/dev/null || true + else + echo "Could not stop $unit; Hermes was not removed." >&2 + exit 1 + fi +done + +# The installer leaves a unit waiting to hand the app the Omarchy theme; it +# runs Hermes itself to do so, so it is stopped before the sweep reaches it. systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true +hermes_holders stop +omarchy-pkg-drop hermes-desktop + # Upstream's installer writes this when the runtime under ~/.hermes has landed, # whether Omarchy ran it for the app or the app's own first launch did, and it # is the only thing that tells that runtime apart from one the user installed @@ -115,7 +221,7 @@ if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cm # turn that into an aborted removal; the size is worth no such thing. size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then - ensure_hermes_stopped + hermes_holders stop rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" data_removed=true fi diff --git a/manual/17-ai.md b/manual/17-ai.md index 35ba9a11..6739a612 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -52,7 +52,7 @@ Crashes can also be silenced one program at a time, which is what the diagnosis The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, the Claude desktop app (Anthropic's Linux beta, with Chat, Cowork, and Claude Code tabs), Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. -Hermes Desktop is the one to know about, because a machine has one Hermes, and it is the app's: Hermes is only ever installed through the app, and the `hermes` command the default agent runs is the app's. Choosing Hermes as the default agent installs the same thing the _Install > AI_ entry does: the package, and a Hermes runtime under `~/.hermes` set up by Hermes' own installer, which takes a few minutes the first time. That runtime is the one Hermes the terminal `hermes` command, the default agent and the app all use, and it updates itself with `hermes update` rather than waiting on an Omarchy release. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. +Hermes Desktop is the one to know about, because a machine has one Hermes, and it is the app's: Hermes is only ever installed through the app, and the `hermes` command the default agent runs is the app's. Choosing Hermes as the default agent installs the same thing the _Install > AI_ entry does: the package, and a Hermes runtime under `~/.hermes` set up by Hermes' own installer, which takes a few minutes the first time. That runtime is the one Hermes the terminal `hermes` command, the default agent and the app all use, and it updates itself with `hermes update` rather than waiting on an Omarchy release. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ closes Hermes first, gateway included, takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 3e612ddb..3c6d7bc7 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -41,9 +41,21 @@ if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then fi exit "${OMARCHY_TEST_GUM_STATUS:-1}" SH +# A unit that will not stop, when a test says so: disable fails and is-active +# keeps answering active. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash echo "systemctl $*" >>"$OMARCHY_TEST_SYSTEMCTL_LOG" +if [[ ${OMARCHY_TEST_UNIT_STUCK:-0} == 1 ]]; then + [[ $2 == "disable" ]] && exit 1 + [[ $2 == "is-active" ]] && echo active +fi +# A user manager that refuses the disable of a unit that is already down. +if [[ ${OMARCHY_TEST_UNIT_DOWN:-0} == 1 ]]; then + [[ $2 == "disable" ]] && exit 1 + [[ $2 == "is-active" ]] && echo inactive +fi +exit 0 SH chmod +x "$mock_bin"/* @@ -73,7 +85,7 @@ remove() { OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ - HOME="$test_home" PATH="$mock_bin:$PATH" \ + HOME="$test_home" XDG_CONFIG_HOME= PATH="$mock_bin:$PATH" \ bash "$test_tmp/remover" "$test_tmp/output" 2>&1 } @@ -86,7 +98,7 @@ remove_tty() { OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ - HOME="$test_home" PATH="$mock_bin:$PATH" \ + HOME="$test_home" XDG_CONFIG_HOME= PATH="$mock_bin:$PATH" \ script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1 } @@ -211,6 +223,50 @@ OMARCHY_TEST_GUM_STATUS=0 remove_tty || fail "a yes takes ~/.hermes whole when the marker never appeared" pass "removal honors a yes on the named paths without the marker" +# The gateway unit upstream's `hermes gateway install` writes runs the runtime +# being removed, so it is stopped first and goes with it: the unit file, the +# .bak a forced reinstall leaves, and its enablement link. +seed_install +unit_dir="$test_home/.config/systemd/user" +mkdir -p "$unit_dir/default.target.wants" +printf '[Service]\nExecStart=%s/.hermes/hermes-agent/venv/bin/python %s/.hermes/hermes-agent/hermes gateway run\nEnvironment="HERMES_HOME=%s/.hermes"\n' "$test_home" "$test_home" "$test_home" >"$unit_dir/hermes-gateway.service" +cp "$unit_dir/hermes-gateway.service" "$unit_dir/hermes-gateway.service.bak" +ln -s "$unit_dir/hermes-gateway.service" "$unit_dir/default.target.wants/hermes-gateway.service" +# A unit that runs a Hermes kept somewhere else is not this runtime's, even +# when the home it serves sits under ~/.hermes. +printf '[Service]\nExecStart=/srv/hermes/venv/bin/python /srv/hermes/hermes gateway run\nEnvironment="HERMES_HOME=%s/.hermes/profiles/work"\n' "$test_home" >"$unit_dir/hermes-gateway-other.service" +remove || fail "remove succeeds with a gateway unit installed" "$(cat "$test_tmp/output")" +grep -Fxq 'systemctl --user disable --now hermes-gateway.service' "$test_tmp/systemctl-log" || + fail "the gateway unit is stopped and disabled" "$(cat "$test_tmp/systemctl-log")" +grep -Fxq 'systemctl --user daemon-reload' "$test_tmp/systemctl-log" || fail "systemd is told the unit is gone" +grep -Fxq 'systemctl --user reset-failed hermes-gateway.service' "$test_tmp/systemctl-log" || fail "a failed state is reset" +[[ ! -e $unit_dir/hermes-gateway.service && ! -e $unit_dir/hermes-gateway.service.bak && ! -L $unit_dir/default.target.wants/hermes-gateway.service ]] || + fail "the gateway unit, its backup and its enablement link are removed" +[[ -f $unit_dir/hermes-gateway-other.service ]] || fail "a gateway unit for a Hermes kept elsewhere survives" +! grep -q 'hermes-gateway-other' "$test_tmp/systemctl-log" || fail "a gateway unit for a Hermes kept elsewhere is not touched" "$(cat "$test_tmp/systemctl-log")" +[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime goes once its gateway is stopped" +pass "removal stops the gateway unit and takes it with the runtime, leaving units for other homes alone" + +# A user manager that will not disable a unit already down is not in the way. +seed_install +mkdir -p "$unit_dir" +printf '[Service]\nExecStart=%s/.hermes/hermes-agent/venv/bin/python %s/.hermes/hermes-agent/hermes gateway run\n' "$test_home" "$test_home" >"$unit_dir/hermes-gateway.service" +OMARCHY_TEST_UNIT_DOWN=1 remove || fail "remove succeeds when disable fails on a unit that is already inactive" "$(cat "$test_tmp/output")" +[[ ! -e $unit_dir/hermes-gateway.service && ! -d $test_home/.hermes/hermes-agent ]] || fail "an inactive unit whose disable failed still goes with the runtime" +pass "a unit already down goes even when systemd refuses the disable" + +# A gateway that will not stop keeps the removal from dropping the package +# under a live process. +seed_install +mkdir -p "$unit_dir" +printf '[Service]\nExecStart=%s/.hermes/hermes-agent/venv/bin/python %s/.hermes/hermes-agent/hermes gateway run\n' "$test_home" "$test_home" >"$unit_dir/hermes-gateway.service" +: >"$test_tmp/drop-log" +OMARCHY_TEST_UNIT_STUCK=1 remove && fail "a gateway that will not stop aborts the removal" +grep -q 'Could not stop hermes-gateway.service' "$test_tmp/output" || fail "a gateway that will not stop is named" "$(cat "$test_tmp/output")" +[[ ! -s $test_tmp/drop-log ]] || fail "the package is not dropped under a gateway that will not stop" +[[ -d $test_home/.hermes/hermes-agent && -f $unit_dir/hermes-gateway.service ]] || fail "nothing is removed under a gateway that will not stop" +pass "a gateway unit that will not stop aborts the removal before anything goes" + # Real SQLite writers exercise the kernel's live/deleted file descriptors. # Package, service and confirmation commands remain confined to the mocks. python3 - "$test_tmp" <<'PY' @@ -239,7 +295,7 @@ def setup(name): runtime.mkdir(parents=True) (runtime / '.hermes-bootstrap-complete').touch() (home / '.config/Hermes').mkdir(parents=True) - env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin", + env = {**os.environ, 'HOME': str(home), 'XDG_CONFIG_HOME': '', 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin", 'OMARCHY_TEST_GUM_STATUS': '0'} for key in ('DROP', 'SYSTEMCTL', 'GUM'): log = home / (key + '.log') @@ -271,7 +327,7 @@ def remove(env): def blocked(result, home, runtime, child): assert result.returncode != 0 and str(child.pid) in result.stderr, result - assert 'Close Hermes' in result.stderr, result.stderr + assert 'other than Hermes' in result.stderr, result.stderr assert (runtime / '.hermes-bootstrap-complete').exists() assert all((home / (name + '.log')).stat().st_size == 0 for name in ('DROP', 'SYSTEMCTL', 'GUM')) @@ -295,6 +351,9 @@ for deleted in (False, True): assert not (home / '.hermes').exists() print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry') +# Hermes's own processes are stopped by the removal rather than reported: the +# agent in its terminal, run by the runtime's command, and the packaged app. +# One of the user's that merely sits in the runtime is still theirs to close. for kind in ('terminal', 'desktop', 'working-directory'): home, runtime, env = setup(kind) executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes') @@ -302,11 +361,94 @@ for kind in ('terminal', 'desktop', 'working-directory'): child = subprocess.Popen(args, executable='/usr/bin/sleep', cwd=runtime if kind == 'working-directory' else scratch) try: - blocked(remove(env), home, runtime, child) + result = remove(env) + if kind == 'working-directory': + blocked(result, home, runtime, child) + else: + assert result.returncode == 0, result + assert 'Stopping Hermes (PIDs: ' + str(child.pid) in result.stdout, result.stdout + assert child.wait(timeout=5) != 0, 'the removal ends a Hermes process of its own' + assert not (home / '.hermes').exists() finally: - child.terminate() - child.wait(timeout=5) -print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database') + if child.poll() is None: + child.terminate() + child.wait(timeout=5) +print('ok - the agent and the packaged app are stopped by the removal; a process merely sitting in the runtime still blocks it') + +# With a stranger holding Hermes's files, the refusal comes before Hermes's +# own processes are touched: the agent is still running afterwards. +home, runtime, env = setup('stranger-and-agent') +unit_dir = home / '.config/systemd/user' +unit_dir.mkdir(parents=True) +unit = unit_dir / 'hermes-gateway.service' +unit.write_text(f'[Service]\nExecStart={runtime}/venv/bin/python {runtime}/hermes gateway run\n') +stranger = writer(home / '.hermes/state.db') +agent = subprocess.Popen([str(runtime / 'hermes'), '30'], executable='/usr/bin/sleep', cwd=scratch) +try: + result = remove(env) + blocked(result, home, runtime, stranger) + assert str(agent.pid) not in result.stderr, result.stderr + assert agent.poll() is None, 'a refusal over a stranger leaves Hermes running' + assert 'Stopping Hermes' not in result.stdout, result.stdout + assert unit.exists(), 'a refusal over a stranger leaves the gateway unit in place' +finally: + stop(stranger) + if agent.poll() is None: + agent.terminate() + agent.wait(timeout=5) +print('ok - a stranger holding Hermes files stops the removal before Hermes itself is touched') + +# A process that merely names a runtime file on its command line, an editor +# opened on one say, is not Hermes: neither stopped nor, holding nothing, in +# the way. +home, runtime, env = setup('argument-only') +editor = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)', str(runtime / 'README.md')], cwd=scratch) +try: + result = remove(env) + assert result.returncode == 0, result + assert editor.poll() is None, 'a process naming a runtime file as an argument is killed' + assert 'Stopping Hermes' not in result.stdout, result.stdout +finally: + editor.terminate() + editor.wait(timeout=5) +print('ok - a runtime path in an argument does not make a process Hermes') + +# A gateway started by hand with the venv active is an interpreter running the +# runtime's script: the script is the program, and it is stopped. +home, runtime, env = setup('interpreter-script') +script = runtime / 'hermes' +script.write_text('import time\ntime.sleep(30)\n') +gateway = subprocess.Popen([sys.executable, str(script), 'gateway', 'run'], cwd=scratch) +try: + result = remove(env) + assert result.returncode == 0, result + assert 'Stopping Hermes (PIDs: ' + str(gateway.pid) in result.stdout, result.stdout + assert gateway.wait(timeout=5) != 0, 'an interpreter running the runtime script is stopped' +finally: + if gateway.poll() is None: + gateway.terminate() + gateway.wait(timeout=5) +print('ok - an interpreter running a runtime script is Hermes and is stopped') + +# A program whose executable lives in the runtime is Hermes whatever it was +# started as: the Electron helpers name themselves by path, but a copy of the +# binary started by a bare name is found through /proc//exe. +home, runtime, env = setup('executable') +import shutil +(runtime / 'venv/bin').mkdir(parents=True) +binary = runtime / 'venv/bin/sleeper' +shutil.copy('/usr/bin/sleep', binary) +helper = subprocess.Popen(['sleeper', '30'], executable=str(binary), cwd=scratch) +try: + result = remove(env) + assert result.returncode == 0, result + assert 'Stopping Hermes (PIDs: ' + str(helper.pid) in result.stdout, result.stdout + assert helper.wait(timeout=5) != 0, 'a program whose executable is in the runtime is stopped' +finally: + if helper.poll() is None: + helper.terminate() + helper.wait(timeout=5) +print('ok - a program whose executable is in the runtime is Hermes and is stopped') home, runtime, env = setup('unrelated-writer') sibling = home / '.hermes-other'