From 3356e8c04ce0f505a5f794fd213fcc3c25be5309 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 02:01:52 -0500 Subject: [PATCH 1/4] Restore packaged Hermes Desktop at 2026.8.31-2 Revert the native installer and updater packaging introduced by #325. Keep the prebuilt desktop and existing launcher, updating only the release tag, commit and archive checksum from the previous recipe. --- .github/workflows/test.yml | 3 - pkgbuilds/hermes-desktop/PKGBUILD | 107 ++++-- .../hermes-desktop/hermes-desktop.desktop | 2 +- pkgbuilds/hermes-desktop/hermes-desktop.sh | 287 ++------------ test/hermes-desktop-test.py | 349 ------------------ 5 files changed, 107 insertions(+), 641 deletions(-) delete mode 100644 test/hermes-desktop-test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c359c5..9fc270f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,9 +16,6 @@ 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 5783f0b..e00398a 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -1,24 +1,38 @@ # 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.31 -pkgrel=1 -pkgdesc='Installer and launcher for the self-updating Hermes desktop' +pkgrel=2 +pkgdesc='Native desktop shell for Hermes Agent' 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' - 'bash' 'cairo' - 'curl' 'dbus' 'expat' - 'gcc' + 'curl' + 'gcc-libs' 'gdk-pixbuf2' 'git' 'glib2' @@ -38,49 +52,65 @@ depends=( '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') -# 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" +# 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=29112bef099274229cadff79cdff7bf7b99c4b77 + +_srcdir="hermes-agent-${pkgver}" +source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" 'hermes-desktop.sh' 'hermes-desktop.desktop' 'hermes-desktop.png') -sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' - '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' - '27040084e691421dc1748e7646ae7ee79d5777e8ee1e1ec7581a68432d4a29c4' - 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' +sha256sums=('78fb3ff707ec1d17044b875ecac8bef28aa39d44242824f6871ca40afe7bf217' + 'f5833b969ce451aadee9f08d92db55cce3c7c9213175080590bf37444854d676' + '3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4' '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() { - install -Dm644 "${srcdir}/install-${_commit}.sh" \ - "${pkgdir}/usr/share/${pkgname}/install.sh" - install -Dm644 "${srcdir}/LICENSE-${_commit}" \ - "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + cd "${srcdir}/${_srcdir}/apps/desktop/release/linux-unpacked" + + install -dm755 "${pkgdir}/opt/${pkgname}" + cp -a . "${pkgdir}/opt/${pkgname}/" + install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}" - # 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" + "${pkgdir}/usr/share/applications/${pkgname}.desktop" + install -Dm644 "${srcdir}/hermes-desktop.png" \ "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" local size @@ -89,4 +119,17 @@ package() { 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 b156fcb..8186b65 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=/usr/bin/hermes-desktop %U +Exec=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 fe00939..5c4d35e 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -1,266 +1,41 @@ #!/bin/bash set -euo pipefail -die() { - echo "Hermes: $*" >&2 - exit 1 -} +# 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 -HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}") -# A profile session shares its parent home's installation and launchers. -home_parent=${HERMES_HOME%/*} -if [[ ${home_parent##*/} == "profiles" ]]; then - HERMES_HOME=${home_parent%/*} -fi -[[ $HERMES_HOME == /* && $HERMES_HOME != "/" ]] || die "Use a Hermes data directory other than /." -export 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") - -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 "${2:-$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 - # Publish only a complete clone with its ownership marker. An interrupted - # clone stays aside for inspection and cannot block the next installation. - local repository_dir - repository_dir=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-repository.XXXXXX") - echo "Preparing the Hermes checkout in $repository_dir" - stage repository "$repository_dir/hermes-agent" - printf '/.omarchy-hermes-desktop\n' >>"$repository_dir/hermes-agent/.git/info/exclude" - printf 'pending\n' >"$repository_dir/hermes-agent/.omarchy-hermes-desktop" - mv -T --no-clobber "$repository_dir/hermes-agent" "$root" - [[ ! -e $repository_dir/hermes-agent ]] || die "Keeping the checkout that appeared at $root during installation." - rmdir "$repository_dir" - fi - - if [[ $mise_predecessor == true ]]; then - printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor" - fi - - if [[ $legacy == false ]]; then - for command in venv python-deps node-deps config; do stage "$command"; done - fi - # The native builder also handles updates and supports the namespace - # sandbox. The shell installer's desktop stage still requires a sudo chown. - # The build registers a desktop entry, too. Keep it private until the new - # CLI is published so a different hermes on PATH cannot become its target. - env -u PYTHONPATH -u PYTHONHOME XDG_DATA_HOME="$install_backup/desktop" "${cli[@]}" desktop --build-only - - # 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." - env -u PYTHONPATH -u PYTHONHOME PATH="$HOME/.local/bin:$PATH" "${cli[@]}" desktop --skip-build --build-only - 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 - -export HERMES_DESKTOP_HERMES_ROOT="$root" +# 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}" -# Upstream desktop registration resolves its launcher through PATH. -export PATH="$HOME/.local/bin:$PATH" -unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME -flags=() -if [[ -n ${WAYLAND_DISPLAY:-} || ${XDG_SESSION_TYPE:-} == "wayland" ]]; then - flags=(--ozone-platform=wayland) +# 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 +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) + for flag in "$@"; do - case "$flag" in --ozone-platform=* | --ozone-platform-hint=*) flags=() ;; esac + case "$flag" in + --ozone-platform=* | --ozone-platform-hint=*) platform_flags=() ;; + esac done fi -# 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[@]}" "$@" +exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@" diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py deleted file mode 100644 index d4f60c2..0000000 --- a/test/hermes-desktop-test.py +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise the packaged entry point with a disposable HOME and installer.""" -import json -import os -import pty -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" - [[ ${FAIL_PARTIAL_CLONE:-} != 1 ]] || exit 44 - 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 - if [[ ${RACE_CLONE:-} == 1 ]]; then - mkdir -p "$HOME/.hermes/hermes-agent" - printf 'foreign checkout\n' > "$HOME/.hermes/hermes-agent/keep" - fi - ;; -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'), - XDG_DATA_HOME=str(self.home / '.local/share'), - XDG_CONFIG_HOME=str(self.home / '.config'), XDG_CACHE_HOME=str(self.home / '.cache'), - 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 -set -eu -if [[ ${2:-} == desktop ]]; then - root=$(dirname "$1") - if [[ ${3:-} == --skip-build ]]; then - [[ ${4:-} == --build-only && -x $root/apps/desktop/release/linux-unpacked/Hermes ]] - [[ ${FAIL_FINAL_REGISTER:-} != 1 ]] || exit 45 - else - [[ ${3:-} == --build-only ]] - printf 'desktop\\n' >> "$TEST_LOG" - [[ ${FAIL_STAGE:-} != desktop ]] || exit 42 - 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" - fi - entry_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications" - mkdir -p "$entry_dir" - printf 'Exec=%s desktop\\n' "$(command -v hermes || printf '%s/venv/bin/python -m hermes_cli.main' "$root")" > "$entry_dir/hermes.desktop" -else - [[ ${FAIL_READY:-} != 1 ]] -fi -''') - 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'],'home':os.environ['HERMES_HOME'],'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_profile_setup_installs_in_machine_home(self): - profile = self.home / '.hermes/profiles/coder' - self.write(profile / 'config.yaml', 'profile data\n') - self.env['HERMES_HOME'] = str(profile) + '/./' - self.install() - self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n') - self.assertEqual(list(profile.iterdir()), [profile / 'config.yaml']) - self.assertEqual((profile / 'config.yaml').read_text(), 'profile data\n') - - def test_profile_warm_check_install_and_launch_share_machine_runtime(self): - self.install() - before = self.log.read_bytes() - self.env['HERMES_HOME'] = str(self.home / '.hermes/profiles/coder') - self.run_launcher('--check') - self.run_launcher('--install') - self.run_launcher('hermes://profile') - observed = json.loads(self.output.read_text()) - self.assertEqual(observed['root'], str(self.root)) - self.assertEqual(observed['home'], str(self.root.parent)) - self.assertEqual(self.log.read_bytes(), before) - - def test_custom_home_profile_uses_its_own_parent_root(self): - custom_home = self.base / 'custom data' - self.root = custom_home / 'hermes-agent' - self.env['HERMES_HOME'] = str(custom_home / 'profiles/coder') - self.install() - self.run_launcher() - observed = json.loads(self.output.read_text()) - self.assertEqual(observed['root'], str(self.root)) - self.assertEqual(observed['home'], str(custom_home)) - self.assertFalse((self.home / '.hermes').exists()) - self.assertFalse((custom_home / 'profiles').exists()) - - def test_custom_home_with_profiles_ancestor_is_preserved(self): - custom_home = self.base / 'profiles/team/custom data' - self.root = custom_home / 'hermes-agent' - self.env['HERMES_HOME'] = str(custom_home) - self.install() - self.run_launcher() - observed = json.loads(self.output.read_text()) - self.assertEqual(observed['root'], str(self.root)) - self.assertEqual(observed['home'], str(custom_home)) - - def test_profile_resolution_preserves_symlink_home_spelling(self): - target = self.base / 'physical data' - target.mkdir() - alias = self.base / 'data alias' - alias.symlink_to(target, target_is_directory=True) - self.root = alias / 'hermes-agent' - self.env['HERMES_HOME'] = str(alias / 'profiles/coder') - self.install() - self.run_launcher() - observed = json.loads(self.output.read_text()) - self.assertEqual(observed['root'], str(self.root)) - self.assertEqual(observed['home'], str(alias)) - self.assertTrue(alias.is_symlink()) - - def test_filesystem_root_home_is_rejected_before_mutation(self): - self.write(self.bin / 'mkdir', '#!/bin/bash\nprintf called >> "$TEST_LOG"\nexit 42\n') - for home in ['/', '/profiles/coder', '/profiles/coder/./']: - with self.subTest(home=home): - result = self.run_launcher('--install', ok=False, HERMES_HOME=home) - self.assertIn('Use a Hermes data directory other than /.', result.stdout) - self.assertFalse(self.log.exists()) - - def test_interrupted_clone_stays_aside_and_retry_succeeds(self): - self.run_launcher('--install', ok=False, FAIL_PARTIAL_CLONE='1') - self.assertFalse(self.root.exists()) - partials=list((self.home/'.hermes').glob('.omarchy-hermes-repository.*/hermes-agent/.git')) - self.assertEqual(len(partials),1) - self.install() - self.assertTrue(partials[0].is_dir()) - - def test_clone_publication_preserves_concurrent_checkout(self): - self.run_launcher('--install', ok=False, RACE_CLONE='1') - self.assertEqual((self.root/'keep').read_text(),'foreign checkout\n') - self.assertFalse((self.root/'.omarchy-hermes-desktop').exists()) - self.assertFalse((self.home/'.local/bin/hermes').exists()) - - def test_desktop_registration_uses_published_cli(self): - self.write(self.bin/'hermes', '#!/bin/bash\necho foreign\n') - self.install() - entry=self.home/'.local/share/applications/hermes.desktop' - self.assertEqual(entry.read_text(),f'Exec={self.home}/.local/bin/hermes desktop\n') - - def test_failed_registration_preserves_previous_launcher(self): - wrapper=self.home/'.local/bin/hermes' - self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') - entry=self.home/'.local/share/applications/hermes.desktop' - self.write(entry,'previous desktop entry\n') - before=wrapper.read_bytes() - self.run_launcher('--install',ok=False,FAIL_FINAL_REGISTER='1') - self.assertEqual(wrapper.read_bytes(),before) - self.assertEqual(entry.read_text(),'previous desktop entry\n') - self.run_launcher('--check',ok=False) - self.install() - - 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_cold_terminal_launch_releases_lock_before_exec(self): - master,slave=pty.openpty() - process=subprocess.Popen(['bash',str(self.base/'launcher')],stdin=slave,stdout=slave,stderr=slave,env=self.env | {'TEST_GUI_WAIT':'1'}) - os.close(slave) - def cleanup(): - if process.poll() is None: process.terminate() - process.wait(timeout=5) - os.close(master) - self.addCleanup(cleanup) - for _ in range(100): - if self.output.exists(): break - time.sleep(.02) - self.assertTrue(self.output.exists()) - self.assertIsNone(process.poll()) - 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() From 9588b28cf6459e65f2e4d930e641bac95031ae1a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 02:55:06 -0500 Subject: [PATCH 2/4] Prepare packaged Hermes for native in-app updates Track main while pinning the initial release commit. Ship the matching upstream installer and Linux namespace sandbox backport, and launch the native user build prepared by Omarchy. Co-Authored-By: GPT-6 Codex (xhigh) --- pkgbuilds/hermes-desktop/PKGBUILD | 53 ++++++++------- pkgbuilds/hermes-desktop/hermes-desktop.sh | 35 +++++++--- pkgbuilds/hermes-desktop/runtime-test.py | 71 +++++++++++++++++++ pkgbuilds/hermes-desktop/runtime.patch | 79 ++++++++++++++++++++++ 4 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 pkgbuilds/hermes-desktop/runtime-test.py create mode 100644 pkgbuilds/hermes-desktop/runtime.patch diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index e00398a..29c9c28 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -1,25 +1,11 @@ # 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. +# Build a prebuilt release; Omarchy seeds it into the user's Hermes checkout +# so the upstream updater can rebuild and relaunch it in place. pkgname=hermes-desktop pkgver=2026.8.31 -pkgrel=2 +pkgrel=3 pkgdesc='Native desktop shell for Hermes Agent' arch=('x86_64') url='https://github.com/NousResearch/hermes-agent' @@ -32,7 +18,7 @@ depends=( 'dbus' 'expat' 'curl' - 'gcc-libs' + 'gcc' 'gdk-pixbuf2' 'git' 'glib2' @@ -52,19 +38,24 @@ depends=( 'libxfixes' 'libxkbcommon' 'libxrandr' + 'make' 'mesa' + 'nodejs' + 'npm' 'nspr' 'nss' 'pango' + 'python' 'systemd-libs' + 'util-linux' 'xdg-utils' ) -optdepends=('omarchy: installs the Hermes CLI the app needs on first launch') +optdepends=('omarchy: sets up the user installation for in-app updates') # 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') +makedepends=('imagemagick') # Electron bundles prebuilt binaries that stripping corrupts. options=('!strip' '!debug') @@ -80,17 +71,21 @@ _srcdir="hermes-agent-${pkgver}" source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" 'hermes-desktop.sh' 'hermes-desktop.desktop' - 'hermes-desktop.png') + 'hermes-desktop.png' + 'runtime.patch' + 'runtime-test.py') sha256sums=('78fb3ff707ec1d17044b875ecac8bef28aa39d44242824f6871ca40afe7bf217' - 'f5833b969ce451aadee9f08d92db55cce3c7c9213175080590bf37444854d676' + '93540bbd8e3fccd132546c3e8f7da16eb04e67c20bbfe84034a2736c00e6d49d' '3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4' - 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') + 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52' + 'cd544605f3b6ed397a829244e4f4d08f2d28a3a2f56edff7d3287b55bdf606b2' + '99caf1ef09c3ac88892ee674ebe92faa916c225fb938fe6796cf04b4e4ac397f') build() { cd "${srcdir}/${_srcdir}" export GITHUB_SHA="${_commit}" - export GITHUB_REF_NAME="v${pkgver}" + export GITHUB_REF_NAME="main" # The desktop workspace resolves against the repo root, so the install has to # happen there rather than in apps/desktop. @@ -100,6 +95,10 @@ build() { npm run pack } +check() { + python "${srcdir}/runtime-test.py" "${srcdir}/${_srcdir}" "${srcdir}/runtime.patch" +} + package() { cd "${srcdir}/${_srcdir}/apps/desktop/release/linux-unpacked" @@ -108,6 +107,12 @@ package() { install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}" + install -Dm644 "${srcdir}/${_srcdir}/scripts/install.sh" \ + "${pkgdir}/usr/share/${pkgname}/install.sh" + # Backport the upstream user-namespace fixes to this release's user runtime. + install -Dm644 "${srcdir}/runtime.patch" "${pkgdir}/usr/share/${pkgname}/runtime.patch" + + install -Dm644 "${srcdir}/hermes-desktop.desktop" \ "${pkgdir}/usr/share/applications/${pkgname}.desktop" diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 5c4d35e..611b04f 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -1,13 +1,7 @@ #!/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. +# Use the runtime prepared by Omarchy rather than a separate CLI on PATH. export HERMES_DESKTOP_IGNORE_EXISTING=1 # Chromium cannot reliably infer the Secret Service password-store backend @@ -38,4 +32,29 @@ if [[ -n "${WAYLAND_DISPLAY:-}" || ${XDG_SESSION_TYPE:-} == wayland ]]; then done fi -exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@" +hermes_home=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}") +parent=${hermes_home%/*} +if [[ ${parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then + hermes_home=${parent%/*} + hermes_home=${hermes_home:-/} +fi +export HERMES_HOME="$hermes_home" +runtime="$hermes_home/hermes-agent" +native="$runtime/apps/desktop/release/linux-unpacked/Hermes" + +if [[ -x $native && -x $runtime/venv/bin/hermes ]]; then + if (( $# == 0 )); then + if (( ${#platform_flags[@]} )); then + export ELECTRON_OZONE_PLATFORM_HINT="${ELECTRON_OZONE_PLATFORM_HINT:-wayland}" + fi + exec "$runtime/venv/bin/hermes" desktop --skip-build + else + # The upstream CLI does not accept Electron arguments or hermes:// URLs. + if unshare --user --map-root-user true 2>/dev/null; then + platform_flags+=(--disable-setuid-sandbox) + fi + exec "$native" "${platform_flags[@]}" "$@" + fi +else + exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@" +fi diff --git a/pkgbuilds/hermes-desktop/runtime-test.py b/pkgbuilds/hermes-desktop/runtime-test.py new file mode 100644 index 0000000..d4987bf --- /dev/null +++ b/pkgbuilds/hermes-desktop/runtime-test.py @@ -0,0 +1,71 @@ +"""Check the release backport against the actual pinned upstream source.""" +import ast +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from typing import Optional +from unittest.mock import patch + +source, patch_file = map(Path, sys.argv[1:]) +with tempfile.TemporaryDirectory(prefix="hermes-runtime-check-") as temporary: + root = Path(temporary) + for filename in ("hermes_cli/main.py", "scripts/desktop-update/posix.sh"): + destination = root / filename + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / filename, destination) + subprocess.run(["git", "apply", str(patch_file.resolve())], cwd=root, check=True) + text = (root / "hermes_cli/main.py").read_text() + functions = {node.name: node for node in ast.parse(text).body if isinstance(node, ast.FunctionDef)} + names = [ + "_desktop_linux_userns_sandbox_available", "_sandbox_helper_lstat", + "_sandbox_helper_is_setuid_root", "_desktop_linux_needs_disable_setuid_sandbox", + "_desktop_linux_sandbox_fixup", + ] + scope = dict(Path=Path, Optional=Optional, os=os, sys=sys, shutil=shutil, + subprocess=subprocess, stat=__import__("stat")) + for name in names: + exec(compile(ast.Module(body=[functions[name]], type_ignores=[]), str(source), "exec"), scope) + + native = root / "apps/desktop/release/linux-unpacked" + native.mkdir(parents=True) + executable = native / "Hermes" + sandbox = native / "chrome-sandbox" + sandbox.write_text("fixture") + sandbox.chmod(0o755) + gui = ast.get_source_segment(text, functions["cmd_gui"]) + start = gui.index(" launch_command = [str(packaged_executable)]") + end = gui.index(" launch_command.extend(config_electron_flags)", start) + exec("def launch(packaged_executable):\n" + gui[start:end] + " return launch_command\n", scope) + scope["_desktop_linux_needs_no_sandbox"] = lambda: False + with patch.object(shutil, "which", side_effect=lambda name: "/fixture/unshare" if name == "unshare" else None): + with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + assert scope["launch"](executable) == [str(executable), "--disable-setuid-sandbox"] + assert all(call.args[0][0] == "/fixture/unshare" for call in run.call_args_list) + with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 1)): + assert not scope["_desktop_linux_sandbox_fixup"](executable) + sandbox.unlink() + sandbox.symlink_to(root / "unrelated") + (root / "unrelated").write_text("keep") + with patch.object(subprocess, "run") as run: + assert not scope["_desktop_linux_sandbox_fixup"](executable) + run.assert_not_called() + sandbox.unlink() + sandbox.write_text("fixture") + + mock_bin = root / "bin" + mock_bin.mkdir() + unshare = mock_bin / "unshare" + unshare.write_text('#!/bin/bash\nexit "${TEST_NAMESPACE_RESULT:-0}"\n') + unshare.chmod(0o755) + env = {**os.environ, "PATH": f"{mock_bin}:/usr/bin:/bin"} + env.pop("ELECTRON_DISABLE_SANDBOX", None) + gate = ["bash", str(root / "scripts/desktop-update/posix.sh"), "--self-test-gate", + "--install-root", str(root), "--relaunch-target", str(executable)] + assert subprocess.check_output(gate, env=env, text=True).strip() == "relaunch" + assert subprocess.check_output(gate, env={**env, "TEST_NAMESPACE_RESULT": "1"}, text=True).startswith("manual:") + gate[-1] = "/opt/hermes-desktop/Hermes" + assert subprocess.check_output(gate, env=env, text=True).startswith("skew:") +print("PASS: native launch and release update handoff retain the user-namespace sandbox") diff --git a/pkgbuilds/hermes-desktop/runtime.patch b/pkgbuilds/hermes-desktop/runtime.patch new file mode 100644 index 0000000..06db82d --- /dev/null +++ b/pkgbuilds/hermes-desktop/runtime.patch @@ -0,0 +1,79 @@ +--- a/hermes_cli/main.py ++++ b/hermes_cli/main.py +@@ -8146,6 +8146,44 @@ + return False + + ++def _desktop_linux_userns_sandbox_available() -> bool: ++ """True when the unprivileged userns sandbox works (probed with ``unshare``, fails closed) — then ++ the setuid ``chrome-sandbox`` helper is never consulted and no sudo prompt is needed.""" ++ if sys.platform != "linux": ++ return False ++ unshare = shutil.which("unshare") ++ if not unshare: ++ return False ++ try: ++ return ( ++ subprocess.run( ++ [unshare, "--user", "--map-root-user", "true"], ++ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, check=False, ++ ).returncode ++ == 0) ++ except (OSError, subprocess.TimeoutExpired): ++ return False ++ ++def _sandbox_helper_lstat(packaged_executable: Path) -> tuple[Path, Optional[os.stat_result]]: ++ """``(chrome-sandbox path, lstat or None)`` — lstat so a symlink is inspected, not followed.""" ++ sandbox = packaged_executable.parent / "chrome-sandbox" ++ try: ++ return sandbox, sandbox.lstat() ++ except OSError: ++ return sandbox, None ++ ++def _sandbox_helper_is_setuid_root(st: os.stat_result) -> bool: ++ return st.st_uid == 0 and stat.S_IMODE(st.st_mode) == 0o4755 ++ ++def _desktop_linux_needs_disable_setuid_sandbox(packaged_executable: Path) -> bool: ++ """True when a present, non-setuid ``chrome-sandbox`` would make Chromium abort with ++ ``setuid_sandbox_host`` despite a working userns sandbox (call after the fixup's userns path).""" ++ if sys.platform != "linux": ++ return False ++ _sandbox, st = _sandbox_helper_lstat(packaged_executable) ++ return st is not None and stat.S_ISREG(st.st_mode) and not _sandbox_helper_is_setuid_root(st) ++ ++ + def _desktop_linux_sandbox_helper_is_regular_file(packaged_executable: Path) -> bool: + """Return True when ``chrome-sandbox`` exists as a regular file.""" + if sys.platform != "linux": +@@ -8182,6 +8220,10 @@ + return False + + if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755: ++ return True ++ ++ if _desktop_linux_userns_sandbox_available(): ++ print("✓ Using Chromium's user-namespace sandbox (setuid helper not needed).") + return True + + sudo = shutil.which("sudo") +@@ -8591,6 +8633,9 @@ + launch_command.append("--no-sandbox") + else: + sys.exit(1) ++ ++ elif _desktop_linux_needs_disable_setuid_sandbox(packaged_executable): ++ launch_command.append("--disable-setuid-sandbox") + + launch_command.extend(config_electron_flags) + print(f"→ Launching packaged Hermes Desktop: {' '.join(launch_command)}") +--- a/scripts/desktop-update/posix.sh ++++ b/scripts/desktop-update/posix.sh +@@ -317,6 +317,8 @@ + if [ ! -e "$sb" ]; then GATE=relaunch; return; fi + if [ -u "$sb" ] && [ "$(stat -c %u "$sb" 2>/dev/null)" = "0" ]; then GATE=relaunch; return; fi + ++ if unshare --user --map-root-user true 2>/dev/null; then GATE=relaunch; return; fi ++ + case "${ELECTRON_DISABLE_SANDBOX:-}" in 1|true|TRUE|True) GATE=relaunch; return ;; esac + [ "$SANDBOX_FALLBACK" -eq 1 ] && { GATE=relaunch; return; } + for arg in ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"}; do From 2fb9ab2ba9ab2a7e4bc150fe52534d117cc9c2a0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 03:17:42 -0500 Subject: [PATCH 3/4] Launch native Hermes without privileged sandbox setup Use the same direct executable path for menu launches and URLs, require working user namespaces, and retain only the first-update relaunch gate backport. This avoids the upstream CLI fallback that makes a helper in the user runtime setuid-root. Co-Authored-By: GPT-6 Codex (xhigh) --- pkgbuilds/hermes-desktop/PKGBUILD | 12 +-- pkgbuilds/hermes-desktop/hermes-desktop.sh | 17 ++-- pkgbuilds/hermes-desktop/runtime-test.py | 104 ++++++++++++--------- pkgbuilds/hermes-desktop/runtime.patch | 68 -------------- 4 files changed, 71 insertions(+), 130 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 29c9c28..441c996 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -5,7 +5,7 @@ pkgname=hermes-desktop pkgver=2026.8.31 -pkgrel=3 +pkgrel=4 pkgdesc='Native desktop shell for Hermes Agent' arch=('x86_64') url='https://github.com/NousResearch/hermes-agent' @@ -75,11 +75,11 @@ source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz 'runtime.patch' 'runtime-test.py') sha256sums=('78fb3ff707ec1d17044b875ecac8bef28aa39d44242824f6871ca40afe7bf217' - '93540bbd8e3fccd132546c3e8f7da16eb04e67c20bbfe84034a2736c00e6d49d' + '7324b1d5e7db16c6169dd9e685f4093925c4a8c0568cb7ce416ac734f3336606' '3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52' - 'cd544605f3b6ed397a829244e4f4d08f2d28a3a2f56edff7d3287b55bdf606b2' - '99caf1ef09c3ac88892ee674ebe92faa916c225fb938fe6796cf04b4e4ac397f') + '03b67e26c234c797a6b1d4c9f34a57502dba37f462540e47ce6c88f6ea79302a' + '69bc57d04610567eca35679328668619fcd3f129ed099bf5e2a1f24d1f197de9') build() { cd "${srcdir}/${_srcdir}" @@ -96,7 +96,7 @@ build() { } check() { - python "${srcdir}/runtime-test.py" "${srcdir}/${_srcdir}" "${srcdir}/runtime.patch" + python "${srcdir}/runtime-test.py" "${srcdir}/${_srcdir}" "${srcdir}/runtime.patch" "${srcdir}/hermes-desktop.sh" } package() { @@ -109,7 +109,7 @@ package() { install -Dm644 "${srcdir}/${_srcdir}/scripts/install.sh" \ "${pkgdir}/usr/share/${pkgname}/install.sh" - # Backport the upstream user-namespace fixes to this release's user runtime. + # Let the release's first updater relaunch with the user-namespace sandbox. install -Dm644 "${srcdir}/runtime.patch" "${pkgdir}/usr/share/${pkgname}/runtime.patch" diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 611b04f..3f96199 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -43,18 +43,13 @@ runtime="$hermes_home/hermes-agent" native="$runtime/apps/desktop/release/linux-unpacked/Hermes" if [[ -x $native && -x $runtime/venv/bin/hermes ]]; then - if (( $# == 0 )); then - if (( ${#platform_flags[@]} )); then - export ELECTRON_OZONE_PLATFORM_HINT="${ELECTRON_OZONE_PLATFORM_HINT:-wayland}" - fi - exec "$runtime/venv/bin/hermes" desktop --skip-build - else - # The upstream CLI does not accept Electron arguments or hermes:// URLs. - if unshare --user --map-root-user true 2>/dev/null; then - platform_flags+=(--disable-setuid-sandbox) - fi - exec "$native" "${platform_flags[@]}" "$@" + # Use the namespace sandbox without asking the CLI to make a user-writable + # helper setuid-root. Keep the same launch path for menu entries and URLs. + if ! timeout 5 unshare --user --map-root-user true 2>/dev/null; then + echo "Hermes Desktop requires working unprivileged user namespaces for its sandbox." >&2 + exit 1 fi + exec "$native" --disable-setuid-sandbox "${platform_flags[@]}" "$@" else exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@" fi diff --git a/pkgbuilds/hermes-desktop/runtime-test.py b/pkgbuilds/hermes-desktop/runtime-test.py index d4987bf..0aa40d5 100644 --- a/pkgbuilds/hermes-desktop/runtime-test.py +++ b/pkgbuilds/hermes-desktop/runtime-test.py @@ -1,71 +1,85 @@ -"""Check the release backport against the actual pinned upstream source.""" -import ast +"""Check native launch and the release updater without running Hermes or sudo.""" +import json import os from pathlib import Path import shutil import subprocess import sys import tempfile -from typing import Optional -from unittest.mock import patch -source, patch_file = map(Path, sys.argv[1:]) +source, patch_file, launcher = map(Path, sys.argv[1:]) with tempfile.TemporaryDirectory(prefix="hermes-runtime-check-") as temporary: root = Path(temporary) - for filename in ("hermes_cli/main.py", "scripts/desktop-update/posix.sh"): - destination = root / filename - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source / filename, destination) + destination = root / "scripts/desktop-update/posix.sh" + destination.parent.mkdir(parents=True) + shutil.copyfile(source / "scripts/desktop-update/posix.sh", destination) subprocess.run(["git", "apply", str(patch_file.resolve())], cwd=root, check=True) - text = (root / "hermes_cli/main.py").read_text() - functions = {node.name: node for node in ast.parse(text).body if isinstance(node, ast.FunctionDef)} - names = [ - "_desktop_linux_userns_sandbox_available", "_sandbox_helper_lstat", - "_sandbox_helper_is_setuid_root", "_desktop_linux_needs_disable_setuid_sandbox", - "_desktop_linux_sandbox_fixup", - ] - scope = dict(Path=Path, Optional=Optional, os=os, sys=sys, shutil=shutil, - subprocess=subprocess, stat=__import__("stat")) - for name in names: - exec(compile(ast.Module(body=[functions[name]], type_ignores=[]), str(source), "exec"), scope) - native = root / "apps/desktop/release/linux-unpacked" + home = root / "home with spaces" + runtime = home / ".hermes/hermes-agent" + native = runtime / "apps/desktop/release/linux-unpacked" native.mkdir(parents=True) executable = native / "Hermes" + executable.write_text(f"#!{sys.executable}\n" + '''import json, os, sys +from pathlib import Path +Path(os.environ["TEST_OUTPUT"]).write_text(json.dumps({ + "args": sys.argv[1:], "home": os.environ["HERMES_HOME"], + "store": os.environ["HERMES_DESKTOP_PASSWORD_STORE"], + "gpu": os.environ.get("HERMES_DESKTOP_DISABLE_GPU"), +})) +''') + executable.chmod(0o755) sandbox = native / "chrome-sandbox" sandbox.write_text("fixture") sandbox.chmod(0o755) - gui = ast.get_source_segment(text, functions["cmd_gui"]) - start = gui.index(" launch_command = [str(packaged_executable)]") - end = gui.index(" launch_command.extend(config_electron_flags)", start) - exec("def launch(packaged_executable):\n" + gui[start:end] + " return launch_command\n", scope) - scope["_desktop_linux_needs_no_sandbox"] = lambda: False - with patch.object(shutil, "which", side_effect=lambda name: "/fixture/unshare" if name == "unshare" else None): - with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: - assert scope["launch"](executable) == [str(executable), "--disable-setuid-sandbox"] - assert all(call.args[0][0] == "/fixture/unshare" for call in run.call_args_list) - with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 1)): - assert not scope["_desktop_linux_sandbox_fixup"](executable) - sandbox.unlink() - sandbox.symlink_to(root / "unrelated") - (root / "unrelated").write_text("keep") - with patch.object(subprocess, "run") as run: - assert not scope["_desktop_linux_sandbox_fixup"](executable) - run.assert_not_called() - sandbox.unlink() - sandbox.write_text("fixture") mock_bin = root / "bin" mock_bin.mkdir() unshare = mock_bin / "unshare" unshare.write_text('#!/bin/bash\nexit "${TEST_NAMESPACE_RESULT:-0}"\n') unshare.chmod(0o755) - env = {**os.environ, "PATH": f"{mock_bin}:/usr/bin:/bin"} - env.pop("ELECTRON_DISABLE_SANDBOX", None) - gate = ["bash", str(root / "scripts/desktop-update/posix.sh"), "--self-test-gate", - "--install-root", str(root), "--relaunch-target", str(executable)] + forbidden = '#!/bin/bash\ntouch "$TEST_FORBIDDEN"\nexit 99\n' + cli = runtime / "venv/bin/hermes" + cli.parent.mkdir(parents=True) + cli.write_text(forbidden) + cli.chmod(0o755) + for command in ("sudo", "omarchy-install-hermes-cli"): + target = mock_bin / command + target.write_text(forbidden if command == "sudo" else '#!/bin/bash\nexit 0\n') + target.chmod(0o755) + + output = root / "launch.json" + forbidden_output = root / "forbidden" + env = {"HOME": str(home), "PATH": f"{mock_bin}:/usr/bin:/bin", + "TEST_OUTPUT": str(output), "TEST_FORBIDDEN": str(forbidden_output)} + launch = ["bash", str(launcher.resolve())] + for args, overrides, expected_args, expected_store in ( + ([], {"WAYLAND_DISPLAY": "wayland-1"}, ["--ozone-platform=wayland"], "gnome-libsecret"), + (["--ozone-platform=x11", "hermes://open?text=a%20b"], + {"WAYLAND_DISPLAY": "wayland-1", "HERMES_DESKTOP_PASSWORD_STORE": "kwallet6"}, + ["--ozone-platform=x11", "hermes://open?text=a%20b"], "kwallet6"), + ([], {"HERMES_HOME": str(home / ".hermes/profiles/work"), "HERMES_DESKTOP_DISABLE_GPU": "1"}, + [], "gnome-libsecret"), + ): + subprocess.run(launch + args, env={**env, **overrides}, check=True) + result = json.loads(output.read_text()) + assert result["args"] == ["--disable-setuid-sandbox", *expected_args], result + assert result["home"] == str(home / ".hermes"), result + assert result["store"] == expected_store, result + assert result["gpu"] == overrides.get("HERMES_DESKTOP_DISABLE_GPU"), result + assert not forbidden_output.exists(), "launcher invoked CLI or sudo" + output.unlink() + for code in ("1", "127"): + for args in ([], ["hermes://open"]): + result = subprocess.run(launch + args, env={**env, "TEST_NAMESPACE_RESULT": code}, + capture_output=True, text=True) + assert result.returncode != 0 and "user namespaces" in result.stderr, result + assert not output.exists() and not forbidden_output.exists() + + gate = ["bash", str(destination), "--self-test-gate", "--install-root", str(runtime), + "--relaunch-target", str(executable)] assert subprocess.check_output(gate, env=env, text=True).strip() == "relaunch" assert subprocess.check_output(gate, env={**env, "TEST_NAMESPACE_RESULT": "1"}, text=True).startswith("manual:") gate[-1] = "/opt/hermes-desktop/Hermes" assert subprocess.check_output(gate, env=env, text=True).startswith("skew:") -print("PASS: native launch and release update handoff retain the user-namespace sandbox") +print("PASS: direct native launches fail closed; release updater accepts the namespace sandbox") diff --git a/pkgbuilds/hermes-desktop/runtime.patch b/pkgbuilds/hermes-desktop/runtime.patch index 06db82d..8b7ed51 100644 --- a/pkgbuilds/hermes-desktop/runtime.patch +++ b/pkgbuilds/hermes-desktop/runtime.patch @@ -1,71 +1,3 @@ ---- a/hermes_cli/main.py -+++ b/hermes_cli/main.py -@@ -8146,6 +8146,44 @@ - return False - - -+def _desktop_linux_userns_sandbox_available() -> bool: -+ """True when the unprivileged userns sandbox works (probed with ``unshare``, fails closed) — then -+ the setuid ``chrome-sandbox`` helper is never consulted and no sudo prompt is needed.""" -+ if sys.platform != "linux": -+ return False -+ unshare = shutil.which("unshare") -+ if not unshare: -+ return False -+ try: -+ return ( -+ subprocess.run( -+ [unshare, "--user", "--map-root-user", "true"], -+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, check=False, -+ ).returncode -+ == 0) -+ except (OSError, subprocess.TimeoutExpired): -+ return False -+ -+def _sandbox_helper_lstat(packaged_executable: Path) -> tuple[Path, Optional[os.stat_result]]: -+ """``(chrome-sandbox path, lstat or None)`` — lstat so a symlink is inspected, not followed.""" -+ sandbox = packaged_executable.parent / "chrome-sandbox" -+ try: -+ return sandbox, sandbox.lstat() -+ except OSError: -+ return sandbox, None -+ -+def _sandbox_helper_is_setuid_root(st: os.stat_result) -> bool: -+ return st.st_uid == 0 and stat.S_IMODE(st.st_mode) == 0o4755 -+ -+def _desktop_linux_needs_disable_setuid_sandbox(packaged_executable: Path) -> bool: -+ """True when a present, non-setuid ``chrome-sandbox`` would make Chromium abort with -+ ``setuid_sandbox_host`` despite a working userns sandbox (call after the fixup's userns path).""" -+ if sys.platform != "linux": -+ return False -+ _sandbox, st = _sandbox_helper_lstat(packaged_executable) -+ return st is not None and stat.S_ISREG(st.st_mode) and not _sandbox_helper_is_setuid_root(st) -+ -+ - def _desktop_linux_sandbox_helper_is_regular_file(packaged_executable: Path) -> bool: - """Return True when ``chrome-sandbox`` exists as a regular file.""" - if sys.platform != "linux": -@@ -8182,6 +8220,10 @@ - return False - - if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755: -+ return True -+ -+ if _desktop_linux_userns_sandbox_available(): -+ print("✓ Using Chromium's user-namespace sandbox (setuid helper not needed).") - return True - - sudo = shutil.which("sudo") -@@ -8591,6 +8633,9 @@ - launch_command.append("--no-sandbox") - else: - sys.exit(1) -+ -+ elif _desktop_linux_needs_disable_setuid_sandbox(packaged_executable): -+ launch_command.append("--disable-setuid-sandbox") - - launch_command.extend(config_electron_flags) - print(f"→ Launching packaged Hermes Desktop: {' '.join(launch_command)}") --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -317,6 +317,8 @@ From 0c2fa676f6de4f91abb5820edfb915b8de72a913 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 03:35:15 -0500 Subject: [PATCH 4/4] Use the next Hermes package release number --- pkgbuilds/hermes-desktop/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 441c996..45eb61e 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -5,7 +5,7 @@ pkgname=hermes-desktop pkgver=2026.8.31 -pkgrel=4 +pkgrel=2 pkgdesc='Native desktop shell for Hermes Agent' arch=('x86_64') url='https://github.com/NousResearch/hermes-agent'