From 9588b28cf6459e65f2e4d930e641bac95031ae1a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 02:55:06 -0500 Subject: [PATCH] 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