diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 388746b7..da8af34c 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -6,6 +6,52 @@ # -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 + +home = Path(sys.argv[1]) +roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')] +roots.append('/opt/hermes-desktop') + +def belongs_to_hermes(target): + target = target.removesuffix(' (deleted)') + return any(target == root or target.startswith(root + '/') for root in roots) + +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(): + 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: + try: + targets.append(os.readlink(entry)) + except OSError: + pass + if any(belongs_to_hermes(target) for target in targets): + holders.append(process.name) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + +if holders: + print('Close Hermes and processes using its files before removing it (PIDs: ' + + ', '.join(holders) + '). Then try again.', file=sys.stderr) + sys.exit(1) +PY +} + +# Removing an open SQLite WAL leaves a live writer on a deleted generation. +ensure_hermes_stopped omarchy-pkg-drop hermes-desktop # The installer leaves a unit waiting to hand the app the Omarchy theme. @@ -19,6 +65,7 @@ systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true # Tolerated here rather than fatal, so the ~/.hermes handling below still runs; # the failure is answered for at the end instead of being swallowed. cli_removed=true +ensure_hermes_stopped omarchy-install-hermes-cli --remove || cli_removed=false # The app writes this when the runtime it provisions under ~/.hermes has landed, @@ -78,6 +125,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 rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" data_removed=true fi diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 0a06f03a..f325b472 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -11,6 +11,14 @@ mock_bin="$test_tmp/bin" test_home="$test_tmp/home" mkdir -p "$mock_bin" +# Keep package-path checks scoped to the fixture, even with a live app open. +python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY' +from pathlib import Path +import sys +source, scratch = map(Path, sys.argv[1:]) +(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package'))) +PY + cat >"$mock_bin/omarchy-pkg-drop" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" @@ -32,6 +40,14 @@ SH cat >"$mock_bin/gum" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG" +if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then + touch "$OMARCHY_TEST_PROMPT_GATE.started" + for (( attempt=0; attempt<500; attempt++ )); do + [[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0 + sleep 0.01 + done + exit 1 +fi exit "${OMARCHY_TEST_GUM_STATUS:-1}" SH cat >"$mock_bin/systemctl" <<'SH' @@ -70,7 +86,7 @@ remove() { OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ - bash "$ROOT/bin/omarchy-remove-ai-hermes" /dev/null 2>&1 + bash "$test_tmp/remover" "$test_tmp/output" 2>&1 } # script(1) puts the remover on a pty, which is the only way -t 0 answers true @@ -85,7 +101,7 @@ remove_tty() { OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ - script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1 + script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1 } # The app brings its own uv and its own node; both are runtime, not data. @@ -229,3 +245,140 @@ OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces i [[ ! -d $test_home/.hermes/hermes-agent ]] || fail "a failed CLI teardown does not stop the runtime removal" pass "a failed CLI teardown is reported after the runtime is handled" + +# 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' +import os +from pathlib import Path +import pty +import subprocess +import sys +import time + +scratch = Path(sys.argv[1]) +writer_code = '''import os, sqlite3, sys +c = sqlite3.connect(os.environ['TEST_DB']) +c.execute('pragma journal_mode=wal') +c.execute('create table fixture(value)') +c.execute("insert into fixture values ('keep')") +c.commit() +print('ready', flush=True) +sys.stdin.readline() +c.close() +''' + +def setup(name): + home = scratch / name + runtime = home / '.hermes/hermes-agent' + 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", + 'OMARCHY_TEST_GUM_STATUS': '0'} + for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'): + log = home / (key + '.log') + log.touch() + env['OMARCHY_TEST_' + key + '_LOG'] = str(log) + return home, runtime, env + +def writer(db): + child = subprocess.Popen([sys.executable, '-u', '-c', writer_code], + env={**os.environ, 'TEST_DB': str(db)}, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + assert child.stdout.readline().strip() == 'ready' + return child + +def stop(child): + if child.poll() is None: + child.stdin.write('\n') + child.stdin.flush() + child.wait(timeout=5) + +def remove(env): + master, slave = pty.openpty() + try: + return subprocess.run(['bash', str(scratch / 'remover')], env=env, + stdin=slave, capture_output=True, text=True, timeout=10) + finally: + os.close(master) + os.close(slave) + +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 (runtime / '.hermes-bootstrap-complete').exists() + assert all((home / (name + '.log')).stat().st_size == 0 + for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM')) + assert child.poll() is None, 'remover must not kill sessions' + +for deleted in (False, True): + home, runtime, env = setup('deleted-writer' if deleted else 'live-writer') + db = home / '.hermes/state.db' + child = writer(db) + try: + if deleted: + for suffix in ('', '-wal', '-shm'): + Path(str(db) + suffix).unlink() + db.write_bytes(b'new database generation') + blocked(remove(env), home, runtime, child) + if deleted: + assert db.read_bytes() == b'new database generation' + finally: + stop(child) + assert remove(env).returncode == 0, 'removal succeeds once the writer closes' + assert not (home / '.hermes').exists() +print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry') + +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') + args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30'] + child = subprocess.Popen(args, executable='/usr/bin/sleep', + cwd=runtime if kind == 'working-directory' else scratch) + try: + blocked(remove(env), home, runtime, child) + finally: + child.terminate() + child.wait(timeout=5) +print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database') + +home, runtime, env = setup('unrelated-writer') +sibling = home / '.hermes-other' +sibling.mkdir() +child = writer(sibling / 'state.db') +try: + assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal' + assert child.poll() is None +finally: + stop(child) +print('ok - unrelated database holders are left alone') + +home, runtime, env = setup('prompt-race') +gate = home / 'prompt' +env['OMARCHY_TEST_PROMPT_GATE'] = str(gate) +master, slave = pty.openpty() +remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +os.close(slave) +child = None +try: + deadline = time.monotonic() + 5 + while not Path(str(gate) + '.started').exists(): + assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached' + time.sleep(0.01) + child = writer(home / '.hermes/state.db') + Path(str(gate) + '.continue').touch() + stdout, stderr = remover.communicate(timeout=10) + assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr) + assert (home / '.hermes/state.db-wal').exists() + assert (home / '.config/Hermes').exists() +finally: + if child is not None: + stop(child) + if remover.poll() is None: + remover.terminate() + remover.wait(timeout=5) + os.close(master) +print('ok - a writer started during confirmation blocks data deletion') +PY