Launch native Hermes without privileged sandbox setup

Use the same direct executable path for menu launches and URLs, require working user namespaces, and retain only the first-update relaunch gate backport. This avoids the upstream CLI fallback that makes a helper in the user runtime setuid-root.

Co-Authored-By: GPT-6 Codex (xhigh) <noreply@openai.com>
This commit is contained in:
Spencer BullandGPT-6 Codex committed 2026-09-07 03:17:42 -05:00
1 parent 9588b28cf6
commit 2fb9ab2ba9
4 files changed
+71 -130

No files matched your search

+6 -6
View File
@@ -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"
+6 -11
View File
@@ -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
+59 -45
View File
@@ -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")
-68
View File
@@ -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 @@