fix: fall back to no-sandbox for desktop launch on restricted Linux hosts

This commit is contained in:
Rafael Millan 2026-06-08 11:43:40 -04:00 committed by Teknium
parent 97640fd9ad
commit 54ea059919
3 changed files with 84 additions and 3 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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"