From f69f6ce36433b915b6894ed2e17950f3a6dec50f Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 04:10:07 -0500 Subject: [PATCH] Fix Hermes desktop launch settings and sandbox consistency Release 2026.8.31-3 preserves upstream desktop settings and explicit launch environment without invoking the CLI sandbox setup. Keep incomplete runtimes from blocking the packaged fallback, and use the namespace sandbox consistently in both locations. Co-Authored-By: Codex XHigh --- pkgbuilds/hermes-desktop/PKGBUILD | 15 +-- pkgbuilds/hermes-desktop/hermes-desktop.sh | 91 +++++++++------- pkgbuilds/hermes-desktop/runtime-test.py | 119 +++++++++++++++++---- 3 files changed, 158 insertions(+), 67 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 45eb61e..1f48dd9 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -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' @@ -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}" @@ -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" } diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 3f96199..40822b8 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -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 @@ -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 - # 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[@]}" "$@" +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 + +python=/usr/bin/python +if [[ -x $runtime/venv/bin/python && -f $runtime/hermes_cli/main.py ]]; then + python="$runtime/venv/bin/python" +else + 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 diff --git a/pkgbuilds/hermes-desktop/runtime-test.py b/pkgbuilds/hermes-desktop/runtime-test.py index 0aa40d5..79f0a43 100644 --- a/pkgbuilds/hermes-desktop/runtime-test.py +++ b/pkgbuilds/hermes-desktop/runtime-test.py @@ -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 @@ -26,6 +27,9 @@ 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"), + "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) @@ -33,6 +37,30 @@ Path(os.environ["TEST_OUTPUT"]).write_text(json.dumps({ 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" @@ -43,6 +71,7 @@ Path(os.environ["TEST_OUTPUT"]).write_text(json.dumps({ 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') @@ -53,33 +82,81 @@ Path(os.environ["TEST_OUTPUT"]).write_text(json.dumps({ 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")