843 lines
39 KiB
Python
843 lines
39 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
|
|
from tools.bot_desktop import placement
|
|
|
|
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
|
|
placement: str = "gateway" # "gateway" | "terminal:<backend>" — where Xvnc runs
|
|
image_switch: Optional[dict[str, object]] = None # pending default-image switch the pane can approve
|
|
|
|
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).
|
|
|
|
Under ``terminal`` placement the sandbox screen is brought up on first use unconditionally: the sandbox is
|
|
the boundary the user chose, a screen inside it touches nothing outside it, and the tools below refuse
|
|
rather than run on the host when it is not up. On the gateway host, ``bot_desktop.auto_start`` (opt-in,
|
|
default off) starts a Linux host's screen when it has NO display and the packages installed, so a headless
|
|
gateway works the first time. Host failure is not an error here (the tool's own "no display" diagnosis is
|
|
the right message then); a sandbox start failure is logged and left for :func:`tool_placement` to raise."""
|
|
if published_env().get("DISPLAY"):
|
|
touch_activity()
|
|
return
|
|
where = placement.resolve()
|
|
if where.where == placement.REFUSED:
|
|
return # tool_placement() raises the reason at the spawn site
|
|
if where.where == placement.TERMINAL:
|
|
try:
|
|
start()
|
|
except Exception as exc:
|
|
logger.info("Bot Desktop sandbox start deferred to the tool: %s", exc)
|
|
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 tool_placement() -> str:
|
|
"""Where the browser / cua-driver a tool is about to spawn MUST run: ``placement.GATEWAY`` or
|
|
``placement.TERMINAL``. Policy first, liveness second: a ``terminal`` placement whose screen is down gets
|
|
it started here (raising with the blocker when it cannot come up) and a ``refused`` placement raises its
|
|
reason. Neither ever yields the host — a screen that happens to be down is not permission to run the
|
|
agent's browser outside the sandbox the user chose."""
|
|
where = placement.resolve()
|
|
if where.where == placement.REFUSED:
|
|
raise RuntimeError(where.reason)
|
|
if where.where == placement.GATEWAY:
|
|
return placement.GATEWAY
|
|
if not sandbox_screen_running() or not published_env().get("DISPLAY"):
|
|
start()
|
|
return placement.TERMINAL
|
|
|
|
|
|
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 not is_running():
|
|
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.
|
|
|
|
Pure file reads on the common path: this is called from every browser / cua-driver env builder, so it
|
|
must not load config (that initializes HERMES_HOME). A sandbox-hosted screen leaves a host-side marker at
|
|
start; only its presence routes to the sandbox probe."""
|
|
from tools.bot_desktop import sandbox_host
|
|
if sandbox_host._read_marker():
|
|
return _sandbox_published_env()
|
|
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]:
|
|
"""Host path of the RFB socket; None when down OR when the screen lives in a sandbox (use
|
|
:func:`open_rfb_stream` there: the socket is not on this filesystem)."""
|
|
from tools.bot_desktop import sandbox_host
|
|
if sandbox_host._read_marker():
|
|
return None
|
|
sock = state_dir() / "rfb.sock"
|
|
return sock if _launcher_pid() is not None and sock.exists() else None
|
|
|
|
|
|
def in_sandbox() -> bool:
|
|
"""True when this profile's screen is placed inside the terminal backend (policy; reads config)."""
|
|
return placement.resolve().where == placement.TERMINAL
|
|
|
|
|
|
def sandbox_screen_running() -> bool:
|
|
"""True when a sandbox-hosted screen is UP for this profile: the start marker exists AND the sandbox it
|
|
names is alive — registered in this process, or (after a gateway restart emptied the registry) the
|
|
marker's recorded container still running. Only a sandbox that is provably gone (its container removed
|
|
out from under us) drops the marker, so the next start rebuilds instead of the browser exec-ing into a
|
|
dead container; an unregistered-but-alive one is re-attached by ``_sandbox_env(create=True)`` at the
|
|
next spawn (the terminal planner reuses the persisted container by label)."""
|
|
from tools.bot_desktop import sandbox_host
|
|
marker = sandbox_host._read_marker()
|
|
if not marker:
|
|
return False
|
|
if _sandbox_env(create=False) is not None:
|
|
return True
|
|
if sandbox_host.marker_sandbox_alive(marker):
|
|
return True
|
|
sandbox_host._marker().unlink(missing_ok=True)
|
|
return False
|
|
|
|
|
|
def _owned_sandbox_env():
|
|
"""The environment hosting the screen the marker records, re-attaching after a restart when the recorded
|
|
sandbox is still alive; None when there is no marker or its sandbox is gone. Never builds a sandbox for
|
|
a screen that is not there."""
|
|
from tools.bot_desktop import sandbox_host
|
|
marker = sandbox_host._read_marker()
|
|
if not marker:
|
|
return None
|
|
env = _sandbox_env(create=False)
|
|
if env is not None:
|
|
return env
|
|
if sandbox_host.marker_sandbox_alive(marker):
|
|
return _sandbox_env(create=True)
|
|
return None
|
|
|
|
|
|
def is_running() -> bool:
|
|
return bool(published_env().get("DISPLAY"))
|
|
|
|
|
|
def open_rfb_stream() -> "subprocess.Popen":
|
|
"""Popen whose stdin/stdout carry RFB bytes for a sandbox-hosted screen (``in_sandbox()`` only)."""
|
|
from tools.bot_desktop import sandbox_host
|
|
env = _sandbox_env(create=False)
|
|
if env is None:
|
|
raise RuntimeError("the sandbox hosting this screen is not running")
|
|
return sandbox_host.open_rfb_stream(env, _profile_name())
|
|
|
|
|
|
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
|
|
from tools.bot_desktop import sandbox_host
|
|
where = placement.resolve()
|
|
if where.where == placement.TERMINAL or sandbox_host._read_marker():
|
|
# A screen already running inside a sandbox is reported (and stoppable) even after the placement
|
|
# setting moved: the recorded owner wins over the current policy until it is stopped.
|
|
return _sandbox_status(profile, where)
|
|
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 _sandbox_status(profile: Optional[str], where) -> DesktopStatus:
|
|
"""Status of a sandbox-placed screen. Package presence is only known once the sandbox exists; before
|
|
that the pane shows "installed" with the image hint carried in ``install_command`` so Start can explain."""
|
|
from tools.bot_desktop import sandbox_host
|
|
env = _owned_sandbox_env() or _sandbox_env(create=False)
|
|
missing = sandbox_host.missing_binaries(env) if env is not None else []
|
|
published = sandbox_host.published_env(env, profile or _profile_name()) if env is not None else {}
|
|
running = bool(published.get("DISPLAY"))
|
|
# No install_command: the pane's Install button runs apt on the HOST, which is the wrong machine here.
|
|
# A sandbox missing the stack is a blocker (shown in place of Start) naming the image that has it.
|
|
blocker = None
|
|
image_switch = None
|
|
if missing:
|
|
blocker = (f"The terminal backend's sandbox image lacks {', '.join(missing)}. Use "
|
|
f"{sandbox_host.SANDBOX_IMAGE_HINT} as terminal.{where.backend}_image (the default sandbox base "
|
|
f"plus the desktop stack), or set bot_desktop.placement: gateway.")
|
|
if where.backend == "docker":
|
|
# The usual reason on an upgraded install: the persisted container predates the default
|
|
# flip and was kept on purpose. The pane offers the switch instead of a config hint.
|
|
from hermes_cli.sandbox_image_switch import pending
|
|
sw = pending()
|
|
if sw is not None:
|
|
image_switch = {"current_image": sw.current_image, "target_image": sw.target_image,
|
|
"containers": len(sw.containers)}
|
|
blocker = (f"Your sandbox container still runs {sw.current_image}, which has no desktop. "
|
|
f"Switch it to {sw.target_image}: files in /root and /workspace stay, packages "
|
|
f"installed inside the container are reinstalled on demand.")
|
|
return DesktopStatus(
|
|
profile=profile or _profile_name(),
|
|
supported=True,
|
|
installed=True,
|
|
missing=missing,
|
|
running=running,
|
|
pid=None,
|
|
display=published.get("DISPLAY"),
|
|
socket=None,
|
|
geometry=geometry(),
|
|
install_command=None,
|
|
browser=None,
|
|
blocker=blocker,
|
|
memory_available_mb=None,
|
|
memory_limit_mb=None,
|
|
placement=f"{placement.TERMINAL}:{where.backend}",
|
|
image_switch=image_switch,
|
|
)
|
|
|
|
|
|
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.
|
|
|
|
``bot_desktop.placement`` decides WHERE: inside the configured terminal backend (docker/ssh/singularity;
|
|
``sandbox_host``), or on the gateway host (the rest of this function). A sandbox backend that cannot host
|
|
a screen refuses rather than silently falling back to the host beside it.
|
|
|
|
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."""
|
|
where = placement.resolve()
|
|
if where.where == placement.REFUSED:
|
|
raise RuntimeError(where.reason)
|
|
if where.where == placement.TERMINAL:
|
|
return _start_in_sandbox(wait_seconds)
|
|
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)
|
|
|
|
# The agent drives this desktop and its dock opens a terminal, so it starts from the
|
|
# scrubbed child env like any other agent child, keeping the user's HOME.
|
|
from tools.environments.local import served_profile_child_env
|
|
child_env = served_profile_child_env(inherit_credentials=False)
|
|
child_env["HOME"] = child_env["HERMES_REAL_HOME"]
|
|
for key in ("DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "SESSION_MANAGER"):
|
|
child_env.pop(key, None)
|
|
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")
|
|
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 _sandbox_env(*, create: bool):
|
|
"""The terminal environment hosting this profile's screen, or None. ``create=False`` for status probes
|
|
(a status call must never build a container)."""
|
|
return placement.terminal_environment(create=create)
|
|
|
|
|
|
def _start_in_sandbox(wait_seconds: float) -> DesktopStatus:
|
|
from tools.bot_desktop import sandbox_host
|
|
env = _sandbox_env(create=True)
|
|
if env is None:
|
|
raise RuntimeError("the terminal backend's sandbox could not be started, so there is nowhere to put the screen")
|
|
sd = state_dir()
|
|
sd.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(sd, 0o700)
|
|
with _flocked(sd / "start.lock"):
|
|
# The dock's Browser icon runs the sandbox's own Playwright Chromium on the profile agent-browser
|
|
# uses there: the human's browser is the bot's browser, as on the host.
|
|
from tools.bot_desktop.browser import dock_exec_line
|
|
exe = sandbox_host.chromium_executable(env)
|
|
browser = (exe, dock_exec_line(exe, sandbox_host.browser_profile_dir(env), sandbox_bypass=True)) if exe else (None, None)
|
|
published = sandbox_host.start(env, _profile_name(), geometry=geometry(), wait_seconds=max(wait_seconds, 20.0),
|
|
browser_exec=browser[0], browser_exec_line=browser[1])
|
|
(sd / "env").write_text("".join(f"{k}={v}\n" for k, v in published.items()), encoding="utf-8")
|
|
touch_activity()
|
|
return status()
|
|
|
|
|
|
def _sandbox_published_env() -> dict[str, str]:
|
|
"""Published env of a sandbox-hosted screen (the caller saw the host-side marker written at start)."""
|
|
from tools.bot_desktop import sandbox_host
|
|
env = _sandbox_env(create=False)
|
|
if env is None:
|
|
return {}
|
|
return sandbox_host.published_env(env, _profile_name())
|
|
|
|
|
|
def stop() -> bool:
|
|
"""Stop this profile's desktop; True when a running launcher (or the X server a dead one left behind)
|
|
was signalled."""
|
|
from tools.bot_desktop import sandbox_host
|
|
if sandbox_host._read_marker() or placement.resolve().where == placement.TERMINAL:
|
|
# The marker names the sandbox that owns the screen; stop THAT one (re-attaching after a restart),
|
|
# whatever the placement setting says now. A marker whose sandbox is gone is simply dropped.
|
|
env = _owned_sandbox_env()
|
|
stopped = sandbox_host.stop(env, _profile_name()) if env is not None else False
|
|
sandbox_host._marker().unlink(missing_ok=True)
|
|
for name in ("env", "activity"):
|
|
(state_dir() / name).unlink(missing_ok=True)
|
|
return stopped
|
|
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
|