Files
hermes-agent/tui_gateway/methods_display.py
teknium1 bead012b91 fix(bot-screen): computer-use screen status redacts the lease holder's viewer id like every RPC surface
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
2026-09-18 19:20:01 -07:00

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=("_",))