diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9fc270f3..9c359c5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,9 @@ jobs: with: persist-credentials: false + - name: Test Hermes installation and launch + run: python3 test/hermes-desktop-test.py + # An Arch container for vercmp: version ordering has to be decided by # the same comparator pacman uses on users' machines. - name: Run self-tests diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 472c804c..f00eee30 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -1,135 +1,53 @@ # Maintainer: David Heinemeier Hansson -# Nous builds Hermes Desktop for macOS and Windows only -- their download page -# offers a .dmg and an .exe and tells Linux users to install from a terminal -- -# so there is no vendor binary to repackage. Their electron-builder config does -# carry a Linux target, though, and it works; this builds it. -# -# The app only runs against a Hermes runtime built from its own commit, so it -# provisions one itself under ~/.hermes on first launch and the package stays -# on the newest tag. Pairing it with the mise CLI instead was tried and does -# not work: PyPI trails the tags, and the version gap fails the readiness probe -# with a 401. Pinning back to the tag behind PyPI's release does not rescue it -# either -- v2026.7.20's desktop hangs after "backend is ready" without ever -# opening a window, against its own matching runtime. -# -# The app is only a shell: it runs `hermes serve` against a Hermes CLI it does -# not ship, and clones its own copy with the upstream install script when it -# finds none. /usr/bin/hermes-desktop heads that off. See hermes-desktop.sh. - pkgname=hermes-desktop -pkgver=2026.8.18 +pkgver=2026.8.31 pkgrel=2 -pkgdesc='Native desktop shell for Hermes Agent' +pkgdesc='Installer and launcher for the self-updating Hermes desktop' arch=('x86_64') url='https://github.com/NousResearch/hermes-agent' license=('MIT') +# Hermes builds its desktop under the user's runtime so both the app and CLI +# can update it. The compiler and Electron libraries must survive installation. depends=( - 'alsa-lib' - 'at-spi2-core' - 'cairo' - 'dbus' - 'expat' - 'curl' - 'gcc-libs' - 'gdk-pixbuf2' - 'git' - 'glib2' - 'glibc' - 'gtk3' - 'hicolor-icon-theme' - 'libcups' - 'libdrm' - 'libglvnd' - 'libnotify' - 'libsecret' - 'libx11' - 'libxcb' - 'libxcomposite' - 'libxdamage' - 'libxext' - 'libxfixes' - 'libxkbcommon' - 'libxrandr' - 'mesa' - 'nspr' - 'nss' - 'pango' - 'systemd-libs' - 'xdg-utils' + 'alsa-lib' 'at-spi2-core' 'bash' 'cairo' 'curl' 'dbus' 'expat' 'gcc' + 'gdk-pixbuf2' 'git' 'glib2' 'glibc' 'gtk3' 'hicolor-icon-theme' 'libcups' + 'libdrm' 'libglvnd' 'libnotify' 'libsecret' 'libx11' 'libxcb' 'libxcomposite' + 'libxdamage' 'libxext' 'libxfixes' 'libxkbcommon' 'libxrandr' 'make' 'mesa' + 'nodejs' 'npm' 'nspr' 'nss' 'pango' 'python' 'systemd-libs' 'util-linux' + 'xdg-terminal-exec' 'xdg-utils' ) +makedepends=('imagemagick') -optdepends=('omarchy: installs the Hermes CLI the app needs on first launch') - -# The build runs the repo's own npm workspace install, which fetches Electron -# and rebuilds node-pty against it. -makedepends=('git' 'imagemagick' 'nodejs' 'npm' 'python') - -# Electron bundles prebuilt binaries that stripping corrupts. -options=('!strip' '!debug') - -# The tag's commit. apps/desktop/scripts/write-build-stamp.mjs pins the app's -# first-launch bootstrap to a Hermes commit, and resolves it from $GITHUB_SHA -# before falling back to `git rev-parse`. That fallback is wrong here: makepkg -# builds inside this repository, so git ascends out of srcdir and stamps the -# app with an omarchy-pkgs commit that means nothing upstream. -_commit=e624e9fde561e1add9388384012b295fde669ade - -_srcdir="hermes-agent-${pkgver}" -source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" +# Snapshot the bootstrap installer, including upstream's Linux sandbox fixes. +# Only installation uses it; updates belong to the native runtime on main. +_commit=245e48008fa814b3251f50755eb656bd9fb86cb1 +source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/scripts/install.sh" + "LICENSE-${_commit}::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/LICENSE" 'hermes-desktop.sh' 'hermes-desktop.desktop' 'hermes-desktop.png') -sha256sums=('1e3d39d3638ec15fa9d31af262568a953e9272090deb1c50c44cd401175f5b80' - 'f5833b969ce451aadee9f08d92db55cce3c7c9213175080590bf37444854d676' - '3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4' +sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' + '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' + '5113ba692fe00b0f4806f799ce16b85a44bb2bd901ca17577ba64c887b1fb33d' + 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') -build() { - cd "${srcdir}/${_srcdir}" - - export GITHUB_SHA="${_commit}" - export GITHUB_REF_NAME="v${pkgver}" - - # The desktop workspace resolves against the repo root, so the install has to - # happen there rather than in apps/desktop. - npm ci - - cd apps/desktop - npm run pack -} - package() { - cd "${srcdir}/${_srcdir}/apps/desktop/release/linux-unpacked" - - install -dm755 "${pkgdir}/opt/${pkgname}" - cp -a . "${pkgdir}/opt/${pkgname}/" - + install -Dm644 "${srcdir}/install-${_commit}.sh" "${pkgdir}/usr/share/${pkgname}/install.sh" + install -Dm644 "${srcdir}/LICENSE-${_commit}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}" - install -Dm644 "${srcdir}/hermes-desktop.desktop" \ - "${pkgdir}/usr/share/applications/${pkgname}.desktop" - - install -Dm644 "${srcdir}/hermes-desktop.png" \ - "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" + # Native registration uses hermes.desktop but omits URI handling. Keep the + # original handler ID hidden, and let the native entry replace the visible one. + install -Dm644 "${srcdir}/hermes-desktop.desktop" "${pkgdir}/usr/share/applications/hermes-desktop.desktop" + printf 'NoDisplay=true\n' >>"${pkgdir}/usr/share/applications/hermes-desktop.desktop" + sed '/^MimeType=/d' "${srcdir}/hermes-desktop.desktop" >"${pkgdir}/usr/share/applications/hermes.desktop" + install -Dm644 "${srcdir}/hermes-desktop.png" "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" local size for size in 512 256 128 64 48; do magick "${srcdir}/hermes-desktop.png" -resize "${size}x${size}" "${srcdir}/icon-${size}.png" - install -Dm644 "${srcdir}/icon-${size}.png" \ - "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png" + install -Dm644 "${srcdir}/icon-${size}.png" "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png" done - - install -Dm644 "${srcdir}/${_srcdir}/LICENSE" \ - "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" - install -Dm644 "${pkgdir}/opt/${pkgname}/LICENSE.electron.txt" \ - "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE.electron.txt" - - # Chromium's setuid sandbox is only needed where unprivileged user namespaces - # are unavailable; where they work, setuid root is the worse of the two. - if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then - chmod 4755 "${pkgdir}/opt/${pkgname}/chrome-sandbox" - else - chmod 0755 "${pkgdir}/opt/${pkgname}/chrome-sandbox" - fi } diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.desktop b/pkgbuilds/hermes-desktop/hermes-desktop.desktop index 8186b658..b156fcbb 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.desktop +++ b/pkgbuilds/hermes-desktop/hermes-desktop.desktop @@ -4,7 +4,7 @@ Type=Application Name=Hermes GenericName=AI Agent Comment=The self-improving AI agent that grows with you -Exec=hermes-desktop %U +Exec=/usr/bin/hermes-desktop %U Icon=hermes-desktop Terminal=false Categories=Development; diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 5c4d35ef..e8a17166 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -1,41 +1,246 @@ #!/bin/bash set -euo pipefail -# Hermes Desktop is a shell around a Hermes runtime, and it only works against -# one built from its own commit. A CLI from PyPI is always a different release -# -- PyPI trails the tags -- and the mismatch fails the app's readiness probe -# with 401 Unauthorized. So keep it away from whatever `hermes` is on PATH, -# which on Omarchy is the mise CLI installed for the terminal agent, and let -# the app provision and manage its own runtime under ~/.hermes. That is the -# arrangement upstream ships, and the only one that starts. -export HERMES_DESKTOP_IGNORE_EXISTING=1 +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +HERMES_HOME=$(realpath -ms "$HERMES_HOME") +root="$HERMES_HOME/hermes-agent" +marker="$root/.omarchy-hermes-desktop" +installer=/usr/share/hermes-desktop/install.sh +cli=("$root/venv/bin/python" "$root/hermes") -# Chromium cannot reliably infer the Secret Service password-store backend -# from a Hyprland session, even when GNOME Keyring is already providing it. -# Keep an explicit user choice (such as KWallet), otherwise select the -# libsecret backend that this package depends on. -export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" +die() { + echo "Hermes: $*" >&2 + exit 1 +} -# Reconcile every launch rather than trusting whatever installed us. A plain -# `pacman -S hermes-desktop`, or an install interrupted partway, leaves any -# Hermes the terminal agent had built still sitting there, and by then the -# menu entry that would have tidied it up is disabled because we are present. -if command -v omarchy-install-hermes-cli >/dev/null 2>&1; then - omarchy-install-hermes-cli >/dev/null 2>&1 || true +desktop_executable() { + local executable + for executable in "$root/apps/desktop/release/linux-unpacked/"{Hermes,hermes}; do + if [[ -f $executable && -x $executable ]]; then + printf '%s\n' "$executable" + return 0 + fi + done + return 1 +} + +native_wrapper() { + local wrapper=$1 entry=$2 suffix=${3:-} + [[ -f $wrapper && ! -L $wrapper ]] && cmp -s "$wrapper" <( + printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix" + ) +} + +runtime_ready() { + [[ -f $root/.hermes-bootstrap-complete && -x ${cli[0]} ]] && + [[ -x $HOME/.local/bin/hermes ]] && + native_wrapper "$HOME/.local/bin/hermes" hermes && + desktop_executable >/dev/null && + timeout 15 env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" --version >/dev/null 2>&1 +} + +ready() { + if [[ -f $marker ]]; then + grep -qxF ready "$marker" || return 1 + fi + runtime_ready +} + +restore_launchers() { + local command wrapper entry + for command in hermes hermes-agent hermes-acp; do + [[ -f $install_backup/$command ]] || continue + wrapper="$HOME/.local/bin/$command" + entry=hermes + [[ $command == "hermes-agent" ]] && entry=run_agent.py + if [[ ! -e $wrapper && ! -L $wrapper ]] || native_wrapper "$wrapper" "$entry" || + { [[ -f $wrapper && ! -L $wrapper ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; } || + { [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; }; then + cp -p "$install_backup/$command" "$wrapper" + else + echo "Keeping changed $wrapper; its original is in $install_backup." >&2 + return + fi + done + rm -rf "$install_backup" +} + +stage() { + bash "$installer" --dir "$root" --branch main --non-interactive --stage "$1" +} + +install_desktop() { + (( EUID != 0 )) || die "Run this installer as your desktop user, without sudo." + mkdir -p "$HERMES_HOME" + exec 9>"$HERMES_HOME/.omarchy-hermes-desktop.lock" + flock -n 9 || die "Another Hermes installation is already running." + if ready; then + exec 9>&- + return + fi + + # Upstream replaces these launchers. Admit its exact shims or Omarchy's + # marked predecessor before starting work, never a user's custom command. + local command wrapper entry + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + [[ -e $wrapper || -L $wrapper ]] || continue + entry=hermes + [[ $command == "hermes-agent" ]] && entry=run_agent.py + if native_wrapper "$wrapper" "$entry"; then continue; fi + if [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; then continue; fi + if [[ $command == "hermes" && -f $wrapper && ! -L $wrapper ]] && + grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; then continue; fi + die "Keeping the existing $wrapper. Move or update that installation before installing Hermes Desktop." + done + + # The native prerequisite stage may install Node's three user symlinks. + for command in node npm npx; do + wrapper="$HOME/.local/bin/$command" + if [[ -e $wrapper || -L $wrapper ]] && + [[ ! -L $wrapper || $(readlink "$wrapper") != "$HERMES_HOME/node/bin/$command" ]]; then + die "Keeping the existing $wrapper. Move it before installing Hermes Desktop." + fi + done + + local legacy=false + if [[ -e $root || -L $root ]]; then + [[ -d $root/.git && ! -L $root && ! -L $root/.git ]] || die "Keeping $root; it is not a managed checkout." + if [[ -f $marker ]] && grep -qxF updating "$marker"; then + legacy=true + elif [[ ! -f $marker ]]; then + python -I - "$root/.hermes-bootstrap-complete" <<'PY' || die "Keeping the existing checkout; install its desktop with 'hermes desktop'." +import json, sys +try: + stamp = json.load(open(sys.argv[1])) + assert stamp.get('desktopVersion') and stamp.get('pinnedCommit') in { + 'e624e9fde561e1add9388384012b295fde669ade', + '29112bef099274229cadff79cdff7bf7b99c4b77', + } +except (OSError, ValueError, AssertionError, AttributeError): + sys.exit(1) +PY + legacy=true + fi + [[ -z $(git -C "$root" status --porcelain) ]] || die "Keeping local changes in $root. Save them before installing the desktop." + case "$(git -C "$root" remote get-url origin)" in + https://github.com/NousResearch/hermes-agent.git | git@github.com:NousResearch/hermes-agent.git) ;; + *) die "Keeping the checkout's custom origin. Install its desktop with 'hermes desktop'." ;; + esac + if [[ $legacy == true ]]; then + case "$(git -C "$root" rev-parse HEAD)" in + e624e9fde561e1add9388384012b295fde669ade | 29112bef099274229cadff79cdff7bf7b99c4b77) ;; + *) + if [[ $(git -C "$root" symbolic-ref --short -q HEAD) != "main" ]] || + ! git -C "$root" merge-base --is-ancestor HEAD refs/remotes/origin/main; then + die "The legacy checkout has moved. Run 'hermes update', then 'hermes desktop'." + fi + ;; + esac + fi + fi + + install_backup=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-launchers.XXXXXX") + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + if [[ -f $wrapper ]]; then cp -p "$wrapper" "$install_backup/$command"; fi + done + trap restore_launchers EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + local mise_predecessor=false + if [[ -f $install_backup/hermes ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$install_backup/hermes"; then + mise_predecessor=true + fi + + if [[ $legacy == true ]]; then + if [[ $mise_predecessor == true ]]; then + printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor" + fi + if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then + printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" + fi + printf 'updating\n' >"$marker" + local refspec='+refs/heads/main:refs/remotes/origin/main' + if ! git -C "$root" config --get-all remote.origin.fetch | grep -qxF "$refspec"; then + git -C "$root" config --add remote.origin.fetch "$refspec" + fi + git -C "$root" fetch origin "$refspec" + env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" update --yes --branch main + elif [[ ! -e $root ]]; then + stage prerequisites + stage repository + printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" + printf 'pending\n' >"$marker" + fi + + if [[ $mise_predecessor == true ]]; then + printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor" + fi + + if [[ $legacy == true ]]; then + env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" desktop --build-only + else + for command in venv python-deps node-deps config desktop; do stage "$command"; done + fi + + # Build first: a failed download leaves the old terminal CLI usable. + stage complete + stage path + runtime_ready || die "The CLI or desktop is not ready. See the installer output above." + if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then + printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" + fi + printf 'ready\n' >"$marker" + trap - EXIT INT TERM + rm -rf "$install_backup" + exec 9>&- + if [[ -f $root/.git/omarchy-mise-predecessor ]] && command -v omarchy-install-hermes-cli >/dev/null 2>&1; then + omarchy-install-hermes-cli || echo 'Run omarchy-install-hermes-cli again to finish the old CLI cleanup.' >&2 + fi + echo "Hermes is ready. Update it in the app or with 'hermes update'." +} + +case "${1:-}" in + --check) ready; exit ;; + --install) install_desktop; exit ;; + --setup) + shift + if /usr/bin/hermes-desktop --install; then + setsid --fork /usr/bin/hermes-desktop "$@" >/dev/null 2>&1 + exit + else + status=$? + echo "Hermes installation failed. Retry with: hermes-desktop --install" >&2 + if [[ -t 0 ]]; then read -r -p 'Press Enter to close.'; fi + exit "$status" + fi + ;; +esac + +if ! ready; then + if [[ -t 0 && -t 1 ]]; then + install_desktop + else + exec xdg-terminal-exec /usr/bin/hermes-desktop --setup "$@" + fi fi -# Chromium's own Ozone detection falls back to XWayland often enough to matter, -# and the result is a blurry window on every scaled display. Ask for Wayland -# directly, unless the user has already picked a platform themselves. -platform_flags=() -if [[ -n "${WAYLAND_DISPLAY:-}" || ${XDG_SESSION_TYPE:-} == wayland ]]; then - platform_flags=(--ozone-platform=wayland) +export HERMES_DESKTOP_HERMES_ROOT="$root" +export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" +unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME +flags=() +if [[ -n ${WAYLAND_DISPLAY:-} || ${XDG_SESSION_TYPE:-} == "wayland" ]]; then + flags=(--ozone-platform=wayland) for flag in "$@"; do - case "$flag" in - --ozone-platform=* | --ozone-platform-hint=*) platform_flags=() ;; - esac + case "$flag" in --ozone-platform=* | --ozone-platform-hint=*) flags=() ;; esac done fi -exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@" +# Disable only the unusable setuid helper; Chromium keeps its namespace sandbox. +if unshare --user --map-root-user true 2>/dev/null; then + flags+=(--disable-setuid-sandbox) +fi +exec "$(desktop_executable)" "${flags[@]}" "$@" diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py new file mode 100644 index 00000000..07b3b581 --- /dev/null +++ b/test/hermes-desktop-test.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Exercise the packaged entry point with a disposable HOME and installer.""" +import json +import os +import signal +import time +from pathlib import Path +import subprocess +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[1] / 'pkgbuilds/hermes-desktop/hermes-desktop.sh' +INSTALLER = r'''#!/bin/bash +set -eu +while (( $# )); do + case $1 in --dir) root=$2; shift;; --stage) stage=$2; shift;; esac + shift +done +printf '%s\n' "$stage" >> "$TEST_LOG" +[[ ${FAIL_STAGE:-} != "$stage" ]] || exit 42 +case $stage in +repository) + mkdir -p "$root/venv/bin" + git init -q "$root" + git -C "$root" remote add origin https://github.com/NousResearch/hermes-agent.git + touch "$root/hermes" + cp "$TEST_PYTHON" "$root/venv/bin/python" + git -C "$root" add hermes venv + git -C "$root" -c user.email=test@example.com -c user.name=Test commit -qm initial + ;; +desktop) + mkdir -p "$root/apps/desktop/release/linux-unpacked" + cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes" + printf '/apps/\n/.hermes-bootstrap-complete\n' >> "$root/.git/info/exclude" + ;; +complete) touch "$root/.hermes-bootstrap-complete";; +path) + mkdir -p "$HOME/.local/bin" + for command in hermes hermes-agent hermes-acp; do + entry=hermes; suffix='' + [[ $command != hermes-agent ]] || entry=run_agent.py + [[ $command != hermes-acp ]] || suffix=' acp' + printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix" > "$HOME/.local/bin/$command" + chmod +x "$HOME/.local/bin/$command" + done + [[ ${FAIL_AFTER_PATH:-} != 1 ]] || exit 43 + ;; +esac +''' + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='hermes-package-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.home = self.base / 'home with spaces' + self.home.mkdir() + self.root = self.home / '.hermes/hermes-agent' + self.bin = self.base / 'bin' + self.bin.mkdir() + self.log = self.base / 'stages' + self.output = self.base / 'launch.json' + self.env = dict(os.environ, HOME=str(self.home), HERMES_HOME=str(self.home / '.hermes'), + PATH=f'{self.bin}:/usr/bin:/bin', TEST_LOG=str(self.log), + TEST_GUI=str(self.base / 'gui'), TEST_PYTHON=str(self.base / 'python'), + TEST_OUTPUT=str(self.output), GIT_CONFIG_NOSYSTEM='1', + GIT_CONFIG_GLOBAL='/dev/null') + for k in ['FAIL_STAGE', 'FAIL_AFTER_PATH', 'PYTHONHOME', 'PYTHONPATH']: + self.env.pop(k, None) + self.write(self.base / 'installer', INSTALLER) + self.write(self.base / 'python', '#!/bin/bash\n[[ ${FAIL_READY:-} != 1 ]]\n') + self.write(self.base / 'gui', '''#!/usr/bin/env python3 +import json,os,sys,time +json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) +if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) +''') + self.write(self.bin / 'unshare', '#!/bin/bash\nexit 0\n') + self.write(self.bin / 'xdg-terminal-exec', '#!/bin/bash\nprintf "%s\\n" "$@" > "$TEST_OUTPUT"\n') + source = SOURCE.read_text().replace('/usr/share/hermes-desktop/install.sh', str(self.base / 'installer')) + source = source.replace('/usr/bin/hermes-desktop', str(self.base / 'launcher')) + self.write(self.base / 'launcher', source) + + def write(self, path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(0o755) + + def run_launcher(self, *args, ok=True, **env): + result = subprocess.run(['bash', str(self.base / 'launcher'), *args], env=self.env | env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self.assertEqual(result.returncode == 0, ok, result.stdout) + return result + + def install(self): + self.run_launcher('--install') + self.run_launcher('--check') + + def test_fresh_install_publishes_cli_after_desktop(self): + self.install() + stages = self.log.read_text().splitlines() + self.assertLess(stages.index('desktop'), stages.index('path')) + self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n') + self.assertEqual(subprocess.check_output(['git','-C',str(self.root),'status','--porcelain'],env=self.env),b'') + + def test_warm_launch_and_package_reinstall_leave_runtime_untouched(self): + self.install() + before = self.log.read_bytes() + self.run_launcher('--install') + self.run_launcher('hermes://test?a=b', 'two words', WAYLAND_DISPLAY='wayland-1', ELECTRON_RUN_AS_NODE='1') + observed = json.loads(self.output.read_text()) + self.assertEqual(observed['args'], ['--ozone-platform=wayland','--disable-setuid-sandbox','hermes://test?a=b','two words']) + self.assertEqual(observed['root'],str(self.root)) + self.assertIsNone(observed['node']) + self.assertEqual(observed['password'],'gnome-libsecret') + self.assertEqual(before,self.log.read_bytes()) + + def test_explicit_platform_and_password_are_preserved(self): + self.install() + self.run_launcher('--ozone-platform=x11', WAYLAND_DISPLAY='wayland-1', HERMES_DESKTOP_PASSWORD_STORE='kwallet6') + result=json.loads(self.output.read_text()) + self.assertNotIn('--ozone-platform=wayland',result['args']) + self.assertEqual(result['password'],'kwallet6') + + def test_foreign_command_and_symlink_are_preserved(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\necho custom\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False) + self.assertEqual(before,wrapper.read_bytes()) + self.assertFalse(self.log.exists()) + wrapper.unlink() + wrapper.symlink_to(self.base/'missing') + self.run_launcher('--install',ok=False) + self.assertTrue(wrapper.is_symlink()) + + def test_customized_native_wrapper_is_preserved(self): + self.install() + wrapper=self.home/'.local/bin/hermes' + wrapper.write_text(wrapper.read_text().replace('unset PYTHONPATH','export CUSTOM=yes\nunset PYTHONPATH')) + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False) + self.assertEqual(wrapper.read_bytes(),before) + + def test_failed_build_preserves_old_cli_and_retry_succeeds(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False,FAIL_STAGE='desktop') + self.assertEqual(wrapper.read_bytes(),before) + self.run_launcher('--check',ok=False) + self.install() + self.assertNotEqual(wrapper.read_bytes(),before) + + def test_late_path_failure_restores_old_cli(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False,FAIL_AFTER_PATH='1') + self.assertEqual(wrapper.read_bytes(),before) + self.run_launcher('--check',ok=False) + self.install() + + def test_dirty_pending_checkout_is_preserved(self): + self.run_launcher('--install',ok=False,FAIL_STAGE='desktop') + (self.root/'hermes').write_text('user changes') + before=self.log.read_bytes() + self.run_launcher('--install',ok=False) + self.assertEqual((self.root/'hermes').read_text(),'user changes') + self.assertEqual(self.log.read_bytes(),before) + + def test_cold_graphical_launch_opens_visible_setup(self): + self.run_launcher('hermes://open') + self.assertEqual(self.output.read_text().splitlines(),[str(self.base/'launcher'),'--setup','hermes://open']) + self.assertFalse(self.log.exists()) + + def test_unexecutable_native_cli_is_repaired(self): + self.install() + wrapper=self.home/'.local/bin/hermes' + wrapper.chmod(0o644) + self.run_launcher('--check',ok=False) + self.install() + self.assertTrue(os.access(wrapper,os.X_OK)) + + def test_old_cli_ownership_survives_package_only_setup(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + self.install() + receipt=self.root/'.git/omarchy-mise-predecessor' + self.assertEqual(receipt.read_text(),'pipx:hermes-agent[extras=all]\n') + + def test_graphical_setup_detaches_and_releases_install_lock(self): + started=time.monotonic() + self.run_launcher('--setup', 'hermes://open', TEST_GUI_WAIT='1') + self.assertLess(time.monotonic()-started,5) + for _ in range(100): + if self.output.exists(): break + time.sleep(.02) + observed=json.loads(self.output.read_text()) + self.addCleanup(lambda: os.kill(observed['pid'],signal.SIGTERM)) + self.assertIn('hermes://open',observed['args']) + self.run_launcher('--install') + lock=self.home/'.hermes/.omarchy-hermes-desktop.lock' + self.assertEqual(subprocess.run(['flock','--nonblock',str(lock),'true']).returncode,0) + + def test_pending_marker_overrides_old_completion(self): + self.install() + (self.root/'.omarchy-hermes-desktop').write_text('pending\n') + self.run_launcher('--check',ok=False) + +if __name__=='__main__': + unittest.main()