Files
hermes-agent/tools/computer_use/cua_backend_daemon.py

209 lines
12 KiB
Python

"""Private embedded cua-driver daemon for non-standard permission modes, plus the macOS CuaDriver.app identity
checks its launch path depends on. Config/policy helpers are looked up lazily through the facade."""
from __future__ import annotations
import contextlib
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import uuid
from collections import deque
from typing import Any, Dict, List, Optional, Tuple
from tools.computer_use import cua_backend_driver as _driver
logger = logging.getLogger("tools.computer_use.cua_backend")
# The only bundle identity the private daemon may launch through, and the teams that sign official
# releases. Exact matches only: a suffixed identifier or other team is an impostor.
_CUA_DRIVER_BUNDLE_ID = "com.trycua.driver"
_CUA_DRIVER_TEAM_IDS = ("4YEC26S9KF", "YCK386LBJ7")
_QUIET_ERRORS = (OSError, subprocess.SubprocessError)
def _cb():
"""Facade module (config/policy helpers), looked up lazily to avoid the import cycle."""
from tools.computer_use import cua_backend
return cua_backend
def _resolve_cua_driver_app_path(driver_cmd: str) -> Optional[str]:
"""Return the CuaDriver.app bundle that CARRIES *driver_cmd*, if any. Derived from the resolved binary path
only — no /Applications fallback, which could be a DIFFERENT install than the one the manifest resolved,
running code the resolution chain never validated."""
head, marker, _ = os.path.realpath(driver_cmd).partition(".app/Contents/MacOS/")
executable = os.path.join(head + ".app", "Contents", "MacOS", "cua-driver")
return head + ".app" if marker and os.path.isfile(executable) and os.access(executable, os.X_OK) else None
def _validate_cua_driver_app_signature(app_path: str) -> None:
"""Fail closed unless *app_path* is the genuinely-signed CuaDriver.app. ``/usr/bin/open`` hands LaunchServices
whatever bundle sits at the path, so ``codesign -dv`` must report EXACTLY ``Identifier=com.trycua.driver`` and
an expected TeamIdentifier. ``TeamIdentifier=not set`` (ad-hoc dev builds) is allowed only with
``computer_use.allow_unsigned_driver: true``. Raises RuntimeError on any mismatch or when codesign is
unavailable/fails."""
codesign = shutil.which("codesign")
if not codesign:
raise RuntimeError("codesign is required to verify CuaDriver.app before launching it.")
try:
proc = _cb()._run_quiet([codesign, "-dv", app_path], timeout=15)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError(f"could not verify CuaDriver.app signature: {exc}") from exc
if proc.returncode != 0:
raise RuntimeError(f"CuaDriver.app at {app_path} is not code-signed; refusing to launch it ({(proc.stderr or '').strip()})")
parts = [line.partition("=") for line in (proc.stderr or "").splitlines()] # codesign -dv reports on stderr
fields = {k.strip(): v.strip() for k, sep, v in reversed(parts) if sep} # first occurrence of a key wins
identifier, team = fields.get("Identifier", ""), fields.get("TeamIdentifier", "")
if identifier != _CUA_DRIVER_BUNDLE_ID:
raise RuntimeError(f"CuaDriver.app at {app_path} has identifier {identifier!r}, expected {_CUA_DRIVER_BUNDLE_ID!r}; "
"refusing to launch it.")
if team in _CUA_DRIVER_TEAM_IDS or (team in ("", "not set") and _cb()._computer_use_cfg().get("allow_unsigned_driver") is True):
return
raise RuntimeError(f"CuaDriver.app at {app_path} is signed by team {team!r}, expected one of {_CUA_DRIVER_TEAM_IDS!r}; "
"refusing to launch it. (Set computer_use.allow_unsigned_driver: true in config.yaml only for "
"local unsigned driver builds.)")
def _embedded_daemon_spawn_command(driver_cmd: str, serve_args: List[str], *, platform: str,
app_path: Optional[str] = None) -> List[str]:
"""Build the private-daemon launch while preserving macOS TCC identity."""
if platform != "darwin":
return [driver_cmd, *serve_args]
resolved_app = app_path or _resolve_cua_driver_app_path(driver_cmd)
if not resolved_app:
raise RuntimeError("CuaDriver.app is required for private computer-use sessions on macOS. Run `hermes computer-use install` to restore it.")
_validate_cua_driver_app_signature(resolved_app)
return ["/usr/bin/open", "-n", "-g", "-a", resolved_app, "--args", *serve_args]
def _wait_or_kill(process: Any) -> None:
"""Wait 5s for a graceful exit, then terminate (2s), then kill."""
try:
process.wait(timeout=5.0)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2.0)
class _EmbeddedCuaDaemon:
"""Private daemon for a non-standard permission mode. cua-driver's permission mode is immutable after daemon
startup, so reusing the machine-wide daemon would let one Hermes session's YOLO choice affect another. A
private daemon gives the session its own socket, runtime and launch-time authorization; on macOS it is
launched through CuaDriver.app so TCC stays attached to ``com.trycua.driver``. ``unrestricted`` = explicit
Hermes YOLO (``--dangerously-bypass-approvals``); ``bounded`` = a user-reviewed capability manifest approved
at launch is the authorization boundary, not a runtime prompt. The manifest is a ceiling, not a mode: it "can
narrow a profile but never widen it", so a configured v3 manifest is forwarded even for ``unrestricted``
(bounding an approval-bypassed run). Mandatory for ``bounded``, optional everywhere else."""
_START_TIMEOUT_SECONDS = 15.0
def __init__(self, driver_cmd: str, permission_mode: str, capability_manifest: Optional[str] = None) -> None:
if permission_mode not in {"unrestricted", "bounded"}:
raise ValueError("embedded permission override supports unrestricted or bounded only")
manifest = str(capability_manifest or "").strip()
if not manifest and permission_mode == "bounded":
raise ValueError("bounded permission mode requires computer_use.capability_manifest")
manifest = os.path.abspath(os.path.expanduser(manifest)) if manifest else ""
if manifest and not os.path.isfile(manifest):
raise ValueError(f"capability manifest not found: {manifest}")
self.capability_manifest: Optional[str] = manifest or None
# bounded always forwards (driver validates it); other modes accept only v3 — a legacy manifest aborts startup.
self.manifest_applies = bool(manifest) and (
permission_mode == "bounded" or _cb()._manifest_is_mode_independent(manifest))
if manifest and not self.manifest_applies:
logger.warning("computer_use.capability_manifest is a legacy (v1/v2) manifest, which cua-driver only accepts in "
"bounded mode — it will NOT bound this %s session. Migrate the manifest to version 3 to keep a "
"ceiling on approval-bypassed runs.", permission_mode)
self.permission_mode, self._driver_cmd, self._command = permission_mode, driver_cmd, driver_cmd
self._mcp_args: List[str] = list(_driver._CUA_DRIVER_ARGS)
self._process: Any = None
self._owns_runtime = self._running = False
self._stderr_tail: deque[str] = deque(maxlen=20)
token = uuid.uuid4().hex[:12]
self.socket_path = (rf"\\.\pipe\hermes-cua-{token}" if sys.platform == "win32"
else os.path.join(tempfile.gettempdir(), f"hc-{token}.sock"))
def child_env(self) -> Dict[str, str]:
env = {**_cb().cua_driver_child_env(), "CUA_DRIVER_PERMISSION_MODE": self.permission_mode}
if self.permission_mode == "unrestricted":
env["CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS"] = "1"
return env
def _sanitized_env(self) -> Dict[str, str]:
from tools.environments.local import _sanitize_subprocess_env
return _sanitize_subprocess_env(self.child_env())
def _drain_stderr(self, process: Any) -> None:
with contextlib.suppress(Exception):
for line in getattr(process, "stderr", None) or ():
text = str(line).strip()
if text:
self._stderr_tail.append(text)
logger.debug("embedded cua-driver: %s", text)
def _serve_args(self) -> List[str]:
serve_args = ["serve", "--embedded", "--socket", self.socket_path, "--no-permissions-gate", "--permission-mode",
self.permission_mode, *(["--dangerously-bypass-approvals"] if self.permission_mode == "unrestricted" else [])]
if self.manifest_applies:
serve_args += ["--capability-manifest", str(self.capability_manifest), "--approve-capability-manifest"]
# The private daemon owns the cursor overlay, so the overlay policy must apply to this long-lived serve
# process, not only its MCP proxy. Appended BEFORE the macOS app-launch wrapping so the flag travels inside
# `open ... --args` with the rest of the serve args.
return _driver._mcp_args_with_overlay_flag(serve_args, driver_cmd=self._command)
def start(self) -> None:
if self._running:
return
self._driver_cmd = self._driver_cmd or _driver.resolve_cua_driver_cmd() or ""
if not self._driver_cmd:
raise RuntimeError(_driver.cua_driver_install_hint())
self._command, self._mcp_args = _driver._resolve_mcp_invocation(self._driver_cmd)
env = self._sanitized_env()
command = _embedded_daemon_spawn_command(self._command, self._serve_args(), platform=sys.platform)
self._process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, text=True, env=env)
self._owns_runtime = True
threading.Thread(target=self._drain_stderr, args=(self._process,), name="hermes-cua-daemon-stderr", daemon=True).start()
deadline = time.monotonic() + self._START_TIMEOUT_SECONDS
while time.monotonic() < deadline:
return_code = self._process.poll()
# `open` exits 0 once LaunchServices took the request: on macOS only a non-zero exit means the daemon died.
if return_code is not None and (sys.platform != "darwin" or return_code != 0):
self._startup_failure("embedded cua-driver exited during startup", "no diagnostic output")
if self._socket_ready(env):
self._running = True
return
time.sleep(0.1)
self.stop()
self._startup_failure("embedded cua-driver startup timed out", "daemon did not become ready")
def _startup_failure(self, what: str, fallback: str) -> None:
raise RuntimeError(f"{what}: {'; '.join(self._stderr_tail) or fallback}")
def _socket_ready(self, env: Dict[str, str]) -> bool:
"""``cua-driver status --socket`` exits 0 once the private daemon accepts connections."""
probe = _cb()._run_quiet([self._command, "status", "--socket", self.socket_path], timeout=2.0, env=env, swallow=_QUIET_ERRORS)
return probe is not None and probe.returncode == 0
def proxy_invocation(self) -> Tuple[str, List[str]]:
if not self._running:
raise RuntimeError("embedded cua-driver daemon is not running")
return self._command, [*self._mcp_args, "--embedded", "--socket", self.socket_path]
def stop(self) -> None:
process, self._process = self._process, None
owns_runtime, self._owns_runtime, self._running = self._owns_runtime, False, False
if owns_runtime:
_cb()._run_quiet([self._command, "stop", "--socket", self.socket_path], timeout=3.0, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, env=self._sanitized_env(), swallow=_QUIET_ERRORS)
if process is not None:
_wait_or_kill(process)
if sys.platform != "win32" and os.path.exists(self.socket_path):
with contextlib.suppress(OSError):
os.remove(self.socket_path)