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

256 lines
13 KiB
Python

"""Private embedded cua-driver daemon for non-standard permission modes, plus the macOS
CuaDriver.app identity checks its launch path depends on. Driver resolution / policy helpers
are looked up lazily through ``tools.computer_use.cua_backend`` so tests that patch them there
keep working.
"""
from __future__ import annotations
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
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")
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."""
resolved = os.path.realpath(driver_cmd)
marker_index = resolved.find(".app/Contents/MacOS/")
if marker_index < 0:
return None
candidate = resolved[: marker_index + len(".app")]
executable = os.path.join(candidate, "Contents", "MacOS", "cua-driver")
return candidate if 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."""
from tools.computer_use import cua_backend as _cb
codesign = shutil.which("codesign")
if not codesign:
raise RuntimeError("codesign is required to verify CuaDriver.app before launching it.")
try:
proc = subprocess.run([codesign, "-dv", app_path], capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL)
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 "
f"({(proc.stderr or '').strip()})")
fields: Dict[str, str] = {}
for key, sep, value in (line.partition("=") for line in (proc.stderr or "").splitlines()):
if sep: # codesign -dv reports on stderr
fields.setdefault(key.strip(), value.strip())
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}, "
f"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 "
f"{_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:
from tools.computer_use import cua_backend as _cb
if permission_mode not in {"unrestricted", "bounded"}:
raise ValueError("embedded permission override supports unrestricted or bounded only")
self.capability_manifest: Optional[str] = None
manifest = str(capability_manifest or "").strip()
if not manifest and permission_mode == "bounded":
raise ValueError("bounded permission mode requires computer_use.capability_manifest")
if manifest:
manifest = os.path.abspath(os.path.expanduser(manifest))
if not os.path.isfile(manifest):
raise ValueError(f"capability manifest not found: {manifest}")
self.capability_manifest = manifest
# bounded always forwards (the driver validates it); other modes accept only a v3
# manifest — a legacy one would abort startup instead.
self.manifest_applies = bool(self.capability_manifest) and (
permission_mode == "bounded" or _cb._manifest_is_mode_independent(str(self.capability_manifest)))
if self.capability_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 = permission_mode
self._driver_cmd = self._command = driver_cmd
self._mcp_args: List[str] = list(_cb._CUA_DRIVER_ARGS)
self._process: Any = None
self._owns_runtime = self._running = self._launch_via_app = False
self._stderr_tail: deque[str] = deque(maxlen=20)
self._stderr_thread: Optional[threading.Thread] = None
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]:
from tools.computer_use import cua_backend as _cb
env = _cb.cua_driver_child_env()
env["CUA_DRIVER_PERMISSION_MODE"] = self.permission_mode
if self.permission_mode == "unrestricted":
env["CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS"] = "1"
return env
def _drain_stderr(self, process: Any) -> None:
try:
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)
except Exception:
pass
def _serve_args(self) -> List[str]:
from tools.computer_use import cua_backend as _cb
serve_args = ["serve", "--embedded", "--socket", self.socket_path,
"--no-permissions-gate", "--permission-mode", self.permission_mode]
if self.permission_mode == "unrestricted":
serve_args.append("--dangerously-bypass-approvals")
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 _cb._mcp_args_with_overlay_flag(serve_args, driver_cmd=self._command)
def start(self) -> None:
if self._running:
return
from tools.computer_use import cua_backend as _cb
from tools.environments.local import _sanitize_subprocess_env
self._driver_cmd = self._driver_cmd or _cb.resolve_cua_driver_cmd() or ""
if not self._driver_cmd:
raise RuntimeError(_cb.cua_driver_install_hint())
self._command, self._mcp_args = _cb._resolve_mcp_invocation(self._driver_cmd)
env = _sanitize_subprocess_env(self.child_env())
self._launch_via_app = sys.platform == "darwin"
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
self._stderr_thread = threading.Thread(target=self._drain_stderr, args=(self._process,),
name="hermes-cua-daemon-stderr", daemon=True)
self._stderr_thread.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, so on macOS only a
# non-zero exit means the daemon itself died.
if return_code is not None and (not self._launch_via_app or return_code != 0):
detail = "; ".join(self._stderr_tail) or "no diagnostic output"
raise RuntimeError(f"embedded cua-driver exited during startup: {detail}")
if self._socket_ready(env):
self._running = True
return
time.sleep(0.1)
self.stop()
detail = "; ".join(self._stderr_tail) or "daemon did not become ready"
raise RuntimeError(f"embedded cua-driver startup timed out: {detail}")
def _socket_ready(self, env: Dict[str, str]) -> bool:
"""``cua-driver status --socket`` exits 0 once the private daemon accepts connections."""
try:
probe = subprocess.run([self._command, "status", "--socket", self.socket_path],
stdin=subprocess.DEVNULL, capture_output=True, text=True,
timeout=2.0, env=env)
except (OSError, subprocess.SubprocessError):
return False
return 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._owns_runtime, False
self._running = False
if owns_runtime:
from tools.environments.local import _sanitize_subprocess_env
try:
subprocess.run([self._command, "stop", "--socket", self.socket_path],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=3.0,
env=_sanitize_subprocess_env(self.child_env()))
except (OSError, subprocess.SubprocessError):
pass
if process is not None:
_wait_or_kill(process)
if sys.platform != "win32" and os.path.exists(self.socket_path):
try:
os.remove(self.socket_path)
except OSError:
pass