Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions pkgbuilds/hermes-desktop/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

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'
Expand Down Expand Up @@ -75,11 +75,11 @@ source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz
'runtime.patch'
'runtime-test.py')
sha256sums=('78fb3ff707ec1d17044b875ecac8bef28aa39d44242824f6871ca40afe7bf217'
'7324b1d5e7db16c6169dd9e685f4093925c4a8c0568cb7ce416ac734f3336606'
'094d5f3191109a80eea9f23053b78a2e00dbecf90d62d1ca04c8e48866251469'
'3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4'
'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52'
'03b67e26c234c797a6b1d4c9f34a57502dba37f462540e47ce6c88f6ea79302a'
'69bc57d04610567eca35679328668619fcd3f129ed099bf5e2a1f24d1f197de9')
'461e1120e7e6779f531c114d9926479e47fab79113d1b4646efea36bc771b3c5')

build() {
cd "${srcdir}/${_srcdir}"
Expand Down Expand Up @@ -130,11 +130,6 @@ package() {
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
# The launcher requires the namespace sandbox on the target host.
chmod 0755 "${pkgdir}/opt/${pkgname}/chrome-sandbox"
}
87 changes: 53 additions & 34 deletions pkgbuilds/hermes-desktop/hermes-desktop.sh
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
#!/bin/bash
set -euo pipefail

# Use the runtime prepared by Omarchy rather than a separate CLI on PATH.
unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME
export HERMES_DESKTOP_IGNORE_EXISTING=1

# 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}"

# 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.
# Reconcile direct package installs and interrupted Omarchy setup as well.
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=*) platform_flags=() ;;
esac
done
fi

hermes_home=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
parent=${hermes_home%/*}
if [[ ${parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
Expand All @@ -41,15 +18,57 @@ 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
native=/opt/hermes-desktop/Hermes
fi

# Both app locations use namespaces, never a user-writable setuid helper.
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

if [[ -x $native && -x $runtime/venv/bin/hermes ]]; then
# 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[@]}" "$@"
python=/usr/bin/python
if [[ -x $runtime/venv/bin/python && -f $runtime/hermes_cli/main.py ]]; then
python="$runtime/venv/bin/python"
else
exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@"
runtime=""
fi
exec "$python" - "$native" "$runtime" "$@" <<'PY'
import os
from pathlib import Path
import sys

native, runtime, *args = sys.argv[1:]
env = os.environ.copy()
flags, gpu, store, ozone = [], "auto", "auto", "auto"
if runtime:
sys.path.insert(0, runtime)
try:
# Upstream moved the helper out of main after the packaged release.
if Path(runtime, "hermes_cli/main_desktop.py").is_file():
from hermes_cli.main_desktop import _desktop_launch_options
else:
from hermes_cli.main import _desktop_launch_options
from hermes_constants import with_hermes_node_path

flags, gpu, store, ozone = _desktop_launch_options()
env = with_hermes_node_path(env)
except ImportError:
print("Could not load Hermes desktop settings; using launch defaults.", file=sys.stderr)

env["HERMES_DESKTOP_CWD"] = os.getcwd()
if gpu != "auto":
env.setdefault("HERMES_DESKTOP_DISABLE_GPU", gpu)
if ozone != "auto":
env.setdefault("ELECTRON_OZONE_PLATFORM_HINT", ozone)
env.setdefault("HERMES_DESKTOP_PASSWORD_STORE", store if store != "auto" else "gnome-libsecret")

# Explicit config, environment and command-line choices override the Wayland default.
if (env.get("WAYLAND_DISPLAY") or env.get("XDG_SESSION_TYPE") == "wayland") and (
"ELECTRON_OZONE_PLATFORM_HINT" not in env
and not any(arg.startswith(("--ozone-platform=", "--ozone-platform-hint=")) for arg in flags + args)
):
flags.insert(0, "--ozone-platform=wayland")
os.execve(native, [native, "--disable-setuid-sandbox", *flags, *args], env)
PY
119 changes: 98 additions & 21 deletions pkgbuilds/hermes-desktop/runtime-test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Check native launch and the release updater without running Hermes or sudo."""
"""Check launch settings and sandbox gates without running Hermes or sudo."""
import ast
import json
import os
from pathlib import Path
Expand Down Expand Up @@ -26,13 +27,40 @@
"args": sys.argv[1:], "home": os.environ["HERMES_HOME"],
"store": os.environ["HERMES_DESKTOP_PASSWORD_STORE"],
"gpu": os.environ.get("HERMES_DESKTOP_DISABLE_GPU"),
"ozone": os.environ.get("ELECTRON_OZONE_PLATFORM_HINT"),
"cwd": os.environ.get("HERMES_DESKTOP_CWD"),
"inherited": [name for name in ("ELECTRON_RUN_AS_NODE", "PYTHONPATH", "PYTHONHOME") if name in os.environ],
}))
''')
executable.chmod(0o755)
sandbox = native / "chrome-sandbox"
sandbox.write_text("fixture")
sandbox.chmod(0o755)

# Execute the release's real option parser, isolating config I/O and avoiding
# unrelated CLI imports, startup hooks and third-party dependencies.
module = runtime / "hermes_cli"
module.mkdir()
(module / "__init__.py").touch()
upstream = ast.parse((source / "hermes_cli/main.py").read_text())
option_parser = next(node for node in upstream.body
if isinstance(node, ast.FunctionDef) and node.name == "_desktop_launch_options")
stores = next(node for node in upstream.body if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "_LINUX_PASSWORD_STORES"
for target in node.targets))
helper = "import os, shlex\n" + ast.unparse(stores) + "\n" + ast.unparse(option_parser) + "\n"
(module / "main.py").write_text(helper)
(module / "config.py").write_text('''import json, os
from pathlib import Path
def load_config():
path = Path(os.environ["HERMES_HOME"], "config.yaml")
return json.loads(path.read_text()) if path.exists() else {}
''')
(runtime / "hermes_constants.py").write_text('''import os
def with_hermes_node_path(env=None):
return (os.environ if env is None else env).copy()
''')

mock_bin = root / "bin"
mock_bin.mkdir()
unshare = mock_bin / "unshare"
Expand All @@ -43,6 +71,7 @@
cli.parent.mkdir(parents=True)
cli.write_text(forbidden)
cli.chmod(0o755)
(cli.parent / "python").symlink_to(sys.executable)
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')
Expand All @@ -53,33 +82,81 @@
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)

def check_launch(args=(), overrides=None, expected_args=(), store="gnome-libsecret", gpu=None, ozone=None):
subprocess.run(launch + list(args), env={**env, **(overrides or {})}, cwd=home, 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 result == {"args": ["--disable-setuid-sandbox", *expected_args],
"home": str(home / ".hermes"), "store": store, "gpu": gpu,
"ozone": ozone, "cwd": str(home), "inherited": []}, 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()

wayland = {"WAYLAND_DISPLAY": "wayland-1"}
check_launch(overrides=wayland, expected_args=["--ozone-platform=wayland"])
url = "hermes://open?text=a%20b"
check_launch(["--ozone-platform=x11", url], {**wayland, "HERMES_DESKTOP_PASSWORD_STORE": "kwallet6"},
["--ozone-platform=x11", url], store="kwallet6")
check_launch(overrides={"HERMES_HOME": str(home / ".hermes/profiles/work"),
"HERMES_DESKTOP_DISABLE_GPU": "1"}, gpu="1")
check_launch(overrides={"ELECTRON_RUN_AS_NODE": "1", "PYTHONPATH": "/invalid", "PYTHONHOME": "/invalid"})

config = home / ".hermes/config.yaml"
config.write_text(json.dumps({"desktop": {"disable_gpu": True, "password_store": "kwallet5",
"ozone_platform_hint": "x11",
"electron_flags": '--force-device-scale-factor=1.5 "--test=a b"'}}))
config_flags = ["--force-device-scale-factor=1.5", "--test=a b"]
for args in ([], [url]):
check_launch(args, wayland, config_flags + args, store="kwallet5", gpu="1", ozone="x11")
check_launch(overrides={**wayland, "HERMES_DESKTOP_DISABLE_GPU": "0",
"HERMES_DESKTOP_PASSWORD_STORE": "basic", "ELECTRON_OZONE_PLATFORM_HINT": "wayland"},
expected_args=config_flags, store="basic", gpu="0", ozone="wayland")
config.write_text(json.dumps({"desktop": {"electron_flags": ["--ozone-platform=x11"], "disable_gpu": False}}))
check_launch(overrides=wayland, expected_args=["--ozone-platform=x11"], gpu="0")
# New upstream revisions expose the same helper from main_desktop instead.
(module / "main_desktop.py").write_text(helper)
(module / "main.py").write_text('raise AssertionError("old helper import after update")\n')
check_launch([url], wayland, ["--ozone-platform=x11", url], gpu="0")
config.unlink()
(module / "main_desktop.py").write_text(helper + '\nimport os\nos.environ.update(' + repr({
"ELECTRON_RUN_AS_NODE": "1", "PYTHONPATH": "/invalid", "PYTHONHOME": "/invalid",
"HERMES_HOME": "/invalid", "HERMES_DESKTOP_DISABLE_GPU": "1",
"HERMES_DESKTOP_PASSWORD_STORE": "basic", "ELECTRON_OZONE_PLATFORM_HINT": "x11",
}) + ')\n')
check_launch(overrides={**wayland, "HERMES_DESKTOP_DISABLE_GPU": "0"},
expected_args=["--ozone-platform=wayland"], gpu="0")
(module / "main_desktop.py").write_text(helper)

def check_namespace_failure():
for code in ("1", "127"):
for args in ([], [url]):
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()

check_namespace_failure()
# Exercise the real fallback branch with only its absolute app path redirected.
fallback = root / "packaged-Hermes"
shutil.copyfile(executable, fallback)
fallback.chmod(0o755)
fallback_launcher = root / "fallback-launcher"
fallback_launcher.write_text(launcher.read_text().replace("/opt/hermes-desktop/Hermes", str(fallback)))
launch = ["bash", str(fallback_launcher)]
executable.unlink()
check_launch([url], wayland, ["--ozone-platform=wayland", url])
(module / "main_desktop.py").write_text('raise ImportError("incomplete Python dependencies")\n')
check_launch([url], wayland, ["--ozone-platform=wayland", url])
shutil.rmtree(runtime / "venv")
check_launch(overrides={"ELECTRON_RUN_AS_NODE": "1", "PYTHONPATH": "/invalid", "PYTHONHOME": "/invalid"})
check_namespace_failure()

gate = ["bash", str(destination), "--self-test-gate", "--install-root", str(runtime),
"--relaunch-target", str(executable)]
executable.touch()
executable.chmod(0o755)
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: direct native launches fail closed; release updater accepts the namespace sandbox")
print("PASS: desktop settings, environment, native/fallback namespace sandbox and updater gate")
Loading