diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ab56d9986d6..2034077b8c7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5442,6 +5442,46 @@ def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool: return True +def _desktop_linux_needs_no_sandbox() -> bool: + """Return True when Chromium/Electron should bypass the Linux sandbox. + + Ubuntu 23.10+ can enable AppArmor's + ``apparmor_restrict_unprivileged_userns`` hardening, which breaks + Chromium/Electron's user-namespace sandbox for normal users unless the app + ships a working root-owned 4755 ``chrome-sandbox`` helper. In headless or + non-interactive CLI contexts we may be unable to ``sudo chown/chmod`` that + helper, so detect the host restriction and fall back to ``--no-sandbox`` + rather than hard-failing the launcher. + + We intentionally do NOT return True for root users here: running Electron as + root without a sandbox is a qualitatively riskier path than launching as an + unprivileged desktop user on an AppArmor-restricted host. The root case + should remain an explicit user choice. + """ + if sys.platform != "linux": + return False + if hasattr(os, "geteuid") and os.geteuid() == 0: + return False + try: + with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f: + return f.read().strip() == "1" + except OSError: + return False + + +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": + return False + sandbox = packaged_executable.parent / "chrome-sandbox" + try: + sandbox_lstat = sandbox.lstat() + except OSError: + return False + return stat.S_ISREG(sandbox_lstat.st_mode) + + + def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool: """Configure Electron's Linux SUID sandbox helper when required.""" if sys.platform != "linux": @@ -5678,11 +5718,16 @@ def cmd_gui(args: argparse.Namespace): print(" Expected an unpacked Electron app for the current OS.") sys.exit(1) + launch_command = [str(packaged_executable)] if not _desktop_linux_sandbox_fixup(packaged_executable): - sys.exit(1) + if _desktop_linux_needs_no_sandbox() and _desktop_linux_sandbox_helper_is_regular_file(packaged_executable): + print("⚠ Falling back to --no-sandbox because this Linux host restricts unprivileged user namespaces and the Electron sandbox helper could not be configured.") + launch_command.append("--no-sandbox") + else: + sys.exit(1) - print(f"→ Launching packaged Hermes Desktop: {packaged_executable}") - launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False) + print(f"→ Launching packaged Hermes Desktop: {' '.join(launch_command)}") + launch_result = subprocess.run(launch_command, cwd=desktop_dir, env=env, check=False) sys.exit(launch_result.returncode) diff --git a/scripts/release.py b/scripts/release.py index 1b1c88e38ac..d16f4b22cda 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -71,6 +71,7 @@ AUTHOR_MAP = { "277269729+yusekiotacode@users.noreply.github.com": "yusekiotacode", # PR #48706 salvage (anthropic OAuth login token endpoint → platform.claude.com; #45250/#49821) "minz0721@outlook.com": "s010mn", # PR #29221 salvage (ollama-cloud reasoning_effort xhigh→max) "128256017+chriswesley4@users.noreply.github.com": "chriswesley4", # PR #53185 salvage (re-enable titleBarOverlay on plain Linux; missing min/max/close regression) + "rafael.millan@gmail.com": "RafaelMiMi", # PR #42229 salvage (no-sandbox fallback for AppArmor-restricted Linux desktop launch) "jeevesassistant00@gmail.com": "jeeves-assistant", # PR #50771 (computer-use CuaDriver vision capture routing) "21178861+ScotterMonk@users.noreply.github.com": "ScotterMonk", # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126) "rrandqua@gmail.com": "TutkuEroglu", # PR #50481 salvage (AGENTS.md stale token-lock adapter path) diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 728039aa81d..35c061fb104 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -222,6 +222,41 @@ def test_gui_linux_skips_fixup_when_already_configured(tmp_path, monkeypatch): assert mock_run.call_args.args[0] == [str(packaged_exe)] +def test_gui_linux_falls_back_to_no_sandbox_when_userns_is_restricted(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux") + sandbox = packaged_exe.parent / "chrome-sandbox" + sandbox.write_text("", encoding="utf-8") + + launch_ok = subprocess.CompletedProcess([str(packaged_exe), "--no-sandbox"], 0) + + with patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=False), \ + patch("hermes_cli.main._desktop_linux_needs_no_sandbox", return_value=True), \ + patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 0 + mock_run.assert_called_once() + assert mock_run.call_args.args[0] == [str(packaged_exe), "--no-sandbox"] + + +def test_gui_linux_exits_when_sandbox_fixup_fails_without_safe_fallback(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch, platform="linux") + + with patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=False), \ + patch("hermes_cli.main._desktop_linux_needs_no_sandbox", return_value=False), \ + patch("hermes_cli.main.subprocess.run") as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 1 + mock_run.assert_not_called() + + def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch): root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop"