"""Install the Bot Desktop packages on the gateway host from a Desktop client. The install runs the distro command from ``runtime.install_command()`` (apt/dnf/pacman) as a child process on THIS host. Privilege comes from the same masked ``sudo.request`` card the terminal tool raises: ``sudo -n true`` is probed first (NOPASSWD / cached timestamp hosts never see a prompt); when a password is needed the caller-supplied ``ask_password`` blocks on the card and the value is written to sudo's stdin (``-S``) exactly once, never logged, never placed on the command line. Output lines stream through ``on_line`` so the pane can show apt's progress; the return value is the exit code. One install per profile at a time; a second request while one runs is refused. """ from __future__ import annotations import contextlib import logging import os import selectors import shlex import shutil import signal import subprocess import threading from pathlib import Path import time from typing import Callable, Optional from hermes_constants import hermes_home_key from tools.bot_desktop import runtime logger = logging.getLogger(__name__) _install_lock = threading.Lock() _running: set[str] = set() # Installs are host-global but the gateway (Install card) and the CLI (`screen install`) are separate # processes: the in-process set alone let both drive dpkg for one profile at once. The slot is also a # non-blocking flock on a file in the profile's state dir, held for the life of the install. _slot_files: dict[str, object] = {} NO_SUDO = -2 # install_packages: unprivileged host without sudo; the on_line stream carried the command to run as root class InstallBusy(RuntimeError): pass def _try_flock(path: Path): """Open + non-blocking exclusive flock; None when another process holds it.""" import fcntl # windows-footgun: ok — Linux-only runtime (is_supported_host) path.parent.mkdir(parents=True, exist_ok=True) fh = open(path, "a+", encoding="utf-8") # noqa: SIM115 — held open for the life of the install slot try: fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: fh.close() return None return fh def assert_not_running() -> None: with _install_lock: if hermes_home_key() in _running: raise InstallBusy("an install is already running for this profile") fh = _try_flock(runtime.state_dir() / "install.lock") if fh is None: raise InstallBusy("an install is already running for this profile (another process)") fh.close() def claim() -> str: """Atomically take this profile's install slot; raises :class:`InstallBusy` when taken. A caller that claims before handing off to a worker passes ``claimed=True`` to :func:`install_packages`, which then owns releasing it — a check-then-spawn pair (``assert_not_running`` + later claim on the worker) lets two Install clicks both pass the check.""" key = hermes_home_key() with _install_lock: if key in _running: raise InstallBusy("an install is already running for this profile") fh = _try_flock(runtime.state_dir() / "install.lock") if fh is None: raise InstallBusy("an install is already running for this profile (another process)") _running.add(key) _slot_files[key] = fh return key def release(key: str) -> None: with _install_lock: _running.discard(key) fh = _slot_files.pop(key, None) if fh is not None: fh.close() # closing drops the flock def install_packages(*, ask_password: Callable[[], str], on_line: Callable[[str], None], timeout_seconds: float = 900.0, claimed: bool = False) -> int: """Run the package install; returns the process exit code (0 = success, ``-1`` = cancelled, :data:`NO_SUDO` = unprivileged host without sudo — the command to run by hand was streamed). ``claimed=True``: the caller already holds the slot via :func:`claim`; it is released here either way.""" key = hermes_home_key() if claimed else None try: if not runtime.is_supported_host(): raise RuntimeError("Bot Desktop runs on Linux gateway hosts only") cmd = runtime.install_command() if cmd is None: raise RuntimeError("no supported package manager (apt-get, dnf, pacman) found on this host") if key is None: key = claim() return _run(cmd, ask_password=ask_password, on_line=on_line, timeout_seconds=timeout_seconds) finally: if key is not None: release(key) def _sudo_nopasswd() -> bool: try: return subprocess.run(["sudo", "-n", "true"], capture_output=True, timeout=3, stdin=subprocess.DEVNULL).returncode == 0 except Exception: return False def _run(cmd: str, *, ask_password: Callable[[], str], on_line: Callable[[str], None], timeout_seconds: float) -> int: argv = shlex.split(cmd) stdin_payload: Optional[str] = None if argv[0] == "sudo": if shutil.which("sudo") is None: # Minimal containers ship no sudo: a password card would be a dead end. Hand the human the # exact command for the host instead. on_line(f"install needs root and this host has no sudo; run on the host as root: {cmd[len('sudo '):]}") return NO_SUDO if not _sudo_nopasswd(): password = ask_password() or "" if not password: on_line("install cancelled: no sudo password provided") return -1 # -S: read the password from stdin; -p '': no prompt text mixed into the streamed output. argv = ["sudo", "-S", "-p", "", *argv[1:]] stdin_payload = password + "\n" on_line(f"$ {cmd}") env = {"DEBIAN_FRONTEND": "noninteractive", "LC_ALL": "C.UTF-8"} proc = subprocess.Popen( # windows-footgun: ok — Linux-only (is_supported_host) argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env={**os.environ, **env}, start_new_session=True) try: if stdin_payload is not None: proc.stdin.write(stdin_payload.encode("utf-8")) # type: ignore[union-attr] proc.stdin.close() # type: ignore[union-attr] except OSError: pass try: if _drain_until(proc, on_line, time.monotonic() + timeout_seconds): return proc.wait() _kill_group(proc) on_line(f"install timed out after {timeout_seconds:.0f}s; the package manager may still be running as root") return proc.returncode if proc.returncode is not None else -9 finally: proc.stdout.close() # type: ignore[union-attr] if proc.poll() is None: # the drain raised (a failing on_line sink): the slot is released, so no orphan _kill_group(proc) _TERM_GRACE_SECONDS = 5.0 def _drain_until(proc: subprocess.Popen, on_line: Callable[[str], None], deadline: float) -> bool: """Stream ``proc.stdout`` lines to ``on_line`` until EOF (``True``) or ``deadline`` (``False``). Readiness-polled rather than a blocking ``for line in proc.stdout``: from an unprivileged Hermes no signal reaches a root-owned apt/dnf child, and that child keeps the pipe's write end open, so a blocking read would never see EOF and the profile's install slot would be held forever. """ fd = proc.stdout.fileno() # type: ignore[union-attr] buf = b"" with selectors.DefaultSelector() as sel: sel.register(fd, selectors.EVENT_READ) while True: remaining = deadline - time.monotonic() if remaining <= 0: return False if not sel.select(timeout=min(remaining, 1.0)): continue chunk = os.read(fd, 65536) if not chunk: if buf: on_line(buf.decode("utf-8", "replace")) return True *lines, buf = (buf + chunk).split(b"\n") for line in lines: on_line(line.decode("utf-8", "replace")) def _kill_group(proc: subprocess.Popen) -> None: """The package manager runs in its own session (start_new_session); killing only sudo would leave apt/dnf running as root with the dpkg lock while the slot is released, so the whole group goes: TERM first so dpkg can finish its transaction, KILL after the grace. Best effort — as non-root neither signal reaches a root-owned child, which is why the caller never waits on EOF.""" def _group_gone() -> bool: try: os.killpg(proc.pid, 0) # windows-footgun: ok — Linux-only (is_supported_host) except ProcessLookupError: return True except PermissionError: return False # a root-owned child is still there return False for sig, grace in ((signal.SIGTERM, _TERM_GRACE_SECONDS), (signal.SIGKILL, 1.0)): # windows-footgun: ok — Linux-only (is_supported_host) with contextlib.suppress(ProcessLookupError, PermissionError): os.killpg(proc.pid, sig) # windows-footgun: ok — Linux-only (is_supported_host) with contextlib.suppress(subprocess.TimeoutExpired): proc.wait(timeout=grace) # The leader (sudo / the package manager) going away is not the end: wait for the whole group so a # TERM-ignoring descendant gets the KILL round instead of surviving with the dpkg lock. deadline = time.monotonic() + grace while time.monotonic() < deadline and not _group_gone(): time.sleep(0.05) if _group_gone(): return