From aa3868b345256602d267eb2d2d43240511e412c0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 02:55:06 -0500 Subject: [PATCH] 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 fb1411c0..abb668aa 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 00000000..4ba8abb8 --- /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"