Files
hermes-agent/tests/tools/test_bot_desktop_install.py
ethernet 2be53ecd7b fix(encoding): read kernel pseudo-files as plain utf-8
utf-8-sig exists to tolerate BOMs that Windows tooling adds to files
users edit. /proc and /sys files are generated by the Linux kernel, never
BOM'd and absent on Windows, so -sig there only muddies the read/write
policy. Switch every literal /proc/ and /sys/ read to utf-8 and teach
the footgun read rule that string literals starting with /proc/ or
/sys/ are exempt (user-edited files keep utf-8-sig).
2026-09-24 11:50:30 -04:00

264 lines
11 KiB
Python

"""Bot Desktop package install: sudo hand-off and single-flight invariants."""
from __future__ import annotations
import os
import threading
import pytest
from tools.bot_desktop import install, runtime
_REAL_INSTALL_COMMAND = runtime.install_command # captured before the fixture pins a sudo line
@pytest.fixture(autouse=True)
def _isolated_host(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(install, "_sudo_nopasswd", lambda: False)
monkeypatch.setattr(runtime, "is_supported_host", lambda: True)
monkeypatch.setattr(runtime, "install_command", lambda: "sudo apt-get install -y tigervnc-standalone-server")
yield
install._running.clear()
def test_empty_password_cancels_without_spawning(monkeypatch):
monkeypatch.setattr(install.subprocess, "Popen", lambda *a, **k: pytest.fail("package manager spawned"))
lines: list[str] = []
code = install.install_packages(ask_password=lambda: "", on_line=lines.append)
assert code == -1
assert any("cancelled" in line for line in lines)
def test_second_install_for_same_profile_is_refused(monkeypatch):
gate = threading.Event()
entered = threading.Event()
def slow_run(cmd, *, ask_password, on_line, timeout_seconds):
entered.set()
gate.wait(5)
return 0
monkeypatch.setattr(install, "_run", slow_run)
worker = threading.Thread(target=install.install_packages, kwargs={"ask_password": lambda: "pw", "on_line": lambda _l: None})
worker.start()
assert entered.wait(5)
with pytest.raises(install.InstallBusy):
install.install_packages(ask_password=lambda: "pw", on_line=lambda _l: None)
gate.set()
worker.join(5)
install.assert_not_running() # slot released once the run finishes
def test_claim_is_atomic_and_refuses_a_second_claim():
"""The gateway claims BEFORE spawning its worker; a second Install click must fail at claim time, not
pass a read-only check and race the worker for the slot."""
key = install.claim()
with pytest.raises(install.InstallBusy):
install.claim()
with pytest.raises(install.InstallBusy):
install.install_packages(ask_password=lambda: "pw", on_line=lambda _l: None)
install.release(key)
install.claim() # free again
install.release(key)
@pytest.mark.platforms("linux")
def test_timeout_kills_the_package_managers_whole_process_group(monkeypatch):
"""sudo forks the package manager into the same (new) session; killing sudo alone leaves apt/dnf
holding the dpkg lock as root. The timeout must take the group."""
import subprocess
import time
monkeypatch.setattr(install, "_sudo_nopasswd", lambda: True)
# stand-in for `sudo apt-get ...`: a parent that spawns a child and waits, both in the new session
fake = ["sudo"]
real_popen = subprocess.Popen
def popen(argv, **kw):
if argv[:1] != fake:
return real_popen(argv, **kw)
return real_popen(["bash", "-c", "sleep 30 >/dev/null 2>&1 & echo child $!; wait"], **kw)
monkeypatch.setattr(install.subprocess, "Popen", popen)
lines: list[str] = []
code = install._run("sudo apt-get install -y x", ask_password=lambda: "", on_line=lines.append, timeout_seconds=0.5)
assert code != 0
child = next(int(line.split()[1]) for line in lines if line.startswith("child "))
from pathlib import Path
def gone() -> bool: # /proc-based: a reparented orphan sits outside our subtree, where os.kill(pid, 0) is guarded
try:
return "Z" in (Path(f"/proc/{child}/stat").read_text(encoding="utf-8").rsplit(")", 1)[1].split() or ["Z"])[0]
except OSError:
return True
for _ in range(50): # the child must die with the group, not linger reparented to init
if gone():
break
time.sleep(0.1)
else:
subprocess.run(["kill", "-9", str(child)], check=False)
pytest.fail("grandchild survived the install timeout")
def test_passwordless_sudo_runs_the_install_without_asking_for_a_password(monkeypatch):
"""A host with NOPASSWD sudo must not pop the masked password card — the card blocks the install
for up to five minutes on an answer nobody needs to give — and the package command still runs
and streams its output."""
monkeypatch.setattr(install, "_sudo_nopasswd", lambda: True)
spawned: list[list[str]] = []
class _Proc:
"""Stands in for the sudo child: the drain reads the pipe by fd, so stdout is a real pipe with
the output already written and the write end closed (EOF)."""
pid = 4242
returncode = 0
stdin = __import__("io").StringIO()
def __init__(self, argv, **kw):
spawned.append(argv)
r, w = os.pipe()
os.write(w, b"Reading package lists...\nDone\n")
os.close(w)
self.stdout = os.fdopen(r, "rb")
def wait(self, timeout=None):
return 0
def poll(self):
return 0
monkeypatch.setattr(install.subprocess, "Popen", _Proc)
monkeypatch.setattr(install.os, "killpg", lambda *a: None)
lines: list[str] = []
code = install.install_packages(ask_password=lambda: pytest.fail("sudo password asked despite NOPASSWD"),
on_line=lines.append)
assert code == 0
assert spawned and spawned[0][:1] == ["sudo"] and "-S" not in spawned[0]
assert "Done" in lines
@pytest.mark.platforms("linux")
def test_timeout_returns_and_frees_the_slot_even_when_a_descendant_survives(monkeypatch):
"""From an unprivileged Hermes, killpg reaches the sudo leader but not a root-owned apt child; that
child keeps the pipe's write end open, so draining stdout never sees EOF and the profile slot stays
taken forever. The timeout must end the drain and release the slot regardless of what survived.
Stand-in for the unkillable root child: a grandchild in its own session holding our stdout."""
import subprocess
import time
monkeypatch.setattr(install, "_sudo_nopasswd", lambda: True)
monkeypatch.setattr(install, "_TERM_GRACE_SECONDS", 0.2, raising=False)
real_popen = subprocess.Popen
def popen(argv, **kw):
if argv[:1] != ["sudo"]:
return real_popen(argv, **kw)
return real_popen(["sh", "-c", "setsid sleep 30 & echo child $!; wait"], **kw)
monkeypatch.setattr(install.subprocess, "Popen", popen)
lines: list[str] = []
started = time.monotonic()
worker = threading.Thread(target=lambda: lines.append(
f"code {install.install_packages(ask_password=lambda: '', on_line=lines.append, timeout_seconds=0.5)}"))
worker.start()
worker.join(3.0)
survivor = next((int(line.split()[1]) for line in lines if line.startswith("child ")), None)
if survivor:
subprocess.run(["kill", "-9", str(survivor)], check=False)
assert not worker.is_alive(), f"install_packages hung {time.monotonic() - started:.1f}s on a surviving descendant"
assert any(line.startswith("code ") and line != "code 0" for line in lines), lines
assert any("timed out" in line for line in lines), lines
install.release(install.claim()) # slot is free again
def test_root_installs_without_sudo_and_without_asking(monkeypatch):
"""The official Docker image runs Hermes as uid 0 with no sudo binary: the package manager must be
run directly, and the password card must never be raised for a user who already is root."""
import subprocess
monkeypatch.setattr(install.os, "geteuid", lambda: 0)
monkeypatch.setattr("shutil.which", lambda name, *a, **k: None)
monkeypatch.setattr(runtime, "package_manager", lambda: "apt")
monkeypatch.setattr(runtime, "install_command", _REAL_INSTALL_COMMAND)
spawned: list[list[str]] = []
real_popen = subprocess.Popen
def popen(argv, **kw):
spawned.append(list(argv))
return real_popen(["true"], **kw)
monkeypatch.setattr(install.subprocess, "Popen", popen)
code = install.install_packages(ask_password=lambda: pytest.fail("root was asked for a sudo password"),
on_line=lambda _l: None)
assert code == 0
assert spawned and spawned[0][0] == "apt-get", spawned
def test_no_sudo_binary_returns_the_host_command_instead_of_a_password_card(monkeypatch):
"""Unprivileged with no sudo on the host (minimal containers): a password card would be a dead end,
the user needs the exact command to run on the host instead."""
monkeypatch.setattr(install.os, "geteuid", lambda: 1000)
monkeypatch.setattr("shutil.which", lambda name, *a, **k: None)
monkeypatch.setattr(install.subprocess, "Popen", lambda *a, **k: pytest.fail("package manager spawned"))
lines: list[str] = []
code = install.install_packages(ask_password=lambda: pytest.fail("password card raised without sudo"),
on_line=lines.append)
assert code == install.NO_SUDO
assert any("apt-get install" in line for line in lines), lines
def test_timeout_finishes_the_group_when_only_the_leader_dies_on_term(monkeypatch):
"""TERM ends the leader shell at once, but a descendant in the same group that ignores TERM used to be
left alive holding the dpkg lock: the kill helper returned as soon as the leader was reaped. Cleanup is
done only when the whole process group is gone."""
import subprocess
import time
monkeypatch.setattr(install, "_sudo_nopasswd", lambda: True)
monkeypatch.setattr(install, "_TERM_GRACE_SECONDS", 0.3, raising=False)
real_popen = subprocess.Popen
def popen(argv, **kw):
if argv[:1] != ["sudo"]:
return real_popen(argv, **kw)
return real_popen(["sh", "-c", "sh -c 'trap \"\" TERM; echo child $$; sleep 30' & wait"], **kw)
monkeypatch.setattr(install.subprocess, "Popen", popen)
lines: list[str] = []
install.install_packages(ask_password=lambda: "", on_line=lines.append, timeout_seconds=0.5)
child = next(int(line.split()[1]) for line in lines if line.startswith("child "))
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and _alive(child):
time.sleep(0.05)
try:
assert not _alive(child), "the TERM-ignoring descendant survived the timeout cleanup"
finally:
subprocess.run(["kill", "-9", str(child)], check=False)
def _alive(pid: int) -> bool:
import psutil
return psutil.pid_exists(pid)
def test_install_slot_is_held_across_processes(tmp_path):
"""The gateway (Install card) and the CLI (`screen install`) are separate processes on one host: the
single-flight must be a file lock in the profile's state dir, not only the in-process set."""
import subprocess
import sys
key = install.claim()
try:
probe = subprocess.run([sys.executable, "-c",
"import fcntl,sys\nfh=open(sys.argv[1],'a+',encoding='utf-8')\n"
"try:\n fcntl.flock(fh.fileno(), fcntl.LOCK_EX|fcntl.LOCK_NB); print('free')\n"
"except OSError:\n print('held')", str(runtime.state_dir() / "install.lock")],
capture_output=True, text=True)
assert probe.stdout.strip() == "held", probe
finally:
install.release(key)
install.release(install.claim()) # freed: claimable again