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:
@@ -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') } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
26
tests/tools/test_bot_desktop_browser.py
Normal file
26
tests/tools/test_bot_desktop_browser.py
Normal 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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
tests/tools/test_bot_desktop_runtime.py
Normal file
29
tests/tools/test_bot_desktop_runtime.py
Normal 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"
|
||||
@@ -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
|
||||
33
tests/tui_gateway/test_display_methods.py
Normal file
33
tests/tui_gateway/test_display_methods.py
Normal 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)
|
||||
58
tools/bot_desktop/browser.py
Normal file
58
tools/bot_desktop/browser.py
Normal 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
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"})
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user