fix(bot-screen): lease shared across processes, takeover fences in-flight actions, sudo reply pinned to origin, dock Browser is the bot's browser, safe display reuse, install keeps profile scope

Independent review of the PR head found the control boundary only held inside
one process and several claims the code did not back. Each item below was
reproduced, fixed, covered by an invariant test proven red without the fix, and
re-verified live on a real Xvnc/Xfce screen.

- Lease authority on disk. `lease.json` under an fcntl lock in the profile's
  bot-desktop dir; every read goes to the file. `hermes serve` (viewer bridge),
  the messaging gateway, a CLI turn and isolated workers now agree. Live: a
  takeover in process A made `computer_use capture` in process B return
  human_has_control; release in C made B work again.
- Takeover fences admitted actions. `handle_computer_use` re-checks the lease
  under the dispatch lock and discards a result produced after the lease epoch
  changed, so an action admitted before a takeover cannot picture what the human
  typed during approval / backend start-up waits.
- Sudo reply pinned to its origin. `SudoRequest.origin` records the
  (connection, profile) the card came from; SudoDialog answers through
  `requestGatewayForAgent` on that socket, never the foreground gateway. A
  password typed for host A can no longer reach host B. `sudo.expire` and
  `display.install.sudo.expire` now tear the card down (the Desktop never
  handled sudo.expire).
- Dock Browser IS the bot's browser. `tools/bot_desktop/browser.py` resolves one
  identity — the Chromium agent-browser drives + a persistent per-profile
  user-data-dir (`bot-desktop/browser-profile`) — and both sides use it: the
  agent env gets AGENT_BROWSER_EXECUTABLE_PATH / AGENT_BROWSER_PROFILE, the
  dock launcher gets the same exe + --user-data-dir. Live: the bot wrote
  localStorage on http://127.0.0.1:8765 through agent-browser; a dock click and
  a typed URL on the screen showed BOT-WROTE-THIS in Chrome for Testing.
- Safe display reuse. Allocation under a host-wide lock; a recorded number is
  reused only when no live server holds it; the launcher never unlinks a lock
  whose pid is alive. Live: A stopped, B took :20, A restarted on :21, B kept
  running.
- Install worker keeps the caller's profile scope (copy_context carries the
  HERMES_HOME override and the transport); the done event carries the requested
  profile's status.
- Honest scope: `bot_desktop.auto_start` defaults to false (opt-in; Start lives
  in the Screen pane); request_handoff no longer claims a Telegram/Discord
  message was sent — the model relays the ask in its reply; docs match.
This commit is contained in:
teknium1
2026-09-12 09:13:29 -07:00
parent 83244c55c9
commit 2d0a472c17
19 changed files with 409 additions and 113 deletions

View File

@@ -12,12 +12,14 @@ import {
import { $gateway } from '@/store/gateway'
import { setMcpSetupRequest } from '@/store/mcp-setup'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
import {
$vaultCodeRequests,
$vaultSaveLoginRequests,
$vaultUnlockRequests,
clearVaultCodeRequest,
clearVaultSaveLoginRequest,
clearSudoRequest,
clearVaultUnlockRequest,
receiveApprovalRequest,
setSecretRequest,
@@ -203,6 +205,14 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
return true
}
if (event.type === 'sudo.expire' || event.type === 'display.install.sudo.expire') {
// The backend gave up waiting; tear the card down so a late Send cannot go anywhere.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
clearSudoRequest(sessionId ?? undefined, requestId || undefined)
return true
}
if (event.type === 'clarify.expire') {
if (!sessionId) {
return true
@@ -324,6 +334,7 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
setSudoRequest({
requestId,
sessionId: sessionId ?? null,
origin: { connectionId: event.connectionId ?? null, profile: event.profile ?? $activeGatewayProfile.get() },
...(install ? { respondMethod: 'display.install.sudo.respond', description: translateNow('prompts.sudoInstallDesc') } : {})
})

View File

@@ -19,7 +19,7 @@ import { useI18n } from '@/i18n'
import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock, ShieldLock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { $gateway, requestGatewayForAgent } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import {
clearSecretRequest,
@@ -78,10 +78,14 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) {
setSubmitting(true)
try {
await gateway.request<{ status?: string }>(request.respondMethod ?? 'sudo.respond', {
password: value,
request_id: request.requestId
})
const method = request.respondMethod ?? 'sudo.respond'
const reply = { password: value, request_id: request.requestId }
// Pinned to the socket the request came from: the foreground gateway may be another host.
if (request.origin) {
await requestGatewayForAgent<{ status?: string }>(request.origin.connectionId, request.origin.profile, method, reply)
} else {
await gateway.request<{ status?: string }>(method, reply)
}
triggerHaptic('submit')
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {

View File

@@ -103,6 +103,10 @@ export interface SudoRequest extends KeyedPrompt {
respondMethod?: string
/** Description override so the card can say WHAT the password is for. */
description?: string
/** Immutable origin of the request. The reply is sent to THIS socket, never to whichever
* connection happens to be in the foreground when the user presses Send — a password typed for
* host A must not travel to host B. */
origin?: { connectionId: null | string; profile: string }
}
export interface SecretRequest extends KeyedPrompt {

View File

@@ -2259,8 +2259,10 @@ DEFAULT_CONFIG = {
# Desktop where a human can watch, take over (logins, 2FA, CAPTCHAs) and hand back. `hermes desktop`.
"bot_desktop": {
"geometry": "1440x900",
# Start the screen automatically the first time computer_use or a headed browser needs a display.
"auto_start": True,
# Opt-in: start the screen automatically the first time computer_use needs a display on a headless
# host. Off by default so installing TigerVNC for other reasons never yields a screen nobody asked
# for; Hermes Desktop's Screen pane offers Start and this toggle.
"auto_start": False,
},
"computer_use": {
# cua-driver's upstream PostHog telemetry defaults ON; Hermes sets

View File

@@ -62,7 +62,8 @@ async def display_ws(ws: WebSocket) -> None:
from pathlib import Path
sock = Path(info["hermes_home"]) / "bot-desktop" / "rfb.sock"
profile_key = hermes_home_key(info["hermes_home"])
profile_home = str(info["hermes_home"])
profile_key = hermes_home_key(profile_home)
viewer_id = str(info.get("viewer_id") or info.get("user_id") or "viewer")
if not sock.exists():
await ws.close(code=_CLOSE_DESKTOP_GONE, reason="Bot Desktop is not running")
@@ -77,7 +78,7 @@ async def display_ws(ws: WebSocket) -> None:
await ws.accept()
loop = asyncio.get_running_loop()
evicted = asyncio.Event()
held = {"ever": _lease.viewer_may_send_input(viewer_id, profile_key=profile_key)}
held = {"ever": _lease.viewer_may_send_input(viewer_id, profile_key=profile_home)}
def _on_lease(key: str, lease) -> None:
# A viewer that held control during this connection and lost it to ANOTHER human is kicked so
@@ -91,7 +92,7 @@ async def display_ws(ws: WebSocket) -> None:
loop.call_soon_threadsafe(evicted.set)
unsubscribe = _lease.on_change(_on_lease)
rfb_filter = RfbClientFilter(lambda: _lease.viewer_may_send_input(viewer_id, profile_key=profile_key))
rfb_filter = RfbClientFilter(lambda: _lease.viewer_may_send_input(viewer_id, profile_key=profile_home))
async def rfb_to_ws() -> None:
while True:
@@ -136,8 +137,8 @@ async def display_ws(ws: WebSocket) -> None:
unsubscribe()
writer.close()
# Closing the viewer window hands control back; a stale holder never pins the agent out.
if _lease.viewer_may_send_input(viewer_id, profile_key=profile_key):
_lease.release(viewer_id, profile_key=profile_key)
if _lease.viewer_may_send_input(viewer_id, profile_key=profile_home):
_lease.release(viewer_id, profile_key=profile_home)
try:
await ws.close()
except Exception: # already closed by the peer or by an eviction

View File

@@ -0,0 +1,26 @@
"""The dock's Browser and agent-browser resolve to one identity: same executable, same user-data-dir."""
from __future__ import annotations
from tools.bot_desktop import browser, runtime
def test_dock_and_agent_share_browser_identity(tmp_path, monkeypatch):
exe = tmp_path / "chrome"
exe.write_text("#!/bin/sh\n", encoding="utf-8")
exe.chmod(0o755)
monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(exe))
monkeypatch.delenv("AGENT_BROWSER_PROFILE", raising=False)
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
dock_exe, dock_profile = browser.dock_launch()
agent_env = browser.env_for_agent({})
assert dock_exe == agent_env["AGENT_BROWSER_EXECUTABLE_PATH"] == str(exe)
assert dock_profile == agent_env["AGENT_BROWSER_PROFILE"] == str(tmp_path / "bot-desktop" / "browser-profile")
def test_user_pinned_profile_wins(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_BROWSER_PROFILE", str(tmp_path / "mine"))
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
assert browser.profile_dir() == tmp_path / "mine"

View File

@@ -14,7 +14,7 @@ LAUNCHER = Path(__file__).resolve().parents[2] / "tools" / "bot_desktop" / "laun
pytestmark = pytest.mark.linux_only
def _seed(tmp_path: Path, fake_bins: list[str]) -> Path:
def _seed(tmp_path: Path, fake_bins: list[str], browser_exec: str = "") -> Path:
bindir = tmp_path / "bin"
bindir.mkdir()
for name in fake_bins:
@@ -35,13 +35,15 @@ def _seed(tmp_path: Path, fake_bins: list[str]) -> Path:
"HERMES_BD_SOCKET": str(tmp_path / "rfb.sock"), "HERMES_BD_XAUTH": str(tmp_path / "Xauthority"),
"HERMES_BD_ENV_FILE": str(tmp_path / "env"), "HERMES_BD_CONFIG_HOME": str(cfg),
"HERMES_BD_SEED_ONLY": "1",
**({"HERMES_BD_BROWSER_EXEC": browser_exec} if browser_exec else {}),
}
subprocess.run(["bash", str(LAUNCHER)], env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, timeout=30)
return cfg
def test_dock_lists_only_programs_present_on_path(tmp_path):
cfg = _seed(tmp_path, ["xfce4-terminal", "firefox"]) # no thunar, no mousepad, no chrome
chrome = tmp_path / "bin" / "chrome" # the browser is the one runtime.py resolved, never a PATH scan
cfg = _seed(tmp_path, ["xfce4-terminal", "chrome", "firefox"], browser_exec=f"{chrome} --user-data-dir={tmp_path}/bp")
panel = ET.parse(cfg / "xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml") # well-formed or this raises
launcher_ids = [str(p.get("name")) for p in panel.iter("property") if p.get("value") == "launcher"]
execs = sorted(
@@ -50,7 +52,7 @@ def test_dock_lists_only_programs_present_on_path(tmp_path):
for line in (cfg / "xfce4/panel" / pid.replace("plugin-", "launcher-") / "hermes.desktop").read_text(encoding="utf-8").splitlines()
if line.startswith("Exec=")
)
assert execs == ["firefox", "xfce4-terminal"]
assert execs == [f"{chrome} --user-data-dir={tmp_path}/bp", "xfce4-terminal"]
def test_look_is_seeded_with_wallpaper_and_theme(tmp_path):

View File

@@ -65,3 +65,38 @@ def test_computer_use_refuses_every_action_while_a_human_holds_the_screen(monkey
lease.release("human")
done = json.loads(tool.handle_computer_use({"action": "wait_for_human", "seconds": 1}))
assert done["ok"] and done["state"]["holder"] == lease.AGENT
def test_lease_authority_is_shared_across_processes(tmp_path):
"""The gateway that streams the screen and the process running the agent are different processes;
a human takeover in one must refuse actions in the other."""
import os
import subprocess
import sys
lease.acquire("desktop-viewer")
probe = ("import sys; sys.path.insert(0, %r)\n"
"from tools.bot_desktop import lease\n"
"try:\n lease.assert_agent_may_act(); print('AGENT')\n"
"except lease.HumanHasControl:\n print('HUMAN')\n"
"lease.release('desktop-viewer')\n") % os.getcwd()
out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, encoding="utf-8", timeout=30,
stdin=subprocess.DEVNULL, env={**os.environ, "HERMES_HOME": os.environ["HERMES_HOME"]})
assert out.stdout.strip() == "HUMAN", out.stderr
assert lease.get().holder == lease.AGENT, "the other process's release is visible here"
def test_takeover_during_an_admitted_action_discards_its_result(monkeypatch):
"""Approval / backend start-up can take seconds; a human who takes over meanwhile must not have
their keystrokes captured by an action admitted before they did."""
from tools.computer_use import tool
monkeypatch.setattr(tool, "_get_backend", lambda session_id="": object())
def _dispatch_then_takeover(backend, action, args):
lease.acquire("human") # flips while the driver call is in flight
return json.dumps({"ok": True, "action": action, "png_b64": "SECRET"})
monkeypatch.setattr(tool, "_dispatch", _dispatch_then_takeover)
res = json.loads(tool.handle_computer_use({"action": "capture"}))
assert res["code"] == "human_has_control" and "SECRET" not in json.dumps(res)

View File

@@ -0,0 +1,29 @@
"""Bot Desktop runtime: thumbnail and display-number allocation invariants."""
from __future__ import annotations
import sys
from tools.bot_desktop import runtime, thumbnail
def test_no_running_screen_returns_none_without_grabbing(monkeypatch):
monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":99"})
monkeypatch.setattr(runtime, "_launcher_pid", lambda: None)
monkeypatch.setitem(sys.modules, "PIL.ImageGrab", None) # an import would now fail loudly
assert thumbnail.thumbnail_data_url() is None
def test_recorded_display_held_by_a_live_server_is_not_reused(tmp_path, monkeypatch):
"""After profile A stops, B may take A's number; A restarting must pick another rather than
unlink B's socket and lock."""
import os
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path)
(tmp_path / "display").write_text("37", encoding="utf-8")
live = {37: os.getpid()} # :37 is owned by a running server (this very process stands in for it)
monkeypatch.setattr(runtime, "_display_in_use", lambda num: num in live)
monkeypatch.setattr(runtime, "_ALLOC_LOCK", tmp_path / "alloc.lock")
assert runtime._allocate_display() != 37
live.clear()
assert runtime._allocate_display() == 37, "a free recorded number is reclaimed"

View File

@@ -1,14 +0,0 @@
"""Bot Desktop thumbnail: a stopped screen yields no frame and never touches X."""
from __future__ import annotations
import sys
from tools.bot_desktop import runtime, thumbnail
def test_no_running_screen_returns_none_without_grabbing(monkeypatch):
monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":99"})
monkeypatch.setattr(runtime, "_launcher_pid", lambda: None)
monkeypatch.setitem(sys.modules, "PIL.ImageGrab", None) # an import would now fail loudly
assert thumbnail.thumbnail_data_url() is None

View File

@@ -0,0 +1,33 @@
"""display.install runs its worker inside the caller's profile scope."""
from __future__ import annotations
import threading
import pytest
def test_install_worker_keeps_the_requested_profile_scope(tmp_path, monkeypatch):
from hermes_constants import get_hermes_home
from tools.bot_desktop import install, runtime
import tui_gateway.server as server
named = tmp_path / "profiles" / "named"
named.mkdir(parents=True)
monkeypatch.setattr(server, "_profile_home", lambda name: str(named) if name == "named" else None)
monkeypatch.setattr(runtime, "is_supported_host", lambda: True)
monkeypatch.setattr(runtime, "install_command", lambda: "sudo apt-get install -y x")
seen = {}
done = threading.Event()
def fake_install(*, ask_password, on_line, timeout_seconds=900.0):
seen["home"] = str(get_hermes_home())
done.set()
return 0
monkeypatch.setattr(install, "install_packages", fake_install)
monkeypatch.setattr(server, "_broadcast_global_event", lambda *a, **k: None)
resp = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "display.install", "params": {"profile": "named"}})
assert resp["result"]["started"], resp
assert done.wait(5)
assert seen["home"] == str(named)

View File

@@ -0,0 +1,58 @@
"""The bot's browser on its Bot Desktop: one executable, one persistent user-data-dir per profile.
The agent drives Chromium through agent-browser; a human who takes over clicks the dock's Browser
icon. Both must be THE SAME browser — same binary, same ``--user-data-dir`` — or the human logs in
to a jar the bot never sees. Chromium's singleton makes a second launch on the same user-data-dir
open a window in the running instance, which is exactly the hand-over we want.
"""
from __future__ import annotations
import glob
import os
import shutil
from pathlib import Path
from typing import Optional, Tuple
from tools.bot_desktop import runtime
_SYSTEM_BROWSERS = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser")
def profile_dir() -> Path:
"""User-data-dir the bot's browser uses on this profile's screen (``AGENT_BROWSER_PROFILE`` wins)."""
override = os.environ.get("AGENT_BROWSER_PROFILE", "").strip()
if override and os.path.isabs(override):
return Path(override)
return runtime.state_dir() / "browser-profile"
def executable() -> Optional[str]:
"""The Chromium agent-browser launches: an explicit ``AGENT_BROWSER_EXECUTABLE_PATH``, else the newest
Playwright Chromium it bundles, else a system Chrome/Chromium. ``None`` when there is none."""
explicit = os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH", "").strip()
if explicit and os.access(explicit, os.X_OK):
return explicit
from tools.browser_tool_install import _chromium_search_roots
candidates = sorted(
(p for root in _chromium_search_roots() for p in glob.glob(os.path.join(root, "chromium-*", "chrome-linux*", "chrome"))),
key=os.path.getmtime, reverse=True)
for exe in candidates:
if os.access(exe, os.X_OK):
return exe
return next((shutil.which(name) for name in _SYSTEM_BROWSERS if shutil.which(name)), None)
def dock_launch() -> Optional[Tuple[str, str]]:
"""``(executable, user_data_dir)`` for the dock's Browser icon, or ``None`` when no Chromium exists."""
exe = executable()
return (exe, str(profile_dir())) if exe else None
def env_for_agent(env: dict) -> dict:
"""Pin agent-browser to the screen's browser identity unless the user pinned their own."""
env.setdefault("AGENT_BROWSER_PROFILE", str(profile_dir()))
exe = executable()
if exe:
env.setdefault("AGENT_BROWSER_EXECUTABLE_PATH", exe)
return env

View File

@@ -40,8 +40,14 @@ mkdir -p "$XDG_CONFIG_HOME/xfce4/xfconf/xfce-perchannel-xml" "$XDG_CONFIG_HOME/a
export DISPLAY=":$HERMES_BD_DISPLAY_NUM"
export XAUTHORITY="$HERMES_BD_XAUTH"
# Stale lock files from a crashed server block restart.
rm -f "$HERMES_BD_SOCKET" "/tmp/.X${HERMES_BD_DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${HERMES_BD_DISPLAY_NUM}"
# Stale lock files from a crashed server block restart; a lock whose pid is alive belongs to a
# running server (another profile may have taken this number) and is never touched — Xvnc then
# fails to start on it and runtime.py reports that instead of us disrupting the other desktop.
rm -f "$HERMES_BD_SOCKET"
xlock="/tmp/.X${HERMES_BD_DISPLAY_NUM}-lock"
if [[ -e "$xlock" ]] && ! kill -0 "$(tr -d ' ' < "$xlock" 2>/dev/null)" 2>/dev/null; then
rm -f "$xlock" "/tmp/.X11-unix/X${HERMES_BD_DISPLAY_NUM}"
fi
: > "$XAUTHORITY"; chmod 600 "$XAUTHORITY"
xauth -q -f "$XAUTHORITY" add "$DISPLAY" MIT-MAGIC-COOKIE-1 "$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
@@ -134,9 +140,9 @@ if [[ ! -e "$X/xfce4-panel.xml" ]]; then
dock_ids+=("$n")
}
add_launcher "Terminal" utilities-terminal "xfce4-terminal"
for b in google-chrome chromium chromium-browser firefox; do
command -v "$b" >/dev/null 2>&1 && { add_launcher "Browser" internet-web-browser "$b"; break; }
done
# The bot's browser: runtime.py resolves the executable agent-browser drives plus the profile's
# persistent user-data-dir, so a human taking over lands in the bot's own cookie jar.
[[ -n "${HERMES_BD_BROWSER_EXEC:-}" ]] && add_launcher "Browser" internet-web-browser "$HERMES_BD_BROWSER_EXEC"
add_launcher "Files" system-file-manager "thunar"
add_launcher "Text Editor" accessories-text-editor "mousepad"
dock_plugins=""; dock_items=""

View File

@@ -5,22 +5,29 @@ The lease is the single truth shared by the RFB bridge (drops human input from n
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).
Per-process, keyed by ``hermes_home_key()`` so multiplexed profiles never share a lease. Handoff
requests raised by the agent (``request_handoff``) are how it asks for hands and later learns the
human is done: ``wait_for_release`` blocks the tool call until the lease returns to the agent.
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 fcntl
import json
import os
import threading
import time
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Dict, List, Optional
from hermes_constants import hermes_home_key
from hermes_constants import get_hermes_home, hermes_home_key
AGENT = "agent"
HUMAN = "human"
_POLL_SECONDS = 0.25
class HumanHasControl(RuntimeError):
@@ -34,28 +41,63 @@ class Lease:
since: float = field(default_factory=time.time)
reason: str = ""
pending_handoff: Optional[str] = None # agent's reason for asking, until the human takes over
epoch: int = 0
def as_dict(self) -> Dict[str, object]:
return {"holder": self.holder, "viewer_id": self.viewer_id, "since": self.since,
"reason": self.reason, "pending_handoff": self.pending_handoff}
return asdict(self)
_lock = threading.Condition()
_leases: Dict[str, Lease] = {}
_listeners: List[Callable[[str, Lease], None]] = []
def _key(profile_key: Optional[str]) -> str:
return profile_key or hermes_home_key()
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:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return Lease(**{k: v for k, v in data.items() if k in Lease.__dataclass_fields__})
except (OSError, ValueError, TypeError):
return Lease()
def _write(path: Path, lease: Lease) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(lease.as_dict()), encoding="utf-8")
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):
self._lockfile.parent.mkdir(parents=True, exist_ok=True)
self._fh = open(self._lockfile, "a+", encoding="utf-8") # noqa: SIM115 — closed in __exit__
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) # type: ignore[union-attr]
self._fh.close() # type: ignore[union-attr]
def get(profile_key: Optional[str] = None) -> Lease:
with _lock:
return _leases.setdefault(_key(profile_key), Lease())
return _read(_path(profile_key))
def on_change(listener: Callable[[str, Lease], None]) -> Callable[[], None]:
"""Subscribe to lease transitions (gateway broadcasts them to Desktop clients)."""
"""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)
@@ -74,60 +116,66 @@ def _notify(key: str, lease: Lease) -> None:
pass
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."""
key = _key(profile_key)
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:
lease = _leases.setdefault(key, Lease())
lease.holder, lease.viewer_id, lease.since, lease.reason = HUMAN, viewer_id, time.time(), reason
lease.pending_handoff = None
_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:
lease.holder, lease.viewer_id, lease.since, lease.reason = HUMAN, viewer_id, time.time(), reason
lease.pending_handoff = None
return True
return _transition(profile_key, _m)
def release(viewer_id: Optional[str] = None, *, profile_key: Optional[str] = None) -> 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)."""
key = _key(profile_key)
with _lock:
lease = _leases.setdefault(key, Lease())
def _m(lease: Lease) -> bool:
if viewer_id is not None and lease.holder == HUMAN and lease.viewer_id != viewer_id:
return lease
return False
lease.holder, lease.viewer_id, lease.since, lease.reason = AGENT, None, time.time(), ""
lease.pending_handoff = None # "hand back" answers an open request even if nobody formally took over
_lock.notify_all()
_notify(key, lease)
return lease
return True
return _transition(profile_key, _m)
def request_handoff(reason: str, *, profile_key: Optional[str] = None) -> Lease:
"""Agent asks a human to take over (login, 2FA, CAPTCHA, payment). Recorded so the UI can show
why and the bridge can page the user; control itself still flips only on ``acquire``."""
key = _key(profile_key)
with _lock:
lease = _leases.setdefault(key, Lease())
def _m(lease: Lease) -> bool:
lease.pending_handoff = reason
_lock.notify_all()
_notify(key, lease)
return lease
return True
return _transition(profile_key, _m)
def wait_for_release(*, timeout: float, profile_key: Optional[str] = None) -> bool:
"""Block until the agent holds the lease (and no handoff is pending) or ``timeout`` elapses.
True when control is back with the agent."""
key = _key(profile_key)
True when control is back with the agent. Polls the file so a release made by another process
is seen; the local Condition just shortens the wait for same-process transitions."""
path = _path(profile_key)
deadline = time.monotonic() + timeout
with _lock:
while True:
lease = _leases.setdefault(key, Lease())
if lease.holder == AGENT and lease.pending_handoff is None:
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
_lock.wait(remaining)
while True:
lease = _read(path)
if lease.holder == AGENT and lease.pending_handoff is None:
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
with _lock:
_lock.wait(min(remaining, _POLL_SECONDS))
def human_holds(profile_key: Optional[str] = None) -> bool:
@@ -139,16 +187,24 @@ def viewer_may_send_input(viewer_id: str, *, profile_key: Optional[str] = None)
return lease.holder == HUMAN and lease.viewer_id == viewer_id
def assert_agent_may_act(profile_key: Optional[str] = None) -> None:
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; call computer_use action='wait_for_human' "
"to block until then.")
return lease
def _reset_for_tests() -> None:
with _lock:
_leases.clear()
_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

View File

@@ -119,16 +119,32 @@ def _launcher_pid() -> Optional[int]:
def _display_in_use(num: int) -> bool:
return Path(f"/tmp/.X{num}-lock").exists() or Path(f"/tmp/.X11-unix/X{num}").exists()
"""A live X server owns ``:num``: its lock file names a running pid. A lock left by a crashed
server (dead pid) does not count, so the number can be reclaimed."""
lock = Path(f"/tmp/.X{num}-lock")
try:
pid = int(lock.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return False
return _pid_alive(pid)
_ALLOC_LOCK = Path("/tmp/.hermes-bot-desktop-alloc.lock") # host-wide: profiles allocate from one band
def _allocate_display() -> int:
recorded = _read(state_dir() / "display")
if recorded and recorded.isdigit():
return int(recorded)
for num in range(_DISPLAY_MIN, _DISPLAY_MAX + 1):
if not _display_in_use(num):
return num
"""Pick this profile's display number under a host-wide lock. The recorded number is only reused
when no OTHER server holds it now: after profile A stops, B may have taken A's old number, and
A's launcher must never unlink B's socket and lock."""
import fcntl
with open(_ALLOC_LOCK, "a+", encoding="utf-8") as fh: # windows-footgun: ok — Linux-only runtime
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
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")
@@ -141,11 +157,13 @@ def desktop_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
if published:
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): with ``bot_desktop.auto_start`` (default on) a Linux
"""Tool-boundary hook (``computer_use`` dispatch): with ``bot_desktop.auto_start`` (opt-in, default off) a Linux
host that has NO display and the packages installed gets its screen started on first use, so a headless
gateway works the first time instead of answering "no DISPLAY is set". Failure is not an error here;
the tool's own "no display" diagnosis is the right message then."""
@@ -164,7 +182,7 @@ def _should_auto_start(env: Dict[str, str]) -> bool:
return False
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("bot_desktop") or {}
return bool(cfg.get("auto_start", True))
return bool(cfg.get("auto_start", False))
def published_env() -> Dict[str, str]:
@@ -250,6 +268,10 @@ def start(*, wait_seconds: float = 15.0) -> DesktopStatus:
"HERMES_BD_CONFIG_HOME": str(sd / "xdg"),
"HERMES_BD_GEOMETRY": geometry(),
})
from tools.bot_desktop.browser import dock_launch
if (browser := dock_launch()) is not None:
# first-run / default-browser dialogs would sit between the human and the bot's tabs
child_env["HERMES_BD_BROWSER_EXEC"] = f"{browser[0]} --user-data-dir={browser[1]} --no-first-run --no-default-browser-check"
log = open(sd / "launcher.log", "ab") # noqa: SIM115 — handed to the child, closed by it
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,

View File

@@ -3,8 +3,9 @@ take over the screen (login, 2FA, CAPTCHA, payment) and ``wait_for_human`` block
back. Both are answered without touching cua-driver, so a human typing a credential is never
captured.
The notification itself (Desktop pane badge, Telegram/Discord message) is emitted by the gateway's
lease listener; this module only records intent on the lease and waits.
The Desktop pane shows the request (the gateway broadcasts the lease change). Reaching the person
anywhere else is the model's job: it relays the ask in its reply, which is what lands in the chat
surface the user is actually on. This module only records intent on the lease and waits.
"""
from __future__ import annotations
@@ -25,8 +26,9 @@ def handle_handoff(action: str, args: Dict[str, Any]) -> str:
_lease.request_handoff(reason)
return json.dumps({
"ok": True, "action": action, "state": _lease.get().as_dict(),
"next": "The user has been asked to open this bot's Desktop pane and take over. Call "
"computer_use action='wait_for_human' to block until they hand control back, then "
"next": "Hermes Desktop now shows 'Bot needs you' on this bot's Screen. Tell the user in your "
"reply what to do and that they can take over from Bots > Screen, then call "
"computer_use action='wait_for_human' to block until they hand control back and "
"re-capture before continuing — the screen state is whatever they left."})
timeout = min(_MAX_WAIT_SECONDS, max(1.0, float(args.get("seconds") or _DEFAULT_WAIT_SECONDS)))
released = _lease.wait_for_release(timeout=timeout)

View File

@@ -254,10 +254,12 @@ def handle_computer_use(args: Dict[str, Any], **kwargs) -> Any:
# Bot Desktop lease: while a human drives the screen every action, capture included, is refused.
from tools.bot_desktop import lease as _bd_lease
from tools.bot_desktop.runtime import ensure_started_for_tool as _bd_ensure_started
try:
_bd_lease.assert_agent_may_act()
except _bd_lease.HumanHasControl as e:
def _refused(e: Exception) -> str:
return json.dumps({"ok": False, "action": action, "code": "human_has_control", "error": str(e)})
try:
admitted = _bd_lease.assert_agent_may_act()
except _bd_lease.HumanHasControl as e:
return _refused(e)
_bd_ensure_started() # headless gateway: bring the profile's screen up before the backend probes DISPLAY
if (err := _reject_unsafe(action, args)) is not None:
return err
@@ -276,7 +278,19 @@ def handle_computer_use(args: Dict[str, Any], **kwargs) -> Any:
with _backend_lock:
call_lock = _backend_call_locks.setdefault(session_id, threading.RLock())
with call_lock:
return _dispatch(backend, action, args)
# Re-check under the dispatch lock: approval, backend start-up and lock waits above can take
# seconds, and a human may have taken over meanwhile. A result produced after such a flip is
# discarded too — it may picture what they typed.
try:
_bd_lease.assert_agent_may_act()
except _bd_lease.HumanHasControl as e:
return _refused(e)
result = _dispatch(backend, action, args)
if _bd_lease.get().epoch != admitted.epoch and _bd_lease.human_holds():
return _refused(_bd_lease.HumanHasControl(
"A human took over this desktop while the action ran; its result was discarded. Call "
"computer_use action='wait_for_human' to block until they hand control back."))
return result
except Exception as e:
logger.exception("computer_use %s failed", action)
return json.dumps({"error": f"{action} failed: {e}"})

View File

@@ -131,26 +131,26 @@ def _(rid, params: dict) -> dict:
def _line(text: str) -> None:
_broadcast_global_event("display.install.log", {"profile_key": profile_key, "line": text})
# The worker thread has no context-bound transport; the sudo card must reach the CLIENT that
# clicked Install, so the caller's transport is carried across.
from .transport import bind_transport, current_transport
caller_transport = current_transport()
# 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:
bind_transport(caller_transport)
try:
code = _bd_install.install_packages(ask_password=_ask_password, on_line=_line)
except Exception as e:
_line(f"install failed: {e}")
code = 1
_broadcast_global_event("display.install.done", {"profile_key": profile_key, "code": code,
"status": _bd_runtime.status().as_dict()})
"status": _display_snapshot()})
try:
_bd_install.assert_not_running()
except _bd_install.InstallBusy as e:
return _err(rid, _DISPLAY_ERR, str(e))
threading.Thread(target=_run, name=f"bot-desktop-install:{profile_key}", daemon=True).start()
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})

View File

@@ -56,9 +56,10 @@ Every bot's computer is one click away in three places of Hermes Desktop:
its conversations too.
1. Open the Screen with any of the entries above.
The first time, click **Start screen** (or leave `bot_desktop.auto_start` on,
the default: the screen starts on the bot's first `computer_use` call when the
host has no display). A headed browser opens on the screen once it is running.
The first time, click **Start screen**. Set `bot_desktop.auto_start: true` if
you want a headless host to start the screen by itself on the bot's first
`computer_use` call (off by default: installing TigerVNC never yields a screen
nobody asked for). A headed browser opens on the screen once it is running.
2. The pane streams the bot's desktop. The chip in the header says who is in
control: **Bot is in control** by default.
3. Click **Take over**. The border turns red, your keyboard and mouse now drive
@@ -71,18 +72,22 @@ refused with `human_has_control`; the bot never sees what you type.
The bot can ask for you: when it recognises a login or verification step it
calls `computer_use` with `action: "request_handoff"` and a reason, the pane
shows **Bot needs you**, and the bot blocks in `action: "wait_for_human"` until
you hand back. Your Telegram/Discord chat with the bot gets the same request.
shows **Bot needs you**, the bot tells you in its reply what it needs (so the
ask reaches you in whatever chat you are on), and it blocks in
`action: "wait_for_human"` until you hand back.
Two viewers on one screen: the most recent **Take over** wins; the previous
controller drops back to watching.
## Browser sessions that survive the handoff
The bot's headed Chromium (`browser.headed: true` or a real-profile session)
opens on the bot's screen and keeps one persistent profile per bot. What you
sign in to during a takeover is what the bot uses afterwards, and in every later
session for that bot, until the site itself expires the login.
While the screen runs, the bot's browser tool and the dock's **Browser** icon are
the same browser: the Chromium agent-browser drives, with one persistent
user-data-dir per bot (`<HERMES_HOME>/bot-desktop/browser-profile`; set
`AGENT_BROWSER_PROFILE` to pin your own). Click Browser during a takeover and you
are in the bot's own windows and cookie jar; what you sign in to is what the bot
uses afterwards and in every later session, until the site expires the login.
Set `browser.headed: true` so the bot's own browsing is visible on the screen too.
## CLI