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 <noreply@openai.com>
This commit is contained in:
1 parent
bf842f5a6f
commit
f69f6ce364
3 files changed
+158
-67
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user