Merge pull request #336 from spencerbull/fix-hermes-desktop-launch
Fix Hermes desktop launch settings (2026.8.31-3)
This commit is contained in:
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