Files
hermes-agent/tests/hermes_cli/test_linux_sandbox_fixup.py
finn763 df126f6a05 fix(desktop): fail fast instead of hanging when sudo has no TTY
The Linux sandbox fixup shelled out to sudo with inherited stdio, no -n
and no timeout. A .desktop/autostart/detached launch that reaches the sudo
path (user-owned chrome-sandbox, no usable user namespace) hung forever on
the password prompt. Related to #123927.

This line is new in this diff, so the guard belongs here: sys.stdin is
None when the process has no stdin at all and a closed stream raises from
isatty(), and either exception escaped as a traceback before the caller's
--no-sandbox fallback could run.
2026-09-29 18:41:02 -05:00

296 lines
12 KiB
Python

"""Tests for the Linux desktop sandbox-helper fixup and the userns probe.
``_desktop_linux_sandbox_fixup`` historically demanded a root-owned 4755
``chrome-sandbox`` on every Linux host and shelled out to ``sudo`` to get it
— which fails silently when the desktop entry launches ``hermes desktop``
without a TTY (#88032, #51327), and blocked the updater's relaunch gate
(#58593). On hosts where unprivileged user namespaces work, Chromium uses
its namespace sandbox and never consults the setuid helper, so the fixup now
probes for that capability first and skips the sudo path entirely.
"""
from __future__ import annotations
import contextlib
import io
import stat
import subprocess
import sys
import threading
from unittest.mock import Mock, patch
import pytest
from hermes_cli import main_desktop
# Linux-only subject: run on the real Linux host instead of faking sys.platform.
pytestmark = pytest.mark.platforms("linux")
class TestDesktopLinuxUsernsSandboxAvailable:
def test_false_when_unshare_is_missing(self, monkeypatch):
with patch.object(main_desktop.shutil, "which", return_value=None):
assert main_desktop._desktop_linux_userns_sandbox_available() is False
def test_true_when_probe_succeeds(self, monkeypatch):
with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \
patch.object(main_desktop.subprocess, "run") as run:
run.return_value.returncode = 0
assert main_desktop._desktop_linux_userns_sandbox_available() is True
probe = run.call_args.args[0]
assert probe[0] == "/usr/bin/unshare"
assert "--user" in probe
def test_false_when_probe_fails(self, monkeypatch):
"""EPERM from the kernel (userns disabled or AppArmor-restricted)."""
with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \
patch.object(main_desktop.subprocess, "run") as run:
run.return_value.returncode = 1
assert main_desktop._desktop_linux_userns_sandbox_available() is False
def test_false_when_probe_raises(self, monkeypatch):
with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \
patch.object(
main_desktop.subprocess,
"run",
side_effect=subprocess.TimeoutExpired(cmd="unshare", timeout=5),
):
assert main_desktop._desktop_linux_userns_sandbox_available() is False
class TestDesktopLinuxSandboxFixup:
def _fake_packaged_app(self, tmp_path):
"""Unpacked-app layout with a non-root, non-setuid chrome-sandbox."""
unpacked = tmp_path / "linux-unpacked"
unpacked.mkdir()
exe = unpacked / "Hermes"
exe.write_text("", encoding="utf-8")
sandbox = unpacked / "chrome-sandbox"
sandbox.write_text("", encoding="utf-8")
sandbox.chmod(0o755)
return exe
def test_userns_host_skips_sudo_and_succeeds(self, monkeypatch, tmp_path):
"""A user-owned helper must not trigger sudo when userns works.
This is the .desktop-launch regression: no TTY means sudo cannot
prompt, so reaching the sudo path at all kills the launch.
"""
exe = self._fake_packaged_app(tmp_path)
with patch.object(
main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True
), \
patch.object(main_desktop.subprocess, "run") as run:
assert main_desktop._desktop_linux_sandbox_fixup(exe) is True
run.assert_not_called()
def test_restricted_host_without_sudo_still_fails(self, monkeypatch, tmp_path):
"""The pre-existing strict path is preserved when userns is unusable."""
exe = self._fake_packaged_app(tmp_path)
with patch.object(
main_desktop, "_desktop_linux_userns_sandbox_available", return_value=False
), \
patch.object(main_desktop.shutil, "which", return_value=None):
assert main_desktop._desktop_linux_sandbox_fixup(exe) is False
def test_root_owned_setuid_helper_short_circuits(self, monkeypatch, tmp_path):
"""A correctly configured helper wins before the userns probe runs."""
exe = self._fake_packaged_app(tmp_path)
real_lstat = (exe.parent / "chrome-sandbox").lstat()
class _RootSetuidStat:
st_mode = stat.S_IFREG | 0o4755
st_uid = 0
def __getattr__(self, name):
return getattr(real_lstat, name)
with patch.object(main_desktop.Path, "lstat", return_value=_RootSetuidStat()), \
patch.object(
main_desktop, "_desktop_linux_userns_sandbox_available"
) as probe:
assert main_desktop._desktop_linux_sandbox_fixup(exe) is True
probe.assert_not_called()
class TestDesktopLinuxNeedsDisableSetuidSandbox:
def _fake_packaged_app(self, tmp_path):
unpacked = tmp_path / "linux-unpacked"
unpacked.mkdir()
exe = unpacked / "Hermes"
exe.write_text("", encoding="utf-8")
sandbox = unpacked / "chrome-sandbox"
sandbox.write_text("", encoding="utf-8")
sandbox.chmod(0o755)
return exe
def test_true_for_user_owned_helper_when_userns_works(self, monkeypatch, tmp_path):
exe = self._fake_packaged_app(tmp_path)
with patch.object(
main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True
):
assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is True
def test_false_for_root_owned_setuid_helper(self, monkeypatch, tmp_path):
exe = self._fake_packaged_app(tmp_path)
real_lstat = (exe.parent / "chrome-sandbox").lstat()
class _RootSetuidStat:
st_mode = stat.S_IFREG | 0o4755
st_uid = 0
def __getattr__(self, name):
return getattr(real_lstat, name)
with patch.object(main_desktop.Path, "lstat", return_value=_RootSetuidStat()), \
patch.object(
main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True
) as probe:
assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is False
probe.assert_not_called()
def test_false_when_helper_missing(self, monkeypatch, tmp_path):
unpacked = tmp_path / "linux-unpacked"
unpacked.mkdir()
exe = unpacked / "Hermes"
exe.write_text("", encoding="utf-8")
assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is False
class TestDesktopLinuxSandboxFixupNoTty:
"""No-TTY sudo must fail fast, never hang the launch (#123927).
When the .desktop entry (``Terminal=false``), autostart, or a detached
updater relaunch reaches the sudo path — a user-owned ``chrome-sandbox``
plus no usable user namespace (hardened kernels such as CachyOS's
``linux-cachyos-hardened``, ``user.max_user_namespaces=0``, containers)
— sudo's password prompt can never be answered. The call used to inherit
stdio with no ``-n`` and no timeout, so the icon click hung indefinitely.
"""
def _fake_packaged_app(self, tmp_path):
unpacked = tmp_path / "linux-unpacked"
unpacked.mkdir(exist_ok=True)
exe = unpacked / "Hermes"
exe.write_text("", encoding="utf-8")
sandbox = unpacked / "chrome-sandbox"
sandbox.write_text("", encoding="utf-8")
with contextlib.suppress(OSError):
sandbox.chmod(0o755)
return exe
def test_sudo_is_non_interactive_without_a_tty(self, monkeypatch, tmp_path):
"""Without a TTY sudo gets ``-n`` + DEVNULL stdin + a timeout."""
exe = self._fake_packaged_app(tmp_path)
calls = []
def fake_run(cmd, **kwargs):
calls.append((cmd, kwargs))
return Mock(returncode=0)
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(sys, "stdin", io.StringIO()) # isatty() is False
monkeypatch.setattr(
main_desktop, "_desktop_linux_userns_sandbox_available", lambda: False)
monkeypatch.setattr(main_desktop.shutil, "which", lambda _name: "/usr/bin/sudo")
monkeypatch.setattr(main_desktop.subprocess, "run", fake_run)
assert main_desktop._desktop_linux_sandbox_fixup(exe) is True
assert len(calls) == 2
for cmd, kwargs in calls:
assert cmd[0] == "/usr/bin/sudo"
assert "-n" in cmd
assert kwargs.get("stdin") == subprocess.DEVNULL
assert kwargs.get("timeout") is not None
def test_sudo_is_non_interactive_with_an_unusable_stdin(self, monkeypatch, tmp_path):
"""A detached launch (``sys.stdin is None``) or a closed stream still fails fast.
CPython sets ``sys.stdin`` to ``None`` when the process has no stdin at all,
and ``isatty()`` on a closed stream raises; both used to escape as an
``AttributeError``/``ValueError`` before the caller's ``--no-sandbox``
fallback could run.
"""
exe = self._fake_packaged_app(tmp_path)
for state in ("missing", "closed"):
calls = []
def fake_run(cmd, **kwargs):
calls.append((cmd, kwargs))
return Mock(returncode=0)
if state == "missing":
monkeypatch.setattr(sys, "stdin", None)
else:
closed = io.StringIO()
closed.close()
monkeypatch.setattr(sys, "stdin", closed)
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(
main_desktop, "_desktop_linux_userns_sandbox_available", lambda: False)
monkeypatch.setattr(main_desktop.shutil, "which", lambda _name: "/usr/bin/sudo")
monkeypatch.setattr(main_desktop.subprocess, "run", fake_run)
assert main_desktop._desktop_linux_sandbox_fixup(exe) is True, state
assert len(calls) == 2, state
for cmd, kwargs in calls:
assert "-n" in cmd, state
assert kwargs.get("stdin") == subprocess.DEVNULL, state
def test_sudo_stays_interactive_with_a_tty(self, monkeypatch, tmp_path):
"""A terminal launch keeps the password prompt (no -n)."""
exe = self._fake_packaged_app(tmp_path)
calls = []
def fake_run(cmd, **kwargs):
calls.append((cmd, kwargs))
return Mock(returncode=0)
tty = Mock()
tty.isatty.return_value = True
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(sys, "stdin", tty)
monkeypatch.setattr(
main_desktop, "_desktop_linux_userns_sandbox_available", lambda: False)
monkeypatch.setattr(main_desktop.shutil, "which", lambda _name: "/usr/bin/sudo")
monkeypatch.setattr(main_desktop.subprocess, "run", fake_run)
assert main_desktop._desktop_linux_sandbox_fixup(exe) is True
assert len(calls) == 2
for cmd, _kwargs in calls:
assert "-n" not in cmd
def test_blocking_sudo_returns_fast(self, tmp_path):
"""A sudo stuck at its password prompt must not hang the fixup.
The fake honours ``-n`` (exit 1, like a real sudo with no cached
credentials) and blocks otherwise (the pre-fix hang: a prompt with
no TTY to answer it). Needs a POSIX shell, so Linux-only like the
rest of this file.
"""
exe = self._fake_packaged_app(tmp_path)
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
fake = fake_bin / "sudo"
fake.write_text(
"#!/bin/sh\n"
'for a in "$@"; do if [ "$a" = "-n" ]; then exit 1; fi; done\n'
"sleep 30\n",
encoding="utf-8",
)
fake.chmod(0o755)
outcome: dict = {}
def target():
outcome["result"] = main_desktop._desktop_linux_sandbox_fixup(exe)
with patch.object(sys, "platform", "linux"), \
patch.object(
main_desktop, "_desktop_linux_userns_sandbox_available",
return_value=False), \
patch.object(main_desktop.shutil, "which", return_value=str(fake)), \
patch.object(sys, "stdin", io.StringIO()):
worker = threading.Thread(target=target, daemon=True)
worker.start()
worker.join(timeout=10)
assert not worker.is_alive(), \
"fixup hung in sudo with no TTY (icon click goes nowhere)"
assert outcome.get("result") is False