Files
ethernet 66d54ebf51 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.github/workflows/docker.yml
#	Dockerfile
#	apps/desktop/src/app/settings/about-settings.tsx
#	docker/stage2-hook.sh
2026-09-24 02:23:44 -04:00

633 lines
29 KiB
Python

"""Bot Desktop runtime: one headless Xfce desktop per Hermes profile, served over RFB on a private
Unix socket, viewed and driven from Hermes Desktop.
Layout under ``<HERMES_HOME>/bot-desktop/``: ``display`` (allocated X display number), ``rfb.sock``
(Xvnc RFB Unix socket, 0600), ``Xauthority``, ``env`` (DISPLAY/XAUTHORITY/DBUS_SESSION_BUS_ADDRESS
published by the launcher once Xfce's bus exists), ``launcher.pid``, ``launcher.log``, ``xdg/``
(per-profile XDG_CONFIG_HOME so two profiles never share xfconf). Everything is profile-scoped via
``get_hermes_home()`` so N profiles in one gateway get N desktops: one screen per bot on the shared
machine.
The launcher is ``launcher.sh`` next to this module; :func:`desktop_env` is what cua-driver and headed
Chromium spawns merge in so the agent acts on this profile's screen and nowhere else.
"""
from __future__ import annotations
import contextlib
import logging
import os
import shutil
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_LAUNCHER = Path(__file__).with_name("launcher.sh")
# Display numbers below 10 collide with real seats and default Xvfb recipes (:99 is popular too); scan a
# private band and record the choice so restarts reuse it.
_DISPLAY_MIN, _DISPLAY_MAX = 20, 89
# Binaries the launcher execs; the package hint is per distro family.
REQUIRED_BINARIES = ("Xvnc", "xfwm4", "xfce4-panel", "xfdesktop", "xfsettingsd", "dbus-run-session",
"xauth", "xdpyinfo", "setxkbmap", "xprop")
# Which package in each distro list ships each required binary. Fedora retired the xorg-x11-utils /
# xorg-x11-server-utils umbrellas (per-binary packages since F35) and dnf5 refuses the whole transaction on
# one unknown name, so every binary must map to a package that still resolves; the test suite checks that
# each mapped package is in PACKAGES for its manager.
BINARY_PACKAGES = {
"apt": {"Xvnc": "tigervnc-standalone-server", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
"xfdesktop": "xfdesktop4", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-x11",
"xauth": "xauth", "xdpyinfo": "x11-utils", "setxkbmap": "x11-xkb-utils", "xprop": "x11-utils"},
# tigervnc-x11-server is the real package (tigervnc-server-minimal is only a Provides on it); dbus-run-session
# is in dbus-daemon (dbus-x11 ships dbus-launch only).
"dnf": {"Xvnc": "tigervnc-x11-server", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
"xfdesktop": "xfdesktop", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-daemon",
"xauth": "xorg-x11-xauth", "xdpyinfo": "xdpyinfo", "setxkbmap": "setxkbmap", "xprop": "xprop"},
"pacman": {"Xvnc": "tigervnc", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel", "xfdesktop": "xfdesktop",
"xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus",
"xauth": "xorg-xauth", "xdpyinfo": "xorg-xdpyinfo", "setxkbmap": "xorg-setxkbmap", "xprop": "xorg-xprop"},
}
PACKAGES = {
"apt": ["tigervnc-standalone-server", "xfce4-panel", "xfwm4", "xfdesktop4", "xfce4-settings",
"xfce4-terminal", "dbus-x11", "x11-xserver-utils", "x11-utils", "x11-xkb-utils", "xauth",
"fonts-dejavu-core"],
"dnf": ["tigervnc-x11-server", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings",
"xfce4-terminal", "dbus-daemon", "xsetroot", "xset", "xdpyinfo", "xprop", "xorg-x11-xauth", "setxkbmap",
"dejavu-sans-fonts"],
"pacman": ["tigervnc", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings", "xfce4-terminal", "dbus",
"xorg-xsetroot", "xorg-xset", "xorg-xdpyinfo", "xorg-xprop", "xorg-xauth", "xorg-setxkbmap",
"ttf-dejavu"],
}
def state_dir() -> Path:
return get_hermes_home() / "bot-desktop"
def is_supported_host() -> bool:
return sys.platform.startswith("linux")
def missing_binaries() -> list[str]:
return [b for b in REQUIRED_BINARIES if shutil.which(b) is None]
def package_manager() -> Optional[str]:
for pm in ("apt-get", "dnf", "pacman"):
if shutil.which(pm):
return "apt" if pm == "apt-get" else pm
return None
def install_command() -> Optional[str]:
"""The distro command that installs the Bot Desktop packages, as the human would type it on THIS host:
prefixed with ``sudo`` unless Hermes already runs as root, so it is both what the pane shows and what
:mod:`tools.bot_desktop.install` runs. ``None`` when no package manager is present.
Not a promise that it can run here: see :func:`installable`. The published Docker image supervises
every service under ``s6-setuidgid hermes`` (UID 10000 by default) and ships no ``sudo`` binary, so an
install on a hosted instance is impossible no matter what this returns."""
pm = package_manager()
if pm is None:
return None
pkgs = " ".join(PACKAGES[pm])
body = {
"apt": f"apt-get install -y --no-install-recommends {pkgs}",
"dnf": f"dnf install -y {pkgs}",
"pacman": f"pacman -S --needed --noconfirm {pkgs}",
}[pm]
return body if is_root() else f"sudo {body}"
def is_root() -> bool:
return hasattr(os, "geteuid") and os.geteuid() == 0
def installable() -> bool:
"""Whether :func:`install_command` could actually succeed on this host.
False on an unprivileged process with no ``sudo`` to reach for, which is exactly the published Docker
image: services drop to the ``hermes`` user and no ``sudo`` binary is installed. The packages can only
arrive in the image there, so :func:`start` says that instead of printing a sudo line the user has no
way to run. ``status()`` still reports ``install_command`` for the pane; surfacing this there needs a
wire-contract change and is deliberately out of scope.
"""
if package_manager() is None:
return False
return is_root() or shutil.which("sudo") is not None
@dataclass
class DesktopStatus:
profile: str
supported: bool
installed: bool
missing: list[str]
running: bool
pid: Optional[int]
display: Optional[str]
socket: Optional[str]
geometry: str
install_command: Optional[str]
browser: Optional[str] # headed Chromium the dock's Browser icon and agent-browser share; None = no headed browser
blocker: Optional[str] = None # why start() would refuse right now (memory); None = may start
memory_available_mb: Optional[int] = None
memory_limit_mb: Optional[int] = None
def as_dict(self) -> Dict[str, object]:
return dict(self.__dict__)
def _read(path: Path) -> Optional[str]:
try:
return path.read_text(encoding="utf-8-sig").strip() or None
except OSError:
return None
def _pid_alive(pid: int) -> bool:
"""A zombie is dead for our purposes: a SIGKILLed launcher stays a zombie in the gateway until the next
Popen reaps it, and reporting it as running would hide its orphaned X server behind a live status."""
import psutil
try:
return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
except psutil.Error:
return False
def _create_time(pid: int) -> Optional[float]:
import psutil
try:
return psutil.Process(pid).create_time()
except (psutil.Error, OverflowError, ValueError):
return None
def _launcher_pid() -> Optional[int]:
"""The live launcher's pid, or None. ``launcher.pid`` holds ``"<pid> <create_time>"``: a recycled pid
with a different start time is somebody else's process and must never be reported as ours nor
killed by :func:`stop`. The pre-identity single-number format is treated as not running."""
raw = _read(state_dir() / "launcher.pid")
pid_s, _, born_s = (raw or "").partition(" ")
if not pid_s.isdigit() or not born_s:
return None
try:
pid, born = int(pid_s), float(born_s)
except ValueError:
return None
actual = _create_time(pid)
return pid if actual is not None and abs(actual - born) < 0.01 and _pid_alive(pid) else None
def _recorded_launcher_pid() -> Optional[int]:
"""The pid ``launcher.pid`` names, alive or not (the orphan sweep matches process groups against it)."""
pid_s, _, born_s = (_read(state_dir() / "launcher.pid") or "").partition(" ")
return int(pid_s) if pid_s.isdigit() and born_s else None
_X_LOCK_DIR = Path("/tmp") # no-tmp: ok — X servers write .X<n>-lock here by protocol (tests point it at a scratch dir)
_X_UNIX_TABLE = Path("/proc/net/unix") # the kernel's list of bound Unix sockets (tests point it at a fixture)
def _x_lock_pid(num: int) -> Optional[int]:
try:
return int((_X_LOCK_DIR / f".X{num}-lock").read_text(encoding="utf-8-sig").strip())
except (OSError, ValueError):
return None
def _x_socket_bound(num: int) -> bool:
"""A running X server keeps ``@/tmp/.X11-unix/X<n>`` (abstract namespace) bound for its whole life; the
kernel drops it only when the process exits. A /tmp reaper can remove the lock file under a live Xvnc,
and only this binding then still says the number is taken (a new server on it dies 'already running')."""
try:
lines = _X_UNIX_TABLE.read_text(encoding="utf-8-sig").splitlines()
except OSError:
return False
# no-tmp: ok — detects the X server's display socket at the path the X11 protocol fixes
return any(line.split()[-1].lstrip("@") == f"/tmp/.X11-unix/X{num}" for line in lines if line.strip())
def _display_in_use(num: int) -> bool:
"""A live X server owns ``:num``: its lock file names a running pid, or its X11 socket is bound. A lock
left by a crashed server (dead pid, no socket) does not count, so the number can be reclaimed."""
pid = _x_lock_pid(num)
return (pid is not None and _pid_alive(pid)) or _x_socket_bound(num)
def _reap_orphaned_server(sd: Path) -> bool:
"""Caller holds ``start.lock`` and has established that no live launcher exists. The launcher runs Xvnc in
its own session, so a SIGKILLed launcher leaves the X server alive, holding the display and ``rfb.sock``;
``status()`` keys on the launcher and says stopped, and a naive restart allocates a second server next
to it and overwrites the socket path both now claim. The X lock of the recorded display names that
server: it is ours when it sits in the dead launcher's process group or its command line binds OUR
socket. When a /tmp reaper took the lock too (or the failed launch dropped ``display``), the socket path
on the Xvnc command line is the remaining handle — without it one X server leaks per occurrence. Kill it
(group first), drop the state it left, and report whether anything was signalled."""
import psutil
def _binds_our_socket(cmdline: list) -> bool:
return "Xvnc" in Path(cmdline[0] if cmdline else "").name and str(sd / "rfb.sock") in cmdline
recorded = _read(sd / "display")
pid = _x_lock_pid(int(recorded)) if recorded and recorded.isdigit() else None
if pid is None or not _pid_alive(pid):
pid = next((p.pid for p in psutil.process_iter(["cmdline"]) if _binds_our_socket(p.info["cmdline"] or [])), None)
if pid is None:
return False
launcher = _recorded_launcher_pid()
try:
pgid = os.getpgid(pid) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
cmdline = psutil.Process(pid).cmdline()
except (ProcessLookupError, psutil.Error):
return False
if pgid != launcher and not _binds_our_socket(cmdline):
return False # somebody else's server took the number after we died; never touch it
logger.warning("Bot Desktop launcher %s is gone but its X server (pid %s) survived on :%s; reaping",
launcher, pid, recorded or "?")
_kill_group_then_wait(pgid if pgid == launcher else None, pid)
if recorded:
(_X_LOCK_DIR / f".X{recorded}-lock").unlink(missing_ok=True)
for name in ("launcher.pid", "env", "rfb.sock"):
(sd / name).unlink(missing_ok=True)
return True
def _kill_group_then_wait(pgid: Optional[int], pid: int, grace: float = 2.0) -> None:
"""SIGTERM the group (or the lone pid), SIGKILL whatever is still there after ``grace``."""
def _signal(sig: int) -> None:
with contextlib.suppress(ProcessLookupError, PermissionError):
if pgid is not None:
os.killpg(pgid, sig) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
else:
os.kill(pid, sig)
def _anything_left() -> bool:
# The leader dying first is the common case (bash exits on TERM, Xvnc traps it); the group is
# done only when killpg(0) finds nobody, else a TERM-ignoring descendant keeps the display.
if pgid is None:
return _pid_alive(pid)
try:
os.killpg(pgid, 0) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _reap_if_ours() -> None:
# The launcher was Popen'd by whichever gateway started it; a later gateway that stops it holds
# no Popen, so the dead leader would sit as a zombie in our table (and count as "left").
with contextlib.suppress(ChildProcessError, OSError):
os.waitpid(pid, os.WNOHANG) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
_signal(signal.SIGTERM)
deadline = time.monotonic() + grace
while time.monotonic() < deadline:
_reap_if_ours()
if not _anything_left():
return
time.sleep(0.05)
_signal(signal.SIGKILL) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
time.sleep(0.1)
_reap_if_ours()
# Host-wide (every profile allocates from one band), so it lives outside any profile home. A predictable
# name must not be squattable: XDG_RUNTIME_DIR is the boundary — 0700 from logind, or from
# docker/stage2-hook.sh in containers, which have none.
_ALLOC_LOCK = Path(os.environ.get("XDG_RUNTIME_DIR") or Path.home() / ".cache") / "hermes-bot-desktop-alloc.lock"
@contextlib.contextmanager
def _flocked(path: Path):
import fcntl # windows-footgun: ok — Linux-only runtime (is_supported_host gates start)
with open(path, "a+", encoding="utf-8") as fh: # windows-footgun: ok — Linux-only runtime
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
try:
yield fh
finally:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
def _pick_display() -> int:
"""Caller holds ``_ALLOC_LOCK``. The recorded number is only reused when no OTHER server holds it now:
after profile A stops, B may have taken A's number, and A's launcher must never unlink B's socket."""
recorded = _read(state_dir() / "display")
if recorded and recorded.isdigit() and not _display_in_use(int(recorded)):
return int(recorded)
for num in range(_DISPLAY_MIN, _DISPLAY_MAX + 1):
if not _display_in_use(num):
return num
raise RuntimeError("no free X display number in the Bot Desktop band")
def _allocate_display() -> int:
_ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with _flocked(_ALLOC_LOCK):
return _pick_display()
def desktop_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""``base_env`` (default ``os.environ``) with this profile's DISPLAY/XAUTHORITY/DBUS_SESSION_BUS_ADDRESS
merged in when its desktop is running. Unchanged otherwise, so hosts with a real seat keep it.
Pure: never starts anything (it is called from env builders, status probes and tests)."""
env = dict(os.environ if base_env is None else base_env)
published = published_env()
if published:
touch_activity() # a browser / cua-driver spawn is the agent using its screen
env.update(published)
env.pop("WAYLAND_DISPLAY", None) # X11 desktop; a leaked Wayland socket flips GTK/Chromium backends
from tools.bot_desktop.browser import env_for_agent
env_for_agent(env) # same binary + user-data-dir as the dock's Browser icon
return env
def ensure_started_for_tool() -> None:
"""Tool-boundary hook (``computer_use`` dispatch and the headed Chromium spawn sites of the browser tool): with
``bot_desktop.auto_start`` (opt-in, default off) a Linux host that has NO display and the packages installed gets
its screen started on first use, so a headless gateway works the first time instead of answering "no DISPLAY is
set". Failure is not an error here; the tool's own "no display" diagnosis is the right message then."""
if published_env():
touch_activity()
return
if not _should_auto_start(os.environ):
return
try:
start()
except Exception as exc:
logger.info("Bot Desktop auto-start skipped: %s", exc)
def _should_auto_start(env: Dict[str, str]) -> bool:
if not is_supported_host() or env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"):
return False
if missing_binaries():
return False
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("bot_desktop") or {}
return bool(cfg.get("auto_start", False))
# ---- idle auto-stop -------------------------------------------------------------------------------
# A desktop nobody is using still holds ~220 MiB (plus whatever browser was left open). Every use —
# a computer_use action, a browser spawn onto the screen, a viewer attached, a human takeover — stamps
# ``activity``; the gateway's display watcher stops a screen idle past ``bot_desktop.idle_stop_minutes``
# unless a human holds it. The next use starts it again (auto_start or the pane's Start).
DEFAULT_IDLE_STOP_MINUTES = 30
def touch_activity() -> None:
path = state_dir() / "activity"
try:
path.touch()
os.utime(path, None)
except OSError:
pass
def idle_seconds() -> Optional[float]:
"""Seconds since the last stamped use; None when the screen never recorded one (falls back to the
env file's publish time so a screen started and then forgotten still ages)."""
for name in ("activity", "env"):
try:
return max(0.0, time.time() - (state_dir() / name).stat().st_mtime)
except OSError:
continue
return None
def idle_stop_seconds() -> float:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("bot_desktop") or {}
try:
minutes = float(cfg.get("idle_stop_minutes", DEFAULT_IDLE_STOP_MINUTES))
except (TypeError, ValueError):
minutes = DEFAULT_IDLE_STOP_MINUTES
return max(0.0, minutes) * 60
def stop_if_idle() -> bool:
"""Stop this profile's screen when it has been idle past the limit and no human holds it. True when
it was stopped."""
limit = idle_stop_seconds()
if limit <= 0 or _launcher_pid() is None:
return False
idle = idle_seconds()
if idle is None or idle < limit:
return False
from tools.bot_desktop import lease as _bd_lease
if _bd_lease.get().holder == _bd_lease.HUMAN:
return False
logger.info("Bot Desktop for profile %s idle for %.0f min; stopping", _profile_name(), idle / 60)
return stop()
def published_env() -> Dict[str, str]:
"""Variables the launcher wrote once Xfce's private bus existed; empty when the desktop is down."""
if _launcher_pid() is None:
return {}
raw = _read(state_dir() / "env")
if not raw:
return {}
out: Dict[str, str] = {}
for line in raw.splitlines():
key, sep, value = line.partition("=")
if sep:
out[key.strip()] = value.strip()
return out
def rfb_socket_path() -> Optional[Path]:
sock = state_dir() / "rfb.sock"
return sock if _launcher_pid() is not None and sock.exists() else None
def geometry() -> str:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("bot_desktop") or {}
return str(cfg.get("geometry") or "1440x900")
def status(profile: Optional[str] = None) -> DesktopStatus:
from tools.bot_desktop import browser as _bd_browser
from tools.bot_desktop import resources
missing: list[str] = missing_binaries() if is_supported_host() else list(REQUIRED_BINARIES)
pid = _launcher_pid()
env = published_env()
running = pid is not None and bool(env.get("DISPLAY"))
mem = resources.memory_info() if is_supported_host() else resources.MemoryInfo(None, None)
return DesktopStatus(
profile=profile or _profile_name(),
supported=is_supported_host(),
installed=not missing,
missing=missing,
running=running,
pid=pid,
display=env.get("DISPLAY"),
socket=str(rfb_socket_path()) if rfb_socket_path() else None,
geometry=geometry(),
install_command=install_command() if missing else None,
browser=_bd_browser.executable() if is_supported_host() else None,
# A running screen is never "blocked": the check guards the allocation, not the session.
blocker=None if running or missing or not is_supported_host() else resources.memory_blocker(mem),
memory_available_mb=mem.available_mb,
memory_limit_mb=mem.limit_mb,
)
def _profile_name() -> str:
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return "default"
# The gate lives in ``resources`` so start() and status() cannot disagree about it. Measured in the
# official image: gateway idle 304 MiB, +216 for Xvnc/Xfce, 1073 MiB with one Chromium page. The OOM
# killer picks by score, so on a small instance the casualty is the dashboard or the gateway, not the
# desktop that caused the pressure.
def start(*, wait_seconds: float = 15.0) -> DesktopStatus:
"""Start this profile's desktop (idempotent). Blocks until the launcher publishes its env file or
``wait_seconds`` pass; raises ``RuntimeError`` naming the blocker.
Two locks: the per-profile ``start.lock``, held from the running-check to the launcher's publish so two
start() calls for one profile spawn one launcher (the loser sees it running), and the host-wide
display-allocation lock, held only until this Xvnc has written ``/tmp/.X<n>-lock`` (a second profile
picking the same number before that would fail and its stale-lock cleanup could remove our socket).
Holding it for the whole Xfce bring-up serialized every profile's start behind one desktop launch."""
if not is_supported_host():
raise RuntimeError("Bot Desktop runs on Linux gateway hosts only")
missing = missing_binaries()
if missing:
# Three dead ends: an operator told "unprivileged, no sudo" while running as root hunts the wrong bug.
need = f"Bot Desktop needs {', '.join(missing)} on the gateway host"
if package_manager() is None:
raise RuntimeError(
f"{need}, and no supported package manager (apt/dnf/pacman) is available to install them. "
"Install TigerVNC (Xvnc) and the Xfce core components with this distro's own tooling.")
if not installable():
raise RuntimeError(
f"{need}, and this host cannot install them: the process is unprivileged and there is no "
"sudo. On the published Docker image the packages have to be baked in, so this needs a "
"newer image rather than an install.")
raise RuntimeError(f"{need}. Install: {install_command()}")
sd = state_dir()
sd.mkdir(parents=True, exist_ok=True)
os.chmod(sd, 0o700)
with _flocked(sd / "start.lock"):
if _launcher_pid() is not None and published_env().get("DISPLAY"):
return status()
from tools.bot_desktop import resources
floor = resources.min_free_mb()
mem = resources.memory_info()
if (blocker := resources.memory_blocker(mem, need=floor)) is not None:
raise RuntimeError(blocker)
if mem.available_mb is not None and mem.available_mb < resources.tight_headroom_mb(floor):
logger.warning(
"Bot Desktop starting with %d MB available; a browser with a few pages open can use most "
"of that.", mem.available_mb)
if _launcher_pid() is None:
_reap_orphaned_server(sd)
_ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
return _spawn_and_wait(sd, wait_seconds)
except RuntimeError:
# _pick_display reuses the recorded number first: left in place after a failed launch (e.g. Xvnc
# 'server already running' on it), every retry would pick the same number and the profile wedges.
(sd / "display").unlink(missing_ok=True)
raise
def _spawn_and_wait(sd: Path, wait_seconds: float) -> DesktopStatus:
"""Caller holds ``start.lock``. Takes ``_ALLOC_LOCK`` itself, from picking the number to Xvnc's claim."""
with contextlib.ExitStack() as alloc:
alloc.enter_context(_flocked(_ALLOC_LOCK))
num = _pick_display()
(sd / "display").write_text(str(num), encoding="utf-8")
env_file = sd / "env"
env_file.unlink(missing_ok=True)
child_env = {k: v for k, v in os.environ.items() if k not in {
"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "SESSION_MANAGER"}}
child_env.update({
"HERMES_BD_PROFILE": _profile_name(),
"HERMES_BD_DISPLAY_NUM": str(num),
"HERMES_BD_SOCKET": str(sd / "rfb.sock"),
"HERMES_BD_XAUTH": str(sd / "Xauthority"),
"HERMES_BD_ENV_FILE": str(env_file),
"HERMES_BD_CONFIG_HOME": str(sd / "xdg"),
"HERMES_BD_GEOMETRY": geometry(),
})
from tools.bot_desktop.browser import dock_exec_line, dock_launch
if (browser := dock_launch()) is not None:
# The bare executable (the launcher checks it exists) and the ready-made, spec-quoted Exec= line.
child_env["HERMES_BD_BROWSER_EXEC"] = browser[0]
child_env["HERMES_BD_BROWSER_EXEC_LINE"] = dock_exec_line(*browser)
# Truncated per start: the log is a diagnostic for THIS launch, and nothing rotates it otherwise.
log = open(sd / "launcher.log", "wb") # noqa: SIM115 — handed to the child, closed by it
proc = subprocess.Popen( # windows-footgun: ok — Linux-only runtime (is_supported_host)
["bash", str(_LAUNCHER)], env=child_env, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
start_new_session=True, close_fds=True)
log.close()
born = _create_time(proc.pid)
(sd / "launcher.pid").write_text(f"{proc.pid} {born if born is not None else 0}", encoding="utf-8")
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
if _x_lock_pid(num) is not None:
alloc.close() # the number is Xvnc's now; other profiles may allocate (idempotent)
if proc.poll() is not None:
tail = (sd / "launcher.log").read_bytes()[-2000:].decode("utf-8", "replace")
raise RuntimeError(f"Bot Desktop launcher exited with {proc.returncode}:\n{tail}")
if env_file.exists() and (sd / "rfb.sock").exists():
logger.info("Bot Desktop for profile %s up on :%s", _profile_name(), num)
touch_activity()
return status()
time.sleep(0.1)
# Giving up must take the launch down: left alone, the launcher publishes DISPLAY and rfb.sock a moment
# later and a screen whose start() reported failure stays up as "running". The launcher is its own
# session leader, so its group is exactly this launch (Xvnc, dbus, Xfce) and nothing else.
_kill_group_then_wait(proc.pid, proc.pid)
proc.wait()
for name in ("launcher.pid", "env", "rfb.sock"):
(sd / name).unlink(missing_ok=True)
raise RuntimeError(f"Bot Desktop did not publish its display within {wait_seconds:.0f}s (see {sd / 'launcher.log'})")
def stop() -> bool:
"""Stop this profile's desktop; True when a running launcher (or the X server a dead one left behind)
was signalled."""
if not is_supported_host():
return False
sd = state_dir()
sd.mkdir(parents=True, exist_ok=True)
with _flocked(sd / "start.lock"):
return _stop_locked(sd)
def _stop_locked(sd: Path) -> bool:
pid = _launcher_pid()
if pid is None:
reaped = _reap_orphaned_server(sd)
(sd / "env").unlink(missing_ok=True)
return reaped
# The launcher runs in its own session; killing the group takes Xvnc, dbus and Xfce with it.
_kill_group_then_wait(pid, pid, grace=5.0)
for name in ("launcher.pid", "env", "activity"):
(sd / name).unlink(missing_ok=True)
return True