Files
hermes-agent/tui_gateway/methods_display_watch.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

146 lines
6.1 KiB
Python

"""Cross-process lease watcher: ``display.lease`` for transitions made OUTSIDE ``hermes serve``.
The lease (``tools.bot_desktop.lease``) lives on disk and is changed by whatever process hosts the
agent — a ``hermes computer-use screen`` takeover from the CLI, a cron worker's release.
``lease.on_change`` only fires in the writing process, so ``methods_display``'s in-process
listener never sees those; the Desktop's hero tone and pane state stayed stale until the
pane was reopened. One daemon thread stats every served home's ``bot-desktop/lease.json`` (launch
home + ``_served_profile_homes``) every 0.5s and broadcasts the SAME ``display.lease`` payload when
the epoch moves. Bodies are rebound onto server.py's globals (method_ctx.bind_module).
"""
from __future__ import annotations
import threading
import time
from pathlib import Path
from .method_ctx import bind_module
_LEASE_POLL_S = 0.5
_lease_watcher_started = threading.Event()
# profile key → last epoch broadcast or seen (in-process transitions record theirs too, so a change
# made by THIS process is not re-broadcast when its file write is noticed a tick later).
_lease_epochs: dict[str, int] = {}
_lease_mtimes: dict[str, int | None] = {}
# profile key → (env mtime, launcher.pid mtime, launcher alive): the screen's running/display
# identity. A start or stop made by another process (CLI, gateway auto-start) moves a file; a crash
# leaves both files and only flips liveness.
_runtime_marks: dict[str, tuple] = {}
def _lease_event_payload(profile_key: str, lease) -> dict:
# Same shape and the same redaction (viewer_hash, never the raw id) as the in-process broadcast.
from tools.bot_desktop.lease import public_view
return {"profile_key": profile_key, "lease": public_view(lease)}
def _watched_lease_homes() -> list[Path]:
return [Path(_hermes_home), *_served_profile_homes]
def _mtime(path: Path):
try:
return path.stat().st_mtime_ns
except OSError:
return None
def _poll_runtime_files() -> None:
"""Broadcast ``display.status`` when a home's screen started/stopped outside this process — a
file move (start/stop by the CLI or gateway) or the launcher dying without touching its files
(Xvnc crash: env and launcher.pid stay put, only the pid stops being live)."""
from hermes_constants import hermes_home_key, reset_hermes_home_override, set_hermes_home_override
from tools.bot_desktop import runtime as _bd_runtime
for home in _watched_lease_homes():
key = hermes_home_key(home)
sd = home / "bot-desktop"
token = set_hermes_home_override(home)
try:
mark = (_mtime(sd / "env"), _mtime(sd / "launcher.pid"), _bd_runtime._launcher_pid() is not None)
first = key not in _runtime_marks
if _runtime_marks.get(key) == mark:
continue
_runtime_marks[key] = mark
if first: # seeding: display.status carries the current state
continue
payload = _display_snapshot()
finally:
reset_hermes_home_override(token)
_broadcast_global_event("display.status", payload)
_IDLE_CHECK_S = 30.0
_last_idle_check = 0.0
def _poll_idle_screens() -> None:
"""Every _IDLE_CHECK_S: stop screens idle past ``bot_desktop.idle_stop_minutes`` (runtime.stop_if_idle
holds the rules). The stop moves the runtime files, so _poll_runtime_files broadcasts display.status."""
global _last_idle_check
if time.monotonic() - _last_idle_check < _IDLE_CHECK_S:
return
_last_idle_check = time.monotonic()
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.bot_desktop import runtime as _bd_runtime
for home in _watched_lease_homes():
token = set_hermes_home_override(home)
try:
_bd_runtime.stop_if_idle()
finally:
reset_hermes_home_override(token)
def _poll_lease_files() -> None:
"""One pass: read a home's lease only when its file mtime moved; broadcast when the epoch did."""
from hermes_constants import hermes_home_key
from tools.bot_desktop import lease as _bd_lease
for home in _watched_lease_homes():
key = hermes_home_key(home)
try:
mtime = (home / "bot-desktop" / "lease.json").stat().st_mtime_ns
except OSError:
mtime = None
if mtime == _lease_mtimes.get(key, 0):
continue
_lease_mtimes[key] = mtime
lease = _bd_lease.get(str(home))
if key not in _lease_epochs: # first sighting seeds silently: display.status carries it
_lease_epochs[key] = lease.epoch
continue
if lease.epoch == _lease_epochs[key]:
continue
_lease_epochs[key] = lease.epoch
_broadcast_global_event("display.lease", _lease_event_payload(key, lease))
def _ensure_lease_watcher() -> None:
"""Once per process, from the first display.* call: start the lease-file poll thread and mark
in-process transitions as seen so they broadcast exactly once (via the in-process listener)."""
if _lease_watcher_started.is_set():
return
_lease_watcher_started.set()
from tools.bot_desktop import lease as _bd_lease
def _seen_locally(profile_key: str, lease) -> None:
# methods_display's listener broadcasts in-process transitions; only then is the file
# move ours to skip. Before display.status installed it, the poll below carries them.
if _lease_listener_installed.is_set():
_lease_epochs[profile_key] = lease.epoch
_bd_lease.on_change(_seen_locally)
def _loop() -> None:
while True:
try:
_poll_lease_files()
_poll_idle_screens()
_poll_runtime_files()
except Exception: # noqa: BLE001 - a torn read must not kill the watcher
logger.debug("lease watcher poll failed", exc_info=True)
time.sleep(_LEASE_POLL_S)
threading.Thread(target=_loop, name="hermes-lease-watcher", daemon=True).start()
def register(server) -> None:
bind_module(globals(), server, skip=("_",))