From aa3868b345256602d267eb2d2d43240511e412c0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 02:55:06 -0500 Subject: [PATCH 1/4] Prepare Hermes native desktop for in-app updates Use the packaged upstream installer and matching prebuilt app to prepare the writable user installation before launch. Preserve existing builds and modified sources; record the upstream build stamp only for a matching prebuilt app. Co-Authored-By: GPT-6 Codex (xhigh) --- bin/omarchy-install-ai-hermes | 102 +++++++- test/shell.d/hermes-desktop-install-test.sh | 265 ++++++++++++++++++++ 2 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 test/shell.d/hermes-desktop-install-test.sh diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index fb1411c0b97..abb668aa6cd 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -5,11 +5,11 @@ set -e -# No CLI is installed here on purpose. Hermes Desktop only runs against a -# runtime built from its own commit, so it provisions one itself under -# ~/.hermes on first launch, which takes a few minutes and shows its own -# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and -# the version gap fails the app's readiness probe with a 401. +if (( EUID == 0 )); then + echo "Run this command as your desktop user, without sudo." >&2 + exit 1 +fi + echo "Installing Hermes Desktop..." omarchy-pkg-add hermes-desktop @@ -18,15 +18,101 @@ omarchy-pkg-add hermes-desktop # and the app all end up on the app's installation. omarchy-install-hermes-cli || true +# Keep the runtime at the root even when invoked from a Hermes profile. +HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}") +home_parent=$(dirname -- "$HERMES_HOME") +if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then + HERMES_HOME=$(dirname -- "$home_parent") +fi +export HERMES_HOME + +runtime="$HERMES_HOME/hermes-agent" +native_app="$runtime/apps/desktop/release/linux-unpacked" +release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json) + +runtime_ready() { + [[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] && + timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1 +} + +if ! runtime_ready; then + # The upstream installer can reset an existing checkout. Do not pin a newer + # or modified runtime back to the package release while repairing setup. + if [[ -e $runtime || -L $runtime ]]; then + if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] || + [[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then + echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2 + exit 1 + fi + fi + + echo "Setting up the Hermes runtime..." + bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --dir "$runtime" --hermes-home "$HERMES_HOME" + if ! runtime_ready; then + echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2 + exit 1 + fi +fi + +runtime_commit=$(git -C "$runtime" rev-parse HEAD) +if [[ $runtime_commit == "$release_commit" ]]; then + if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then + git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch + elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then + echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2 + exit 1 + fi +fi + +if [[ -e $native_app || -L $native_app ]]; then + if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then + echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2 + exit 1 + fi +else + if [[ $runtime_commit != "$release_commit" ]]; then + echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2 + exit 1 + fi + desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json) + if [[ -n $desktop_changes ]]; then + echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2 + exit 1 + fi + + mkdir -p -- "${native_app%/*}" + staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX") + trap 'rm -rf -- "$staging"' EXIT + cp -a /opt/hermes-desktop/. "$staging/" + chmod 0755 "$staging/chrome-sandbox" + mv -T --no-clobber -- "$staging" "$native_app" + if [[ -e $staging ]]; then + echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2 + exit 1 + fi + trap - EXIT + + # Record this matching prebuilt app using the CLI's own content hash, so + # subsequent menu launches do not rebuild an app that is already current. + env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY' +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from hermes_cli.main import _write_desktop_build_stamp + +_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False) +PY +fi + echo "Opening Hermes Desktop..." setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & -# Only a running Hermes can be told which skin to show, and the first launch -# takes minutes; a unit outlives this terminal and reports to the journal. +# Only a running Hermes can be told which skin to show; a unit outlives this +# terminal and reports to the journal. echo "Matching Hermes to the current theme once it is set up..." systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait echo "" echo "Hermes Desktop has been installed." -echo "Its first launch installs the Hermes runtime, which takes a few minutes." diff --git a/test/shell.d/hermes-desktop-install-test.sh b/test/shell.d/hermes-desktop-install-test.sh new file mode 100644 index 00000000000..4ba8abb8989 --- /dev/null +++ b/test/shell.d/hermes-desktop-install-test.sh @@ -0,0 +1,265 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +for command in git jq python3; do require_command "$command"; done + +test_tmp=$(mktemp -d) +trap 'rm -rf -- "$test_tmp"' EXIT +export OMARCHY_TEST_ROOT="$test_tmp" +mkdir -p "$test_tmp/bin" "$test_tmp/package/resources" "$test_tmp/share" "$test_tmp/seed" + +# Real Git exercises patch checks and preservation; all package, desktop and +# service commands are mocks. No command reaches the live user installation. +git -C "$test_tmp/seed" init -q -b main +printf 'venv/\n.hermes-bootstrap-complete\napps/desktop/release/\n__pycache__/\n' >"$test_tmp/seed/.gitignore" +printf 'before\n' >"$test_tmp/seed/runtime.txt" +mkdir -p "$test_tmp/seed/apps/desktop/src" +printf 'desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js" +mkdir -p "$test_tmp/seed/hermes_cli" +cat >"$test_tmp/seed/hermes_cli/main.py" <<'PY' +import os +from pathlib import Path + +def _write_desktop_build_stamp(project_root, *, source_mode): + home = Path(os.environ['HERMES_HOME']) + assert project_root == home / 'hermes-agent' + assert source_mode is False + assert (project_root / 'apps/desktop/release/linux-unpacked/resources/app.asar').is_file() + (home / 'desktop-build-stamp.json').write_text('upstream build stamp') + with (Path(os.environ['OMARCHY_TEST_ROOT']) / 'events').open('a') as log: + log.write('build-stamp\n') +PY +git -C "$test_tmp/seed" add . +git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm fixture +release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) +printf 'after\n' >"$test_tmp/seed/runtime.txt" +git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch" +printf 'before\n' >"$test_tmp/seed/runtime.txt" +printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json" +printf 'packaged app\n' >"$test_tmp/package/resources/app.asar" +printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes" +touch "$test_tmp/package/chrome-sandbox" +chmod 755 "$test_tmp/package/Hermes" +chmod 4755 "$test_tmp/package/chrome-sandbox" + +cat >"$test_tmp/share/install.sh" <<'MOCK' +#!/bin/bash +set -e +printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events" +printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args" +[[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7 +while (( $# )); do + case "$1" in + --dir) runtime=$2; shift ;; + --hermes-home) [[ $2 == "$HERMES_HOME" ]] ;; + esac + shift +done +mkdir -p -- "${runtime%/*}" +if [[ ! -d $runtime ]]; then git clone -q "$OMARCHY_TEST_ROOT/seed" "$runtime"; fi +mkdir -p "$runtime/venv/bin" +printf '#!/bin/bash\nexit 0\n' >"$runtime/venv/bin/hermes" +chmod +x "$runtime/venv/bin/hermes" +printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python" +chmod +x "$runtime/venv/bin/python" +[[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete" +MOCK + +cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK' +#!/bin/bash +printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +[[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]] +MOCK +cat >"$test_tmp/bin/omarchy-install-hermes-cli" <<'MOCK' +#!/bin/bash +printf 'handoff\n' >>"$OMARCHY_TEST_ROOT/events" +exit 1 +MOCK +cat >"$test_tmp/bin/setsid" <<'MOCK' +#!/bin/bash +exec "$@" +MOCK +cat >"$test_tmp/bin/cp" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_COPY_FAIL:-0} == 1 ]]; then + touch "${@: -1}/partial-copy" + exit 9 +fi +exec /usr/bin/cp "$@" +MOCK +cat >"$test_tmp/bin/mv" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then + mkdir -p "${@: -1}" + printf 'concurrent app\n' >"${@: -1}/keep" +fi +exec /usr/bin/mv "$@" +MOCK +cat >"$test_tmp/bin/uwsm-app" <<'MOCK' +#!/bin/bash +[[ $1 == -- ]] || exit 1 +shift +exec "$@" +MOCK +cat >"$test_tmp/bin/hermes-desktop" <<'MOCK' +#!/bin/bash +native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked" +if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then + printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events" +else + printf 'launch-before-copy\n' >>"$OMARCHY_TEST_ROOT/events" +fi +MOCK +cat >"$test_tmp/bin/systemctl" <<'MOCK' +#!/bin/bash +printf 'theme-stop\n' >>"$OMARCHY_TEST_ROOT/events" +MOCK +cat >"$test_tmp/bin/systemd-run" <<'MOCK' +#!/bin/bash +printf 'theme-start\n' >>"$OMARCHY_TEST_ROOT/events" +# Join the mock asynchronous launch so every test owns its full lifetime. +for (( attempt=0; attempt<100; attempt++ )); do + if grep -q '^launch' "$OMARCHY_TEST_ROOT/events"; then exit 0; fi + sleep 0.01 +done +exit 1 +MOCK +chmod +x "$test_tmp/bin/"* + +# Substitute only system package paths in a scratch copy of the actual script. +python3 - "$ROOT/bin/omarchy-install-ai-hermes" "$test_tmp" <<'PY' +from pathlib import Path +import sys +source, scratch = Path(sys.argv[1]), Path(sys.argv[2]) +script = source.read_text() +for original, replacement in { + '/opt/hermes-desktop': str(scratch / 'package'), + '/usr/share/hermes-desktop': str(scratch / 'share'), + '/usr/bin/hermes-desktop': str(scratch / 'bin/hermes-desktop'), +}.items(): + script = script.replace(original, replacement) +(scratch / 'installer').write_text(script) +PY + +new_home() { + test_home="$test_tmp/$1" + hermes_home="$test_home/.hermes" + runtime="$hermes_home/hermes-agent" + native="$runtime/apps/desktop/release/linux-unpacked" + mkdir -p "$test_home" + : >"$test_tmp/events" +} +run_installer() { + HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$PATH" \ + bash "$test_tmp/installer" >"$test_tmp/output" 2>&1 +} +assert_stopped() { + if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi +} + +new_home fresh +run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")" +expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --dir "$runtime" --hermes-home "$hermes_home") +[[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments" +[[ $(head -3 "$test_tmp/events") == $'package hermes-desktop\nhandoff\nbootstrap' ]] || fail "package and CLI handoff precede runtime bootstrap" +grep -qx launch "$test_tmp/events" || fail "native app is copied before launch" +[[ $(sed -n '4p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy and precedes launch" +[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "the upstream helper records the completed packaged build" +[[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch" +[[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid" +[[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged" +pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch" + +printf 'user app\n' >"$native/resources/app.asar" +printf 'user build stamp\n' >"$hermes_home/desktop-build-stamp.json" +: >"$test_tmp/events" +run_installer || fail "repeat setup succeeds" "$(cat "$test_tmp/output")" +! grep -qx bootstrap "$test_tmp/events" || fail "repeat setup does not bootstrap again" +! grep -qx build-stamp "$test_tmp/events" || fail "existing app never reruns the build stamp writer" +[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'user build stamp' ]] || fail "existing native build stamp remains unchanged" +[[ $(cat "$native/resources/app.asar") == 'user app' ]] || fail "existing native app remains unchanged" +pass "repeat setup accepts the applied patch and preserves the existing native app" + +# Advancing the runtime must never reinstall the release or reapply its patch. +printf 'new main\n' >"$runtime/runtime.txt" +git -C "$runtime" add runtime.txt +git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update +run_installer || fail "a complete updated runtime and native app are reused" +[[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched" +mv "$native" "$test_tmp/saved-native" +: >"$test_tmp/events" +run_installer && fail "a newer runtime cannot receive an older native app" +[[ ! -e $native ]] || fail "no mismatched native app was copied" +grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "missing newer native app has actionable guidance" +assert_stopped "a missing updated app prevents launch and theme setup" +pass "updated runtimes are preserved and never seeded with the old packaged app" + +new_home dirty-desktop +HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home" +printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js" +: >"$test_tmp/events" +run_installer && fail "modified desktop sources cannot be certified as the packaged build" +[[ ! -e $native && ! -e $hermes_home/desktop-build-stamp.json ]] || fail "modified desktop sources receive neither packaged app nor build stamp" +[[ $(cat "$runtime/apps/desktop/src/main.js") == 'local desktop edit' ]] || fail "desktop source edits are preserved" +grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "modified desktop sources have build guidance" +assert_stopped "modified desktop sources prevent stamping, launch and theme setup" +pass "a matching commit with modified desktop sources is preserved without seeding or stamping" + +for failure in package install marker; do + new_home "$failure-failure" + case "$failure" in + package) OMARCHY_TEST_PACKAGE_FAIL=1 run_installer && fail "package failure stops setup" ;; + install) OMARCHY_TEST_INSTALL_FAIL=1 run_installer && fail "installer failure stops setup" ;; + marker) OMARCHY_TEST_NO_MARKER=1 run_installer && fail "missing marker stops setup" ;; + esac + [[ ! -e $native ]] || fail "failed setup does not seed the app" + assert_stopped "failed setup prevents launch and theme setup" +done +pass "package, upstream installer and readiness failures stop before launch" + +for failure in copy race; do + new_home "$failure-failure" + if [[ $failure == "copy" ]]; then + OMARCHY_TEST_COPY_FAIL=1 run_installer && fail "copy failure stops setup" + [[ ! -e $native ]] || fail "partial copy is never published" + else + OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication" + [[ $(cat "$native/keep") == 'concurrent app' ]] || fail "concurrent native app is preserved" + fi + [[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up" + assert_stopped "publication failure prevents launch" +done +pass "failed copies and concurrent app creation preserve existing work and clean only staging" + +new_home incomplete-native +run_installer || fail "incomplete native fixture sets up" +rm "$native/resources/app.asar" +: >"$test_tmp/events" +run_installer && fail "incomplete existing app requires repair" +[[ ! -e $native/resources/app.asar ]] || fail "incomplete existing app is not overwritten" +assert_stopped "incomplete native app prevents launch" +pass "an incomplete existing native app is preserved" + +new_home patch-conflict +run_installer || fail "patch conflict fixture sets up" +printf 'local edit\n' >"$runtime/runtime.txt" +: >"$test_tmp/events" +run_installer && fail "unexpected patch conflict stops setup" +[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "conflicting runtime changes are preserved" +assert_stopped "patch conflict prevents launch" +rm "$runtime/.hermes-bootstrap-complete" +: >"$test_tmp/events" +run_installer && fail "incomplete modified runtime cannot be reset by upstream installer" +! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer" +pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely" + +new_home custom-profile +hermes_home="$test_home/custom home" +runtime="$hermes_home/hermes-agent" +OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail "profile setup succeeds" +[[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime" +grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer" +pass "custom profile paths normalize to the shared Hermes home" From 827266fbed148a004168fed3eab014e152722914 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 03:39:05 -0500 Subject: [PATCH 2/4] Prepare a consistent Hermes release for its first update Align main with the packaged release and fetch connected history so the updater detects and rebuilds the first update. Guard local branch work before upstream installation, force only the admitted incomplete release pin, preserve existing command files, and explain incompatible packages. Exercise empty-directory publication races and delayed launches. Co-Authored-By: GPT-6 Codex (xhigh) --- bin/omarchy-install-ai-hermes | 45 ++++++++- test/shell.d/hermes-desktop-install-test.sh | 104 +++++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index abb668aa6cd..5bf6b67421c 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -13,6 +13,12 @@ fi echo "Installing Hermes Desktop..." omarchy-pkg-add hermes-desktop +if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] || + ! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then + echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2 + exit 1 +fi + # If Hermes was already installed for the terminal, the app supersedes it: one # machine, one Hermes. This drops that copy so the terminal, the default agent # and the app all end up on the app's installation. @@ -28,13 +34,21 @@ export HERMES_HOME runtime="$HERMES_HOME/hermes-agent" native_app="$runtime/apps/desktop/release/linux-unpacked" -release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json) runtime_ready() { [[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] && timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1 } +check_main() { + local main_commit + main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true) + if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then + echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2 + return 1 + fi +} + if ! runtime_ready; then # The upstream installer can reset an existing checkout. Do not pin a newer # or modified runtime back to the package release while repairing setup. @@ -44,10 +58,29 @@ if ! runtime_ready; then echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2 exit 1 fi + check_main fi + # Upstream replaces these commands, including foreign files and symlinks. + # Keep their original bytes/links before handing the names to the desktop. + command_backup="" + for command in hermes hermes-agent hermes-acp; do + command_path="$HOME/.local/bin/$command" + if [[ -e $command_path || -L $command_path ]]; then + if [[ ! -f $command_path && ! -L $command_path ]]; then + echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&2 + exit 1 + fi + if [[ -z $command_backup ]]; then + command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX") + echo "Saving existing Hermes commands in $command_backup" + fi + cp -a -- "$command_path" "$command_backup/" + fi + done + echo "Setting up the Hermes runtime..." - bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --dir "$runtime" --hermes-home "$HERMES_HOME" + bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME" if ! runtime_ready; then echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2 exit 1 @@ -56,6 +89,14 @@ fi runtime_commit=$(git -C "$runtime" rev-parse HEAD) if [[ $runtime_commit == "$release_commit" ]]; then + # The updater switches to main before checking for changes. Start main at + # the packaged release, with enough history for its first fast-forward. + check_main + if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then + git -C "$runtime" fetch --unshallow origin main + fi + git -C "$runtime" switch -C main "$release_commit" + if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then diff --git a/test/shell.d/hermes-desktop-install-test.sh b/test/shell.d/hermes-desktop-install-test.sh index 4ba8abb8989..d9d342cf303 100644 --- a/test/shell.d/hermes-desktop-install-test.sh +++ b/test/shell.d/hermes-desktop-install-test.sh @@ -38,6 +38,11 @@ release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) printf 'after\n' >"$test_tmp/seed/runtime.txt" git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch" printf 'before\n' >"$test_tmp/seed/runtime.txt" +printf 'newer desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js" +git -C "$test_tmp/seed" add apps/desktop/src/main.js +git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm newer-main +origin_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) +export OMARCHY_TEST_RELEASE_COMMIT="$release_commit" printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json" printf 'packaged app\n' >"$test_tmp/package/resources/app.asar" printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes" @@ -51,21 +56,40 @@ set -e printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events" printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args" [[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7 +commit=$OMARCHY_TEST_RELEASE_COMMIT +force=false while (( $# )); do case "$1" in --dir) runtime=$2; shift ;; + --commit) commit=$2; shift ;; + --force-commit) force=true ;; --hermes-home) [[ $2 == "$HERMES_HOME" ]] ;; esac shift done mkdir -p -- "${runtime%/*}" -if [[ ! -d $runtime ]]; then git clone -q "$OMARCHY_TEST_ROOT/seed" "$runtime"; fi +if [[ ! -d $runtime ]]; then + git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime" +else + git -C "$runtime" checkout -q main + git -C "$runtime" pull -q --ff-only origin main +fi +git -C "$runtime" fetch -q origin "$commit" +if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$commit" HEAD; then + git -C "$runtime" checkout -q --detach "$commit" +fi mkdir -p "$runtime/venv/bin" +git -C "$runtime" rev-parse HEAD >"$runtime/venv/dependency-commit" printf '#!/bin/bash\nexit 0\n' >"$runtime/venv/bin/hermes" chmod +x "$runtime/venv/bin/hermes" printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python" chmod +x "$runtime/venv/bin/python" [[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete" +mkdir -p "$HOME/.local/bin" +for command in hermes hermes-agent hermes-acp; do + rm -f "$HOME/.local/bin/$command" + printf 'native runtime shim\n' >"$HOME/.local/bin/$command" +done MOCK cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK' @@ -73,6 +97,11 @@ cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK' printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" [[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]] MOCK +cat >"$test_tmp/bin/git" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_FETCH_FAIL:-0} == 1 && " $* " == *" --unshallow "* ]]; then exit 8; fi +exec /usr/bin/git "$@" +MOCK cat >"$test_tmp/bin/omarchy-install-hermes-cli" <<'MOCK' #!/bin/bash printf 'handoff\n' >>"$OMARCHY_TEST_ROOT/events" @@ -94,7 +123,6 @@ cat >"$test_tmp/bin/mv" <<'MOCK' #!/bin/bash if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then mkdir -p "${@: -1}" - printf 'concurrent app\n' >"${@: -1}/keep" fi exec /usr/bin/mv "$@" MOCK @@ -106,6 +134,7 @@ exec "$@" MOCK cat >"$test_tmp/bin/hermes-desktop" <<'MOCK' #!/bin/bash +sleep 0.05 native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked" if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events" @@ -162,7 +191,7 @@ assert_stopped() { new_home fresh run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")" -expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --dir "$runtime" --hermes-home "$hermes_home") +expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$hermes_home") [[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments" [[ $(head -3 "$test_tmp/events") == $'package hermes-desktop\nhandoff\nbootstrap' ]] || fail "package and CLI handoff precede runtime bootstrap" grep -qx launch "$test_tmp/events" || fail "native app is copied before launch" @@ -171,6 +200,17 @@ grep -qx launch "$test_tmp/events" || fail "native app is copied before launch" [[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch" [[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid" [[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged" +[[ $(git -C "$runtime" symbolic-ref --short HEAD) == main && $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release rather than the clone tip" +[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "dependencies are installed for the release" +[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "first update has connected history" +# Reproduce the updater's checkout/count/pull sequence while origin stays put. +git clone -q "$runtime" "$test_tmp/first-update" +git -C "$test_tmp/first-update" remote set-url origin "file://$test_tmp/seed" +git -C "$test_tmp/first-update" fetch -q origin main +git -C "$test_tmp/first-update" checkout -q main +[[ $(git -C "$test_tmp/first-update" rev-list HEAD..origin/main --count) == 1 ]] || fail "first update detects work even when origin has not moved since install" +git -C "$test_tmp/first-update" pull -q --ff-only origin main +[[ $(git -C "$test_tmp/first-update" rev-parse HEAD) == "$origin_commit" ]] || fail "first update fast-forwards to origin" pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch" printf 'user app\n' >"$native/resources/app.asar" @@ -187,6 +227,7 @@ pass "repeat setup accepts the applied patch and preserves the existing native a printf 'new main\n' >"$runtime/runtime.txt" git -C "$runtime" add runtime.txt git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update +: >"$test_tmp/events" run_installer || fail "a complete updated runtime and native app are reused" [[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched" mv "$native" "$test_tmp/saved-native" @@ -198,7 +239,7 @@ assert_stopped "a missing updated app prevents launch and theme setup" pass "updated runtimes are preserved and never seeded with the old packaged app" new_home dirty-desktop -HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home" +HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home" printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js" : >"$test_tmp/events" run_installer && fail "modified desktop sources cannot be certified as the packaged build" @@ -227,7 +268,7 @@ for failure in copy race; do [[ ! -e $native ]] || fail "partial copy is never published" else OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication" - [[ $(cat "$native/keep") == 'concurrent app' ]] || fail "concurrent native app is preserved" + [[ -d $native && -z $(ls -A "$native") ]] || fail "concurrent empty app directory is preserved" fi [[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up" assert_stopped "publication failure prevents launch" @@ -256,6 +297,59 @@ run_installer && fail "incomplete modified runtime cannot be reset by upstream i ! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer" pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely" +new_home full-history-retry +git clone -q "$test_tmp/seed" "$runtime" +git -C "$runtime" checkout -q --detach "$release_commit" +run_installer || fail "clean incomplete full-history release checkout is repaired" "$(cat "$test_tmp/output")" +[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "full-history retry pins before installing dependencies" +[[ $(git -C "$runtime" rev-parse HEAD) == "$release_commit" && -f $native/resources/app.asar ]] || fail "full-history retry seeds the matching release" +pass "full-history retries force the guarded release pin before dependency setup" + +new_home local-main +git clone -q "$test_tmp/seed" "$runtime" +printf 'local branch work\n' >"$runtime/keep" +git -C "$runtime" add keep +git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm local-work +local_main=$(git -C "$runtime" rev-parse main) +git -C "$runtime" checkout -q --detach "$release_commit" +run_installer && fail "local main commits cannot be reset by upstream installation" +! grep -qx bootstrap "$test_tmp/events" || fail "local main is checked before upstream installer" +[[ $(git -C "$runtime" rev-parse main) == "$local_main" ]] || fail "local main commit stays referenced" +pass "detached release checkouts do not hide local main work from the installer guard" + +new_home deepen-retry +OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup" +[[ ! -e $native ]] || fail "failed history fetch does not seed the app" +assert_stopped "failed history fetch prevents launch" +: >"$test_tmp/events" +run_installer || fail "history fetch can be retried after runtime setup" "$(cat "$test_tmp/output")" +! grep -qx bootstrap "$test_tmp/events" || fail "history retry does not repeat upstream installation" +pass "a history fetch failure can be retried without reinstalling the ready runtime" + +new_home existing-commands +mkdir -p "$test_home/.local/bin" +printf 'foreign wrapper\n' >"$test_home/.local/bin/hermes" +printf 'symlink target\n' >"$test_home/target" +ln -s "$test_home/target" "$test_home/.local/bin/hermes-agent" +ln -s "$test_home/missing" "$test_home/.local/bin/hermes-acp" +run_installer || fail "existing commands are preserved before upstream replaces them" "$(cat "$test_tmp/output")" +backups=("$test_home/.local/bin/".hermes-before-desktop.*) +[[ ${#backups[@]} == 1 && -d ${backups[0]} ]] || fail "one backup directory preserves existing command names" +[[ $(cat "${backups[0]}/hermes") == 'foreign wrapper' ]] || fail "foreign wrapper bytes are saved" +[[ $(readlink "${backups[0]}/hermes-agent") == "$test_home/target" && $(readlink "${backups[0]}/hermes-acp") == "$test_home/missing" ]] || fail "working and broken symlinks are saved as links" +[[ $(cat "$test_home/target") == 'symlink target' ]] || fail "upstream does not overwrite the original symlink target" +grep -qF "${backups[0]}" "$test_tmp/output" || fail "backup location is reported" +pass "pre-existing command files and symlinks are backed up before replacement" + +new_home old-package +mv "$test_tmp/package/resources/install-stamp.json" "$test_tmp/saved-install-stamp.json" +run_installer && fail "an old installed package cannot bootstrap" +grep -q 'omarchy update' "$test_tmp/output" || fail "old package has actionable upgrade guidance" +! grep -qx handoff "$test_tmp/events" || fail "old package is rejected before CLI handoff" +! grep -qx bootstrap "$test_tmp/events" || fail "old package never reaches upstream installer" +mv "$test_tmp/saved-install-stamp.json" "$test_tmp/package/resources/install-stamp.json" +pass "old package fails with upgrade guidance before changing the runtime or CLI" + new_home custom-profile hermes_home="$test_home/custom home" runtime="$hermes_home/hermes-agent" From 1b4ac8380bc669c3aa813916ae99f153a49a59b4 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 03:56:51 -0500 Subject: [PATCH 3/4] Refuse Hermes removal while its files are in use An open terminal can retain deleted SQLite WAL files across removal and reinstall, causing the updated desktop to refuse session writes. Check user processes before package/runtime removal and again after data confirmation, without killing sessions. Cover the failure with real SQLite writers in isolated fixtures. Co-Authored-By: GPT-6 Codex (xhigh) --- bin/omarchy-remove-ai-hermes | 48 +++++++++ test/shell.d/hermes-remove-test.sh | 157 ++++++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 388746b75fb..da8af34cdc5 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 0a06f03a567..f325b47269b 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 From 9fb73b0051502e725468f943cad2d0ca44356237 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 04:20:13 -0500 Subject: [PATCH 4/4] Hide the upstream Hermes launcher in Omarchy Keep the package launcher visible when Hermes builds register a second desktop entry. Use the existing launcher hide list so in-app updates cannot restore the duplicate menu row. --- default/omarchy/launcher.hides | 1 + test/shell.d/app-search-test.sh | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/default/omarchy/launcher.hides b/default/omarchy/launcher.hides index 113565c7b14..77a57d3b70f 100644 --- a/default/omarchy/launcher.hides +++ b/default/omarchy/launcher.hides @@ -12,6 +12,7 @@ fcitx5-configtool fcitx5-wayland-launcher foot-server footclient +hermes java-java-openjdk jconsole-java-openjdk jshell-java-openjdk diff --git a/test/shell.d/app-search-test.sh b/test/shell.d/app-search-test.sh index d0b6a3d1768..5ebd1cfe3ab 100644 --- a/test/shell.d/app-search-test.sh +++ b/test/shell.d/app-search-test.sh @@ -55,6 +55,14 @@ const entries = [ } ] +// Keep the packaged launcher when upstream rebuilds register their own entry. +const configuredHides = new Set(fs.readFileSync(path.join(root, 'default/omarchy/launcher.hides'), 'utf8').trim().split(/\n/)) +const hermesEntries = [{ name: 'Hermes', id: 'hermes' }, { name: 'Hermes', id: 'hermes-desktop' }] +for (const query of ['', 'hermes']) { + const visible = search.sortedEntries(hermesEntries, query, entry => configuredHides.has(entry.id)) + assertDeepEqual(visible.map(row => row.entry.id), ['hermes-desktop'], 'only the packaged Hermes launcher is visible') +} + const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')