diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 45eb61ec..1f48dd9d 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 3f96199e..40822b88 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 + 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 diff --git a/pkgbuilds/hermes-desktop/runtime-test.py b/pkgbuilds/hermes-desktop/runtime-test.py index 0aa40d54..79f0a43d 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 @@ "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 @@ 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 @@ 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 @@ 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")