The CLI printed lease.get().as_dict() (--json) and "human (<viewer_id>)" while display.status, the display.lease broadcast and both lease mutations replaced the id with its 12-hex sha256 prefix. The viewer id is a capability (whoever presents it co-drives or releases the lease), and the CLI leaked the one string the rest of the feature deliberately hides. The redaction now has one owner, tools.bot_desktop.lease.public_view(); the RPC handlers, the lease watcher and the CLI all call it, so there is no second hash implementation to drift. The human-readable line shows "human (viewer <hash>)". Closes #110006
247 lines
12 KiB
Python
247 lines
12 KiB
Python
"""Bot Desktop JSON-RPC handlers: the Desktop app's door to a profile's headless screen.
|
|
|
|
``display.status`` reports runtime + lease; ``display.start`` / ``display.stop`` manage the Xvnc/Xfce
|
|
process; ``display.observe`` mints a single-use ticket the renderer redeems on ``/api/display/ws``
|
|
(``hermes_cli.web_routers.display``) to stream raw RFB; ``display.lease.acquire`` / ``release`` are
|
|
Take over / Hand back. ``display.install`` runs the distro package install on the gateway host: sudo
|
|
privilege is asked for through the masked ``display.install.sudo`` server→client request (same ``_ask``
|
|
bridge as the terminal tool's sudo prompt), stdout streams as ``display.install.log`` and the run ends with
|
|
``display.install.done`` carrying a fresh status snapshot. Every handler is profile-scoped so a multiplexed gateway answers for the bot
|
|
the pane is looking at. Lease transitions fan out as the global ``display.lease`` event so every
|
|
connected client repaints (badge on the bot row, red border on the viewer, agent handoff prompt).
|
|
|
|
Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
import weakref
|
|
|
|
from .method_ctx import HandlerRegistry, bind_module
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_registry = HandlerRegistry()
|
|
method = _registry.method
|
|
_profile_scoped = _registry.profile_scoped
|
|
|
|
_DISPLAY_ERR = 5300
|
|
_lease_listener_installed = threading.Event()
|
|
|
|
|
|
def _display_snapshot() -> dict:
|
|
from hermes_constants import hermes_home_key
|
|
from tools.bot_desktop import lease as _bd_lease, runtime as _bd_runtime
|
|
st = _bd_runtime.status()
|
|
return {**st.as_dict(), "lease": _bd_lease.public_view(_bd_lease.get()), "profile_key": hermes_home_key()}
|
|
|
|
|
|
def _install_lease_listener() -> None:
|
|
"""Once per process: broadcast every lease change to all connected clients."""
|
|
if _lease_listener_installed.is_set():
|
|
return
|
|
from tools.bot_desktop import lease as _bd_lease
|
|
|
|
def _on_change(profile_key: str, lease) -> None:
|
|
_broadcast_global_event("display.lease", {"profile_key": profile_key, "lease": _bd_lease.public_view(lease)})
|
|
_bd_lease.on_change(_on_change)
|
|
_lease_listener_installed.set() # only once the subscription exists, or a failed import would silence every client
|
|
|
|
|
|
@method("display.status")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
_install_lease_listener()
|
|
try:
|
|
return _ok(rid, _display_snapshot())
|
|
except Exception as e:
|
|
return _err(rid, _DISPLAY_ERR, str(e))
|
|
|
|
|
|
@method("display.thumbnail")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""One JPEG grab of the bot's screen (``data_url``: null while stopped). Read-only: no lease change.
|
|
Suppressed while a human holds the lease — the frame may show what they are typing — and when
|
|
the lease moved DURING the grab: a takeover racing the framebuffer read means the frame may
|
|
already be the human's session, so it is dropped rather than shipped to every client."""
|
|
try:
|
|
from tools.bot_desktop import lease as _bd_lease
|
|
before = _bd_lease.get()
|
|
if before.holder == _bd_lease.HUMAN:
|
|
return _ok(rid, {"data_url": None, "suppressed": "human_has_control"})
|
|
from tools.bot_desktop.thumbnail import thumbnail_data_url
|
|
data_url = thumbnail_data_url()
|
|
if _bd_lease.get().epoch != before.epoch:
|
|
return _ok(rid, {"data_url": None, "suppressed": "human_has_control"})
|
|
return _ok(rid, {"data_url": data_url})
|
|
except Exception as e:
|
|
return _err(rid, _DISPLAY_ERR, str(e))
|
|
|
|
|
|
@method("display.start")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
_install_lease_listener()
|
|
from tools.bot_desktop import runtime as _bd_runtime
|
|
try:
|
|
_bd_runtime.start()
|
|
return _ok(rid, _display_snapshot())
|
|
except Exception as e:
|
|
return _err(rid, _DISPLAY_ERR, str(e))
|
|
|
|
|
|
@method("display.stop")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Stopping kills the screen under whoever is on it, so it obeys the same rule as a bare
|
|
display.lease.release: refused while a human holds unless the caller says ``force``."""
|
|
from tools.bot_desktop import lease as _bd_lease, runtime as _bd_runtime
|
|
force = bool(params.get("force"))
|
|
# Refusal and release are ONE lease transition: a takeover landing between a separate human_holds()
|
|
# check and the release would be acknowledged to the human and then silently revoked here.
|
|
if _bd_lease.release(unless_human=not force).holder == _bd_lease.HUMAN:
|
|
return _err(rid, _DISPLAY_ERR, "a human holds this screen; pass force: true to stop it anyway",
|
|
data={"code": "viewer_mismatch"})
|
|
try:
|
|
stopped = _bd_runtime.stop()
|
|
return _ok(rid, {**_display_snapshot(), "stopped": stopped})
|
|
except Exception as e:
|
|
return _err(rid, _DISPLAY_ERR, str(e))
|
|
|
|
|
|
# viewer ids minted per connection (keyed by the transport that asked), so a reconnecting pane can
|
|
# keep its identity — and its lease — while nobody can claim an id minted for another connection.
|
|
_minted_viewer_ids: "weakref.WeakKeyDictionary[object, set[str]]" = weakref.WeakKeyDictionary()
|
|
# Transports that cannot be weakly referenced (stdio, slotted, or none bound at all) are one
|
|
# connection per process — the TUI's own pipe — so their minted ids share one process-wide set.
|
|
_unweakable_minted_ids: set[str] = set()
|
|
|
|
|
|
def _minted_for_this_connection() -> set[str]:
|
|
try:
|
|
return _minted_viewer_ids.setdefault(current_transport(), set())
|
|
except TypeError:
|
|
return _unweakable_minted_ids
|
|
|
|
|
|
def _mint_viewer_id(requested: str) -> str:
|
|
"""Server-minted viewer identity. ``requested`` is honoured only when THIS connection minted it
|
|
earlier; anything else (including a holder id read off display.status) gets a fresh id."""
|
|
import secrets
|
|
mine = _minted_for_this_connection()
|
|
if requested in mine:
|
|
return requested
|
|
viewer_id = secrets.token_urlsafe(16)
|
|
mine.add(viewer_id)
|
|
return viewer_id
|
|
|
|
|
|
def _foreign_viewer_id(rid, viewer_id: str):
|
|
"""The error for a viewer id this connection never minted, or None. acquire/release take the id
|
|
as a capability, so one a caller invented or read off the wire must be refused — otherwise the
|
|
minting only shapes the honest path and a made-up id still evicts or releases the human."""
|
|
if viewer_id in _minted_for_this_connection():
|
|
return None
|
|
return _err(rid, _DISPLAY_ERR, "viewer_id was not minted for this connection; call display.observe first",
|
|
data={"code": "viewer_mismatch"})
|
|
|
|
|
|
@method("display.observe")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Mint a single-use, 30 s ticket for ``/api/display/ws``. The ticket carries the profile home so
|
|
the bridge dials THIS profile's RFB socket, and a server-minted viewer id (returned to the caller,
|
|
who passes it to ``display.lease.acquire`` / ``release``) so the lease can name the holder."""
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
|
|
from tools.bot_desktop import runtime as _bd_runtime
|
|
try:
|
|
if _bd_runtime.rfb_socket_path() is None:
|
|
return _err(rid, _DISPLAY_ERR, "this profile's Bot Desktop is not running; call display.start first")
|
|
viewer_id = _mint_viewer_id(str(params.get("viewer_id") or "").strip())
|
|
ticket = mint_ticket(user_id=f"display:{viewer_id}", provider="bot-desktop",
|
|
extra={"hermes_home": str(get_hermes_home()), "viewer_id": viewer_id})
|
|
return _ok(rid, {"ticket": ticket, "path": "/api/display/ws", "viewer_id": viewer_id,
|
|
**_display_snapshot()})
|
|
except Exception as e:
|
|
return _err(rid, _DISPLAY_ERR, str(e))
|
|
|
|
|
|
@method("display.install")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Start the package install in the background; the renderer follows ``display.install.log`` /
|
|
``display.install.done``. Refused while one is already running for this profile."""
|
|
from hermes_constants import hermes_home_key
|
|
from tools.bot_desktop import install as _bd_install, runtime as _bd_runtime
|
|
if not _bd_runtime.is_supported_host():
|
|
return _err(rid, _DISPLAY_ERR, "Bot Desktop runs on Linux gateway hosts only")
|
|
if _bd_runtime.install_command() is None:
|
|
return _err(rid, _DISPLAY_ERR, "no supported package manager (apt-get, dnf, pacman) on this host")
|
|
profile_key = hermes_home_key()
|
|
|
|
def _ask_password() -> str:
|
|
# App-level card, no session: it reaches the connection that clicked Install through the
|
|
# transport copy_context() carries below. A client-supplied session_id could route the
|
|
# masked password card into another window's chat, so none is accepted.
|
|
return _ask("display.install.sudo", "", {"profile_key": profile_key}, timeout=300)
|
|
|
|
def _line(text: str) -> None:
|
|
_broadcast_global_event("display.install.log", {"profile_key": profile_key, "line": text})
|
|
|
|
# The worker thread inherits NO context: the caller's transport (so the sudo card reaches the
|
|
# CLIENT that clicked Install) and the profile scope `_profile_scoped` installed (so status, lock
|
|
# and events all speak for the requested profile) are carried across with copy_context().
|
|
import contextvars
|
|
ctx = contextvars.copy_context()
|
|
|
|
def _run() -> None:
|
|
try:
|
|
code = _bd_install.install_packages(ask_password=_ask_password, on_line=_line, claimed=True)
|
|
except Exception as e:
|
|
_line(f"install failed: {e}")
|
|
code = 1
|
|
_broadcast_global_event("display.install.done", {"profile_key": profile_key, "code": code,
|
|
"status": _display_snapshot()})
|
|
|
|
try:
|
|
_bd_install.claim() # atomic: two fast clicks cannot both start a package manager
|
|
except _bd_install.InstallBusy as e:
|
|
return _err(rid, _DISPLAY_ERR, str(e))
|
|
threading.Thread(target=ctx.run, args=(_run,), name=f"bot-desktop-install:{profile_key}", daemon=True).start()
|
|
return _ok(rid, {"started": True, "command": _bd_runtime.install_command(), "profile_key": profile_key})
|
|
|
|
|
|
@method("display.lease.acquire")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
from tools.bot_desktop import lease as _bd_lease
|
|
viewer_id = str(params.get("viewer_id") or "").strip()
|
|
if not viewer_id:
|
|
return _err(rid, _DISPLAY_ERR, "viewer_id required")
|
|
if (refused := _foreign_viewer_id(rid, viewer_id)) is not None:
|
|
return refused
|
|
lease = _bd_lease.acquire(viewer_id, reason=str(params.get("reason") or ""))
|
|
return _ok(rid, {"lease": _bd_lease.public_view(lease)})
|
|
|
|
|
|
@method("display.lease.release")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
from tools.bot_desktop import lease as _bd_lease
|
|
viewer_id = str(params.get("viewer_id") or "").strip() or None
|
|
# lease.release(None) skips the holder check; a client that lost its viewer id must not be able to
|
|
# yank control from whoever holds it unless it says so explicitly (force).
|
|
if viewer_id is not None and not params.get("force") and (refused := _foreign_viewer_id(rid, viewer_id)) is not None:
|
|
return refused
|
|
# Same atomicity as display.stop: the "is a human holding?" decision happens inside the transition.
|
|
lease = _bd_lease.release(viewer_id, unless_human=viewer_id is None and not params.get("force"))
|
|
if viewer_id is None and lease.holder == _bd_lease.HUMAN:
|
|
return _err(rid, _DISPLAY_ERR, "viewer_id required to release another viewer's lease (or pass force: true)",
|
|
data={"code": "viewer_mismatch"})
|
|
return _ok(rid, {"lease": _bd_lease.public_view(lease)})
|
|
|
|
|
|
def register(server) -> None:
|
|
bind_module(globals(), server, skip=("_",))
|