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:
1 parent
3356e8c04c
commit
9588b28cf6
4 files changed
+206
-32
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user