Files
ethernet 3f4b8840f1 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	pyproject.toml
#	tests/fixtures/resolution_allowlist.json
2026-09-23 07:36:31 -04:00

253 lines
10 KiB
Python

"""Who may drive a profile's Bot Desktop screen: the agent (default) or exactly one human viewer.
The lease is the single truth shared by the RFB bridge (drops human input from non-holders), the
``computer_use`` tool (refuses to act while a human holds control — the person may be typing a
credential, so even screenshots are refused; fail closed rather than trusting the agent to pause
itself) and the Desktop UI (Watch / Take over / Hand back).
Scope: the lease is a TOOL-LEVEL fence, not a property of the X server. ``computer_use`` and the browser
tools consult it; a process the agent starts by hand against the published ``DISPLAY``/``XAUTHORITY``
(the ``terminal`` tool, a script) is inside the documented same-user boundary and is not stopped by it
(bot-screen.md, "Threat model"; #110040).
Authority lives ON DISK, ``<HERMES_HOME>/bot-desktop/lease.json`` under an fcntl lock, because the
processes that must agree do not share memory: ``hermes serve`` (viewer bridge), the messaging
gateway, a CLI turn and isolated workers all drive the same display. Every read goes to the file;
the in-process Condition only wakes local waiters early. ``epoch`` increments on every transition so
an action admitted under one lease can tell that control changed underneath it.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Dict, List, Optional
from hermes_constants import get_hermes_home, hermes_home_key, secure_parent_dir
try:
import fcntl
except ImportError: # Windows/macOS without fcntl: computer_use imports this module on every call, and no
fcntl = None # multi-process Bot Desktop exists there, so the cross-process lock degrades to a no-op.
logger = logging.getLogger(__name__)
AGENT = "agent"
HUMAN = "human"
class HumanHasControl(RuntimeError):
"""Raised by screen-driving tools while a human holds the lease."""
@dataclass
class Lease:
holder: str = AGENT
viewer_id: Optional[str] = None
since: float = field(default_factory=time.time)
reason: str = ""
epoch: int = 0
def as_dict(self) -> Dict[str, object]:
return asdict(self)
def public_view(lease: Lease) -> Dict[str, object]:
"""The lease as anything outside the gateway may see it (RPC results, the ``display.lease`` broadcast,
the CLI): the holder's viewer id is a capability — whoever presents it co-drives or releases the lease —
so it is replaced by a short hash the holder can match against its own id to know it is in control."""
import hashlib
d = lease.as_dict()
d["viewer_id"] = None
d["viewer_hash"] = hashlib.sha256(lease.viewer_id.encode()).hexdigest()[:12] if lease.viewer_id else None
return d
_lock = threading.Condition()
_listeners: List[Callable[[str, Lease], None]] = []
def _path(profile_key: Optional[str]) -> Path:
"""``profile_key`` is the HERMES_HOME path of the profile whose lease is meant (the RFB bridge
serves several profiles from one process); ``None`` means the current profile."""
home = Path(profile_key) if profile_key else get_hermes_home()
return home / "bot-desktop" / "lease.json"
def _read(path: Path) -> Lease:
"""No file = fresh profile, agent holds. A file that exists but cannot be parsed is a torn write
or tampering: fail CLOSED (human holds) — an unreadable lease must never let the agent act on a
screen a human may be using; the next successful write repairs it."""
try:
raw = path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
return Lease()
except OSError:
return Lease(holder=HUMAN, viewer_id="unreadable-lease", reason="lease file unreadable")
try:
data = json.loads(raw)
except ValueError:
data = None
if not isinstance(data, dict) or data.get("holder") not in (AGENT, HUMAN):
return Lease(holder=HUMAN, viewer_id="unreadable-lease", reason="lease file corrupt")
try:
return Lease(**{k: v for k, v in data.items() if k in Lease.__dataclass_fields__})
except TypeError:
return Lease(holder=HUMAN, viewer_id="unreadable-lease", reason="lease file corrupt")
def _private_dir(path: Path) -> None:
"""``bot-desktop/`` owner-only even when the lease is the first thing written there (a takeover can be
recorded before start() ever ran, and the umask would otherwise leave it 0755)."""
path.parent.mkdir(parents=True, exist_ok=True)
secure_parent_dir(path)
def _open_private(path: str | bytes | os.PathLike, flags: int) -> int:
"""``open(..., opener=_open_private)``: the file is created 0600 regardless of the umask."""
return os.open(path, flags, 0o600)
def _write(path: Path, lease: Lease) -> None:
_private_dir(path)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8", opener=_open_private) as fh:
fh.write(json.dumps(lease.as_dict()))
os.replace(tmp, path)
class _locked:
"""Cross-process critical section over the lease file (fcntl on a sibling lock file)."""
def __init__(self, path: Path):
self._lockfile = path.with_suffix(".lock")
self._fh = None
def __enter__(self):
if fcntl is None:
return self
_private_dir(self._lockfile)
self._fh = open(self._lockfile, "a+", encoding="utf-8", opener=_open_private) # noqa: SIM115 — closed in __exit__
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
if self._fh is None:
return
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
self._fh.close()
def get(profile_key: Optional[str] = None) -> Lease:
return _read(_path(profile_key))
def on_change(listener: Callable[[str, Lease], None]) -> Callable[[], None]:
"""Subscribe to lease transitions made IN THIS PROCESS (the gateway broadcasts them to Desktop
clients). Transitions made by another process are observed by reading, not by callback."""
with _lock:
_listeners.append(listener)
def _off() -> None:
with _lock:
if listener in _listeners:
_listeners.remove(listener)
return _off
def _notify(key: str, lease: Lease) -> None:
for cb in list(_listeners):
try:
cb(key, lease)
except Exception: # a broken subscriber must not wedge the handoff
pass
def _transition(profile_key: Optional[str], mutate: Callable[[Lease], bool]) -> Lease:
key, path = hermes_home_key(profile_key) if profile_key else hermes_home_key(), _path(profile_key)
with _locked(path):
lease = _read(path)
if not mutate(lease):
return lease
lease.epoch += 1
_write(path, lease)
with _lock:
_lock.notify_all()
_notify(key, lease)
return lease
def acquire(viewer_id: str, *, profile_key: Optional[str] = None, reason: str = "") -> Lease:
"""Human ``viewer_id`` takes control. Last writer wins: a second viewer evicts the first, and the
RFB bridge closes the evicted socket so its UI drops to view-only."""
def _m(lease: Lease) -> bool:
if lease.holder == HUMAN and lease.viewer_id == viewer_id:
return False # already theirs: no epoch bump, `since` and the reason on screen stay put
# The agent's ask ("please log in to X") stays as the takeover reason: the human needs it
# on screen WHILE they act, not only before they clicked Take over.
lease.holder, lease.viewer_id, lease.since = HUMAN, viewer_id, time.time()
lease.reason = reason or ""
return True
return _transition(profile_key, _m)
def release(viewer_id: Optional[str] = None, *, profile_key: Optional[str] = None,
unless_human: bool = False) -> Lease:
"""Return control to the agent. With ``viewer_id`` only that holder may release (a stale viewer
closing its window must not yank control from the one who took over after it). ``unless_human``
makes a bare release a no-op while any human holds: the decision is taken under the file lock,
so a takeover racing a check-then-release cannot be silently revoked. Callers read the returned
lease's holder to learn whether anything happened."""
def _m(lease: Lease) -> bool:
if unless_human and lease.holder == HUMAN:
logger.info("bot-desktop lease: bare release ignored, a human holds")
return False
if viewer_id is not None and lease.holder == HUMAN and lease.viewer_id != viewer_id:
# Ignored, not an error: the returned lease still shows the real holder. Logged so a
# caller that never inspects the return value leaves a trace.
logger.info("bot-desktop lease: release by %r ignored, another viewer holds", viewer_id)
return False
if lease.holder == AGENT:
# Already the agent's. Bumping the epoch here would make a legitimately admitted in-flight
# agent action (a double-clicked Hand back, a stray CLI stop) look overtaken and get voided.
return False
lease.holder, lease.viewer_id, lease.since, lease.reason = AGENT, None, time.time(), ""
return True
return _transition(profile_key, _m)
def human_holds(profile_key: Optional[str] = None) -> bool:
return get(profile_key).holder == HUMAN
def viewer_may_send_input(viewer_id: str, *, profile_key: Optional[str] = None) -> bool:
lease = get(profile_key)
return lease.holder == HUMAN and lease.viewer_id == viewer_id
def assert_agent_may_act(profile_key: Optional[str] = None) -> Lease:
"""The lease as of now, or ``HumanHasControl``. Callers keep the returned ``epoch`` and compare it
with ``get().epoch`` after an admitted action: a change means a human took over mid-flight."""
lease = get(profile_key)
if lease.holder == HUMAN:
raise HumanHasControl(
"A human has taken over this desktop (they may be entering a credential). Screen actions and "
"captures are refused until they hand control back. Tell the user what you need in your reply.")
return lease
def _reset_for_tests() -> None:
with _lock:
_listeners.clear()
p = get_hermes_home() / "bot-desktop" / "lease.json"
for f in (p, p.with_suffix(".lock"), p.with_suffix(".json.tmp")):
try:
f.unlink()
except OSError:
pass