* Accept the fifth Hermes desktop launch option Hermes now returns five values from _desktop_launch_options(), appending the renderer accessibility switch. The launcher unpacked exactly four, so once a user's runtime updated, every launch died with "too many values to unpack" before the app opened. The launcher now takes the first four and reads the fifth when present, so it keeps working with the packaged release and with newer runtimes, and it bridges an explicit accessibility opt-out the same way Hermes' own launcher does. The launch check now also runs a five-value helper, which fails against the previous launcher. Fixes basecamp/omarchy#13491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hermes-desktop: launch against the installed runtime, accept the helper's new arity _desktop_launch_options() returns five values since Hermes grew a trailing renderer_accessibility field, so the launcher died with "too many values to unpack (expected 4)" before os.execve — and gtk-launch discards stderr, so the app icon became a silent no-op that left no process and no log line behind. The launcher also exported HERMES_DESKTOP_IGNORE_EXISTING=1 unconditionally while preferring the runtime's own app binary a few lines above it: with a runtime installed, Desktop skipped that runtime and offered first-run setup instead of the user's sessions. Ignoring an existing runtime is only correct for the bundled /opt app, which is built from the package's release commit and cannot drive a runtime built from another one. pkgrel bumped for the changed artifact. * Tell only the packaged Hermes Desktop to skip an existing install The runtime's own app now finds its runtime the way `hermes desktop` launches it, through the installed-runtime lookup, instead of being pinned to it with HERMES_DESKTOP_HERMES_ROOT. That variable is upstream's developer override: it resolves before the lookup that Repair install bypasses, so pinning the runtime turned a hard repair into a restart against the same broken venv. HERMES_DESKTOP_IGNORE_EXISTING is set only when the launcher falls back to the packaged /opt app, and an explicit value in the environment still wins, so the in-app updater's relaunch of the runtime app no longer inherits it. The fifth launch option is read the way it was accepted in the previous commit but one, with the checksums the two earlier commits left stale brought up to date. runtime-test.py now records both variables, so it fails if the unconditional export or the root pin comes back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Codex XHigh <noreply@openai.com> --------- Co-authored-by: manuaudio <manu@arimaka.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Omni <omni@omninova.com.mx> Co-authored-by: Codex XHigh <noreply@openai.com>
193 lines
10 KiB
Python
193 lines
10 KiB
Python
"""Check launch settings and sandbox gates without running Hermes or sudo."""
|
|
import ast
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
source, patch_file, launcher = map(Path, sys.argv[1:])
|
|
with tempfile.TemporaryDirectory(prefix="hermes-runtime-check-") as temporary:
|
|
root = Path(temporary)
|
|
destination = root / "scripts/desktop-update/posix.sh"
|
|
destination.parent.mkdir(parents=True)
|
|
shutil.copyfile(source / "scripts/desktop-update/posix.sh", destination)
|
|
# Omarchy's installer requires the patch and accepts an upstreamed fix
|
|
# through its reverse check. Verify that path without changing the release.
|
|
subprocess.run(["git", "apply", "--reverse", "--check", str(patch_file.resolve())], cwd=root, check=True)
|
|
|
|
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"),
|
|
"ozone": os.environ.get("ELECTRON_OZONE_PLATFORM_HINT"),
|
|
"cwd": os.environ.get("HERMES_DESKTOP_CWD"),
|
|
"a11y": os.environ.get("HERMES_DESKTOP_RENDERER_ACCESSIBILITY"),
|
|
"ignore": os.environ.get("HERMES_DESKTOP_IGNORE_EXISTING"),
|
|
"root": os.environ.get("HERMES_DESKTOP_HERMES_ROOT"),
|
|
"inherited": [name for name in ("ELECTRON_RUN_AS_NODE", "PYTHONPATH", "PYTHONHOME") if name in os.environ],
|
|
}))
|
|
''')
|
|
executable.chmod(0o755)
|
|
sandbox = native / "chrome-sandbox"
|
|
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_desktop.py").read_text())
|
|
option_parser = next(node for node in upstream.body
|
|
if isinstance(node, ast.FunctionDef) and node.name == "_desktop_launch_options")
|
|
constants = [node for node in upstream.body if isinstance(node, ast.Assign)
|
|
and any(isinstance(target, ast.Name)
|
|
and target.id in ("_LINUX_PASSWORD_STORES", "_GPU_FLAG_WORDS")
|
|
for target in node.targets)]
|
|
helper = "import os, shlex\n" + "\n".join(map(ast.unparse, constants)) + "\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"
|
|
unshare.write_text('#!/bin/bash\nexit "${TEST_NAMESPACE_RESULT:-0}"\n')
|
|
unshare.chmod(0o755)
|
|
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)
|
|
(cli.parent / "python").symlink_to(sys.executable)
|
|
# The launcher used to call Omarchy's installer on every start; a launcher
|
|
# that reaches for it, or for sudo, fails here.
|
|
for command in ("sudo", "omarchy-install-hermes-cli"):
|
|
target = mock_bin / command
|
|
target.write_text(forbidden)
|
|
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())]
|
|
|
|
def check_launch(args=(), overrides=None, expected_args=(), store="gnome-libsecret", gpu=None, ozone=None,
|
|
a11y=None, ignore=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],
|
|
"home": str(home / ".hermes"), "store": store, "gpu": gpu,
|
|
"ozone": ozone, "cwd": str(home), "a11y": a11y, "ignore": ignore, "root": None,
|
|
"inherited": []}, result
|
|
assert not forbidden_output.exists(), "launcher invoked the Omarchy installer or sudo"
|
|
output.unlink()
|
|
|
|
wayland = {"WAYLAND_DISPLAY": "wayland-1"}
|
|
check_launch(overrides=wayland, expected_args=["--ozone-platform=wayland"])
|
|
# The runtime's own app finds its runtime unaided; an explicit request to skip it still passes through.
|
|
check_launch(overrides={**wayland, "HERMES_DESKTOP_IGNORE_EXISTING": "1"},
|
|
expected_args=["--ozone-platform=wayland"], ignore="1")
|
|
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")
|
|
# Later runtimes return a fifth option, the renderer accessibility switch.
|
|
(module / "main_desktop.py").write_text(helper + '''
|
|
_first_four = _desktop_launch_options
|
|
def _desktop_launch_options():
|
|
from hermes_cli.config import load_config
|
|
return (*_first_four(), load_config()["desktop"].get("renderer_accessibility", True))
|
|
''')
|
|
check_launch([url], wayland, ["--ozone-platform=x11", url], gpu="0")
|
|
config.write_text(json.dumps({"desktop": {"renderer_accessibility": False}}))
|
|
check_launch(overrides=wayland, expected_args=["--ozone-platform=wayland"], a11y="0")
|
|
check_launch(overrides={**wayland, "HERMES_DESKTOP_RENDERER_ACCESSIBILITY": "1"},
|
|
expected_args=["--ozone-platform=wayland"], a11y="1")
|
|
(module / "main_desktop.py").write_text(helper)
|
|
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()
|
|
# Only the packaged app is told to skip an existing Hermes, unless the environment already says.
|
|
check_launch([url], wayland, ["--ozone-platform=wayland", url], ignore="1")
|
|
check_launch([url], {**wayland, "HERMES_DESKTOP_IGNORE_EXISTING": "0"}, ["--ozone-platform=wayland", url],
|
|
ignore="0")
|
|
(module / "main_desktop.py").write_text('raise ImportError("incomplete Python dependencies")\n')
|
|
check_launch([url], wayland, ["--ozone-platform=wayland", url], ignore="1")
|
|
shutil.rmtree(runtime / "venv")
|
|
check_launch(overrides={"ELECTRON_RUN_AS_NODE": "1", "PYTHONPATH": "/invalid", "PYTHONHOME": "/invalid"},
|
|
ignore="1")
|
|
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: desktop settings, environment, native/fallback namespace sandbox and updater gate")
|