Files
hermes-agent/tools/computer_use/cua_backend.py
T

520 lines
25 KiB
Python

"""Cua-driver backend (macOS, Windows, Linux): MCP over stdio to `cua-driver`.
The async `mcp` SDK runs on a background loop (``cua_backend_session``); the
same tool surface works on all three platforms, and per-host gaps (no DISPLAY,
missing AT-SPI, TCC) surface via `hermes computer-use doctor` instead of
failing silently. Install with `hermes computer-use install`. The macOS path
uses private SkyLight SPIs that can break on OS updates.
Siblings: ``cua_backend_driver`` (binary resolution, runtime contract, update
check), ``cua_backend_capture`` / ``cua_backend_input`` (backend mixins),
``cua_backend_parse`` (pure parsing), ``cua_backend_session`` (bridge + session
+ CLI fallback), ``cua_backend_daemon`` (private daemon + macOS app identity).
Moved names are re-imported here so ``patch("tools.computer_use.cua_backend.X")``
keeps working; siblings look policy helpers up lazily through this module.
"""
from __future__ import annotations
import logging
import os
import shutil # noqa: F401 (tests patch cua_backend.shutil / .subprocess / .threading)
import subprocess
import sys
import threading
import uuid
from typing import Any, Dict, List, Optional
from hermes_cli._subprocess_compat import windows_hide_flags
from tools.computer_use.backend import ActionResult, ComputerUseBackend
from tools.computer_use.cua_backend_capture import ( # noqa: F401
_CaptureMixin, _linux_x11_active_window_id, _select_capture_target,
)
from tools.computer_use.cua_backend_daemon import ( # noqa: F401
_EmbeddedCuaDaemon, _embedded_daemon_spawn_command, _resolve_cua_driver_app_path,
_validate_cua_driver_app_signature,
)
from tools.computer_use.cua_backend_driver import ( # noqa: F401
_CUA_DRIVER_ARGS, _CUA_DRIVER_CMD_ENV, _cua_driver_supports_no_overlay,
_mcp_args_with_overlay_flag, _resolve_mcp_invocation, _wsl_windows_path_to_posix,
cua_driver_binary_available, cua_driver_install_hint, cua_driver_runtime_contract_status,
cua_driver_update_check, cua_driver_update_nudge, resolve_cua_driver_cmd,
)
from tools.computer_use.cua_backend_input import _InputMixin
from tools.computer_use.cua_backend_parse import ( # noqa: F401
_action_result_from, _extract_tool_result, _image_dimensions_from_bytes, _ingest_windows,
_is_placeholder_id, _parse_elements_from_structured, _parse_elements_from_tree,
_parse_key_combo, _parse_xprop_net_active_window, _windows_from_tool_result,
)
from tools.computer_use.cua_backend_session import _AsyncBridge, _CuaDriverSession # noqa: F401
logger = logging.getLogger(__name__)
# cua-driver's anonymous PostHog telemetry gate ("0" disables; absent => ON upstream).
_CUA_TELEMETRY_ENV_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED"
# ---------------------------------------------------------------------------
# Config-derived policy
# ---------------------------------------------------------------------------
def _computer_use_cfg() -> Dict[str, Any]:
"""The ``computer_use`` config block, or ``{}`` when config is unreadable."""
try:
from hermes_cli.config import load_config
return (load_config() or {}).get("computer_use") or {}
except Exception:
return {}
def _cua_no_overlay() -> bool:
"""Pass ``--no-overlay``? ``computer_use.no_overlay`` overrides; else off on
macOS (cursor-overlay redraw loop can peg a core after a session), headless
Linux / WSL2 / containers, and Linux X11 (the overlay is a fullscreen
always-on-top all-workspaces window with no compositor-owned lifecycle, so
an unclean session end can leave it wedged over every app); on for Windows
and Linux Wayland (compositor owns the surface)."""
val = _computer_use_cfg().get("no_overlay")
if val is not None:
return bool(val)
if sys.platform == "darwin":
return True
if sys.platform != "linux":
return False
if not os.environ.get("DISPLAY"):
return True
try:
with open("/proc/version", encoding="utf-8") as f:
if "microsoft" in f.read().lower():
return True
except Exception:
pass
return os.environ.get("XDG_SESSION_TYPE") != "wayland" and not os.environ.get("WAYLAND_DISPLAY")
def _cua_telemetry_disabled() -> bool:
"""True unless ``computer_use.cua_telemetry`` opts in (unreadable config
fails SAFE toward disabling telemetry)."""
return not bool(_computer_use_cfg().get("cua_telemetry", False))
def _cua_configured_permission_mode() -> str:
"""``computer_use.permission_mode``: ``standard`` (default) or ``bounded``;
unknown values fall closed to ``standard``. ``unrestricted`` is deliberately
NOT a config value — it stays tied to the per-session YOLO toggle so a stale
config line can never silently bypass approvals."""
raw = str(_computer_use_cfg().get("permission_mode", "standard") or "").strip().lower()
return raw if raw in {"standard", "bounded"} else "standard"
def _cua_capability_manifest() -> Optional[str]:
"""``computer_use.capability_manifest`` path, or None. Existence is
validated by ``_EmbeddedCuaDaemon`` so a missing file fails loudly."""
raw = _computer_use_cfg().get("capability_manifest")
return raw.strip() if isinstance(raw, str) and raw.strip() else None
def _manifest_is_mode_independent(path: str) -> bool:
"""True when this manifest may accompany any permission mode: v1/v2 declare
``mode: bounded`` and abort startup under an unrestricted runtime; v3 has no
mode and is the ceiling the driver accepts alongside any mode. Unreadable /
unparseable -> False (forwarding one would turn a working session into a hard
startup failure; bounded forwards unconditionally anyway)."""
try:
import yaml
with open(path, "r", encoding="utf-8") as handle:
parsed = yaml.safe_load(handle)
except Exception:
logger.debug("could not read capability manifest %s", path, exc_info=True)
return False
version = parsed.get("version") if isinstance(parsed, dict) else None
return isinstance(version, int) and not isinstance(version, bool) and version >= 3
def _computer_use_max_image_dimension() -> Optional[int]:
"""``computer_use.max_image_dimension`` longest-edge cap (default 1456,
matching the aux-vision downscale); ``0``/negative -> None (unset)."""
try:
dim = int(_computer_use_cfg().get("max_image_dimension", 1456))
except (TypeError, ValueError):
return 1456
return dim if dim > 0 else None
def cua_driver_child_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Env for spawning cua-driver: ``base_env`` (default ``os.environ``) plus
``CUA_DRIVER_RS_TELEMETRY_ENABLED=0`` unless the user opted in. Used by
every spawn site (MCP, status, doctor, install) so the policy is uniform."""
env = dict(base_env if base_env is not None else os.environ)
if _cua_telemetry_disabled():
env[_CUA_TELEMETRY_ENV_VAR] = "0"
return env
def sanitized_cua_driver_env() -> Dict[str, str]:
"""``cua_driver_child_env()`` with Hermes provider secrets stripped —
cua-driver is a third-party binary and must never inherit API keys.
Falls back to the unsanitized telemetry env if the sanitizer can't import."""
env = cua_driver_child_env()
try:
from tools.environments.local import _sanitize_subprocess_env
return _sanitize_subprocess_env(env)
except Exception:
return env
def _run_driver(driver_cmd: str, *args: str, timeout: float) -> subprocess.CompletedProcess:
"""Run a short cua-driver verb with the sanitized env, hidden window and
stdin=DEVNULL (older drivers fall into a stdin-reading mode on unknown
verbs; EOF makes them exit fast instead of blocking until the timeout)."""
return subprocess.run(
[driver_cmd, *args],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
env=sanitized_cua_driver_env(),
)
# ---------------------------------------------------------------------------
# Linux display diagnostics
# ---------------------------------------------------------------------------
def _linux_session_locked() -> Optional[bool]:
"""Is the graphical session locked? (Linux; best-effort.) A locked KDE/GNOME
session freezes renderers and half-disables the AX tree, so discovery
legitimately returns nothing — which otherwise reads as a driver bug.
True/False when loginctl answers, None when unavailable (non-Linux, no
systemd-logind, probe failure)."""
if sys.platform != "linux":
return None
def _loginctl(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["loginctl", *args], capture_output=True, text=True,
timeout=2.0, stdin=subprocess.DEVNULL)
try:
proc = _loginctl("list-sessions", "--no-legend")
if proc.returncode != 0:
return None
any_seat = False
for line in proc.stdout.splitlines():
parts = line.split()
if len(parts) < 2 or "seat" not in line:
continue
any_seat = True
if "LockedHint=no" in _loginctl("show-session", parts[0], "-p", "LockedHint").stdout:
return False
return True if any_seat else None
except Exception:
return None
def _empty_discovery_reason() -> str:
"""One-line diagnosis for 'window discovery found nothing'."""
if _linux_session_locked() is True:
return (
"the desktop session is LOCKED (loginctl LockedHint=yes) — "
"unlock the screen; a locked compositor hides windows and "
"freezes app renderers"
)
if sys.platform == "linux" and not os.environ.get("DISPLAY"):
return "no DISPLAY is set — X11/XWayland is not reachable from this process"
if sys.platform == "darwin":
# Headless Mac / asleep panel: ScreenCaptureKit has 0 shareable
# displays while TCC grants look fine.
return (
"window discovery returned no windows; on macOS this usually "
"means no shareable display (headless Mac or panel asleep) — "
"wake the display or attach a monitor/HDMI dummy, then run "
"`hermes computer-use doctor`"
)
return (
"window discovery returned no windows; run `hermes computer-use "
"doctor` (display reachability, AX capability)"
)
# ---------------------------------------------------------------------------
# One-shot start() helpers: auto-repair + update nudge
# ---------------------------------------------------------------------------
_update_checked = False
# One auto-repair attempt per process: when the runtime-contract gate fails
# for something a reinstall fixes (old version, missing manifest verbs) run
# the standard install path once instead of telling the user to. Guarded so a
# failing installer can't loop — the second start() goes straight to the error.
_contract_repair_attempted = False
def _maybe_repair_runtime_contract(contract: Dict[str, Any]) -> Dict[str, Any]:
"""Try one automatic driver repair; return the post-repair contract (or the
original when no repair was attempted / it failed). Never raises. An
explicit ``HERMES_CUA_DRIVER_CMD`` override is authoritative even when
broken, and a missing binary means installation was never requested."""
global _contract_repair_attempted
if (
contract.get("ready")
or _contract_repair_attempted
or os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip()
or not contract.get("binary")
):
return contract
_contract_repair_attempted = True
logger.info(
"computer_use: installed cua-driver is not usable (%s); "
"attempting automatic repair",
contract.get("reason") or "runtime contract is incomplete",
)
try:
from hermes_cli.tools_config import install_cua_driver
if not install_cua_driver(upgrade=False, show_installer_progress=False):
return contract
except Exception as exc:
logger.warning("computer_use: automatic cua-driver repair failed: %s", exc)
return contract
try:
return cua_driver_runtime_contract_status()
except Exception:
return contract
def _maybe_nudge_update() -> None:
"""Emit an update nudge at most once per process, off-thread so the
(cached, ~20h) GitHub poll never blocks the first computer_use action."""
global _update_checked
if _update_checked:
return
_update_checked = True
def _run() -> None:
try:
msg = cua_driver_update_nudge()
except Exception:
return
if msg:
logger.info("computer_use: %s", msg)
threading.Thread(target=_run, name="cua-driver-update-check", daemon=True).start()
# ---------------------------------------------------------------------------
# The backend itself
# ---------------------------------------------------------------------------
class CuaDriverBackend(_CaptureMixin, _InputMixin, ComputerUseBackend):
"""Default computer-use backend. Cross-platform via cua-driver MCP."""
def __init__(self, permission_mode: str = "standard") -> None:
if permission_mode not in {"standard", "bounded", "unrestricted"}:
raise ValueError(f"unsupported cua-driver permission mode: {permission_mode}")
self.permission_mode = permission_mode
self._embedded_daemon: Optional[_EmbeddedCuaDaemon] = None
if permission_mode != "standard":
# The manifest is mandatory for bounded (the daemon validates it)
# and optional for unrestricted, where it still caps what an
# approval-bypassed run may touch.
self._embedded_daemon = _EmbeddedCuaDaemon(
resolve_cua_driver_cmd() or "",
permission_mode,
capability_manifest=_cua_capability_manifest(),
)
self._bridge = _AsyncBridge()
self._session = _CuaDriverSession(self._bridge, self._embedded_daemon)
# Sticky target — set by capture()/focus_app(), used by actions.
self._active_pid: Optional[int] = None
self._active_window_id: Optional[int] = None
self._last_app: Optional[str] = None
# Exact identity for capture_after: Linux app names may be generic
# (several unrelated Qt windows can all say Qt6Application).
self._last_target: Optional[Dict[str, Optional[int]]] = None
# Per-snapshot `element_index -> element_token`; actions attach it so
# cua-driver reports "stale" instead of silently re-resolving.
self._snapshot_tokens: Dict[int, str] = {}
# Public session label (one per Hermes run) sent as `session` on every
# call: owns the cursor color and gives config/recording state a stable
# owner across transport restarts. Part of the 0.20 runtime contract.
self._session_id: str = f"hermes-{uuid.uuid4().hex[:12]}"
self._session.set_transport_reset_callback(self._handle_transport_reset)
def _handle_transport_reset(self) -> None:
"""Invalidate every capability minted by the replaced transport."""
self._clear_active_target()
# ── Lifecycle ──────────────────────────────────────────────────
def start(self) -> None:
contract = cua_driver_runtime_contract_status()
if not contract.get("ready"):
contract = _maybe_repair_runtime_contract(contract)
if not contract.get("ready"):
reason = contract.get("reason") or "runtime contract is incomplete"
repair = (
"Update the binary selected by HERMES_CUA_DRIVER_CMD or remove that override."
if os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip()
else "Run `hermes computer-use install` to repair it."
)
raise RuntimeError(f"cua-driver is not ready: {reason}. {repair}")
_maybe_nudge_update()
# `mcp` is an optional extra: lazy-install on first use (gated by
# `security.allow_lazy_installs`); failure raises FeatureUnavailable
# with the exact `uv pip install` hint.
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("tool.computer_use", prompt=False)
import importlib
importlib.invalidate_caches() # a just-installed package may not be importable yet
try:
if self._embedded_daemon is not None:
self._embedded_daemon.start()
self._session.start()
except Exception:
if self._embedded_daemon is not None:
self._embedded_daemon.stop()
raise
# Declare this run's identity. Non-fatal: cua-driver accepts anonymous
# calls (the cursor just won't render), so degrade rather than abort.
self._best_effort("start_session failed (continuing anonymous)",
self._session.call_tool, "start_session", {"session": self._session_id})
# Post-handshake tuning guards on `_started`: before the handshake flips
# it, call_tool would re-enter session.start() (stubbed start() recurses).
if self._session._started:
# Smaller screenshots cost less over the daemon socket and per turn.
max_dim = _computer_use_max_image_dimension()
if max_dim:
self._best_effort("set_config(max_image_dimension) failed",
self.set_config, max_image_dimension=max_dim)
# Belt-and-suspenders when --no-overlay is unsupported or ignored.
if _cua_no_overlay():
self._best_effort("set_agent_cursor_enabled failed",
self.set_agent_cursor_enabled, False, cursor_id=self._session_id)
def stop(self) -> None:
# Best-effort end_session so the driver cleans per-session state (cursor
# overlay, recording ownership, config overrides); the connection drop
# below releases daemon-side state regardless.
if self._session._started:
self._best_effort("end_session failed (continuing teardown)",
self._session.call_tool, "end_session", {"session": self._session_id})
try:
self._session.stop()
finally:
try:
self._bridge.stop()
finally:
if self._embedded_daemon is not None:
self._embedded_daemon.stop()
@staticmethod
def _best_effort(what: str, fn, *args: Any, **kwargs: Any) -> None:
"""Run a non-fatal driver call, logging (debug) instead of raising."""
try:
fn(*args, **kwargs)
except Exception as e:
logger.debug("cua-driver %s: %s", what, e)
def is_available(self) -> bool:
# Other Unix-likes haven't been exercised end-to-end.
return sys.platform in ("darwin", "win32", "linux") and cua_driver_binary_available()
# ── Target state ───────────────────────────────────────────────
def _clear_active_target(self) -> None:
"""Forget a capture/focus target so a failed lookup cannot misroute input."""
self._active_pid = None
self._active_window_id = None
self._last_app = None
self._last_target = None
self._snapshot_tokens = {}
def _set_active_target(self, target: Dict[str, Any]) -> None:
self._active_pid = target["pid"]
self._active_window_id = target["window_id"]
# Tokens belong to the prior snapshot; disarm before any capture call
# so an exception cannot pair old tokens with this target.
self._snapshot_tokens = {}
self._last_target = {"pid": self._active_pid, "window_id": self._active_window_id}
# ── App lifecycle / focus ─────────────────────────────────────────
def launch_app(
self,
*,
bundle_id: Optional[str] = None,
name: Optional[str] = None,
urls: Optional[List[str]] = None,
additional_arguments: Optional[List[str]] = None,
creates_new_application_instance: bool = False,
) -> Dict[str, Any]:
"""Idempotent launch returning ``{pid, bundle_id, name, windows[]}``.
``creates_new_application_instance=True`` forces a fresh instance so
concurrent runs touching the same app get isolated windows."""
if not bundle_id and not name:
raise ValueError("launch_app requires either bundle_id or name")
args: Dict[str, Any] = {"session": self._session_id}
for key, value in (("bundle_id", bundle_id), ("name", name), ("urls", urls and list(urls)),
("additional_arguments", additional_arguments and list(additional_arguments)),
("creates_new_application_instance", creates_new_application_instance or None)):
if value:
args[key] = value
out = self._session.call_tool("launch_app", args)
return out["structuredContent"] or {"data": out["data"]}
def bring_to_front(self, *, pid: int, window_id: Optional[int] = None) -> ActionResult:
"""Activate a window so subsequent foreground-dispatched input lands on it."""
args: Dict[str, Any] = {"pid": int(pid)}
if window_id is not None:
args["window_id"] = int(window_id)
# The live schema is strict and has no session property: this is a
# standalone native focus operation, not a session-scoped input action.
return self._action("bring_to_front", args, inject_session=False)
# ── Agent cursor / config ────────────────────────────────────────
def set_agent_cursor_enabled(self, enabled: bool, *,
cursor_id: Optional[str] = None) -> ActionResult:
"""Toggle the agent cursor overlay's visibility for this run."""
args: Dict[str, Any] = {"enabled": bool(enabled)}
if cursor_id:
args["cursor_id"] = cursor_id
return self._action("set_agent_cursor_enabled", args)
def set_config(self, **config) -> ActionResult:
"""Set cua-driver config keys (e.g. ``max_image_dimension``). Unknown
keys pass through verbatim — cua-driver validates its own schema."""
return self._action("set_config", dict(config))
def call_tool(self, name: str, args: Optional[Dict[str, Any]] = None,
*, timeout: float = 30.0) -> Dict[str, Any]:
"""Generic escape hatch: call any cua-driver MCP tool by name.
``session`` is injected via setdefault, so this is the supported path
for tools the wrapper does not type-wrap (preferred over
``self._session.call_tool``)."""
payload = dict(args) if args else {}
payload.setdefault("session", self._session_id)
return self._session.call_tool(name, payload, timeout=timeout)
# ── Internal ───────────────────────────────────────────────────
def _maybe_attach_element_token(self, tool: str, args: Dict[str, Any]) -> None:
"""Attach the snapshot's ``element_token`` to an ``element_index`` call so
a superseded snapshot yields an explicit 'stale' error. Gated on the
per-tool capability: older drivers (``additionalProperties: false``)
must never see the field."""
idx = args.get("element_index")
token = self._snapshot_tokens.get(idx) if isinstance(idx, int) else None
if token and self._session.supports_capability("accessibility.element_tokens", tool=tool):
args["element_token"] = token
def _action(self, name: str, args: Dict[str, Any], *, inject_session: bool = True) -> ActionResult:
self._maybe_attach_element_token(name, args)
# setdefault preserves any explicit session a caller already supplied.
if inject_session:
args.setdefault("session", self._session_id)
try:
out = self._session.call_tool(name, args)
except Exception as e:
logger.exception("cua-driver %s call failed", name)
return ActionResult(ok=False, action=name, message=f"cua-driver error: {e}")
data = out["data"]
structured = out.get("structuredContent") or {}
message = str(data.get("message", "")) if isinstance(data, dict) else data if isinstance(data, str) else ""
if not message and isinstance(structured, dict):
message = str(structured.get("message", ""))
# Merge data + structuredContent into meta, structured winning on
# overlap (it is the canonical verdict surface).
meta: Dict[str, Any] = {}
for part in (data, structured):
if isinstance(part, dict):
meta.update(part)
return _action_result_from(name, not out["isError"], message, meta, structured,
requested_delivery=args.get("delivery_mode"))