diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 29c9c28..441c996 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -5,7 +5,7 @@ pkgname=hermes-desktop pkgver=2026.8.31 -pkgrel=3 +pkgrel=4 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' - '93540bbd8e3fccd132546c3e8f7da16eb04e67c20bbfe84034a2736c00e6d49d' + '7324b1d5e7db16c6169dd9e685f4093925c4a8c0568cb7ce416ac734f3336606' '3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52' - 'cd544605f3b6ed397a829244e4f4d08f2d28a3a2f56edff7d3287b55bdf606b2' - '99caf1ef09c3ac88892ee674ebe92faa916c225fb938fe6796cf04b4e4ac397f') + '03b67e26c234c797a6b1d4c9f34a57502dba37f462540e47ce6c88f6ea79302a' + '69bc57d04610567eca35679328668619fcd3f129ed099bf5e2a1f24d1f197de9') build() { cd "${srcdir}/${_srcdir}" @@ -96,7 +96,7 @@ build() { } check() { - python "${srcdir}/runtime-test.py" "${srcdir}/${_srcdir}" "${srcdir}/runtime.patch" + python "${srcdir}/runtime-test.py" "${srcdir}/${_srcdir}" "${srcdir}/runtime.patch" "${srcdir}/hermes-desktop.sh" } package() { @@ -109,7 +109,7 @@ package() { 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. + # Let the release's first updater relaunch with the user-namespace sandbox. install -Dm644 "${srcdir}/runtime.patch" "${pkgdir}/usr/share/${pkgname}/runtime.patch" diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 611b04f..3f96199 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -43,18 +43,13 @@ 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[@]}" "$@" + # 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[@]}" "$@" fi diff --git a/pkgbuilds/hermes-desktop/runtime-test.py b/pkgbuilds/hermes-desktop/runtime-test.py index d4987bf..0aa40d5 100644 --- a/pkgbuilds/hermes-desktop/runtime-test.py +++ b/pkgbuilds/hermes-desktop/runtime-test.py @@ -1,71 +1,85 @@ -"""Check the release backport against the actual pinned upstream source.""" -import ast +"""Check native launch and the release updater without running Hermes or sudo.""" +import json 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:]) +source, patch_file, launcher = 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) + destination = root / "scripts/desktop-update/posix.sh" + destination.parent.mkdir(parents=True) + shutil.copyfile(source / "scripts/desktop-update/posix.sh", 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" + home = root / "home with spaces" + runtime = home / ".hermes/hermes-agent" + native = runtime / "apps/desktop/release/linux-unpacked" native.mkdir(parents=True) executable = native / "Hermes" + executable.write_text(f"#!{sys.executable}\n" + '''import json, os, sys +from pathlib import Path +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"), +})) +''') + executable.chmod(0o755) 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)] + forbidden = '#!/bin/bash\ntouch "$TEST_FORBIDDEN"\nexit 99\n' + cli = runtime / "venv/bin/hermes" + cli.parent.mkdir(parents=True) + cli.write_text(forbidden) + cli.chmod(0o755) + 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') + target.chmod(0o755) + + output = root / "launch.json" + forbidden_output = root / "forbidden" + 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) + 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 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() + + gate = ["bash", str(destination), "--self-test-gate", "--install-root", str(runtime), + "--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") +print("PASS: direct native launches fail closed; release updater accepts the namespace sandbox") diff --git a/pkgbuilds/hermes-desktop/runtime.patch b/pkgbuilds/hermes-desktop/runtime.patch index 06db82d..8b7ed51 100644 --- a/pkgbuilds/hermes-desktop/runtime.patch +++ b/pkgbuilds/hermes-desktop/runtime.patch @@ -1,71 +1,3 @@ ---- 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 @@