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) <noreply@openai.com>
This commit is contained in:
Spencer BullandGPT-6 Codex committed 2026-09-07 02:55:06 -05:00
1 parent 3356e8c04c
commit 9588b28cf6
4 files changed
+206 -32

No files matched your search

+29 -24
View File
@@ -1,25 +1,11 @@
# Maintainer: David Heinemeier Hansson <david@hey.com>
# 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"
+27 -8
View File
@@ -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
+71
View File
@@ -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")
+79
View File
@@ -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