307 lines
14 KiB
Python
307 lines
14 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 cua-driver releases. Exact matches only: a suffixed
|
|
# identifier or a different non-empty team is an impostor, not a variant.
|
|
_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:
|
|
a fallback candidate could be a DIFFERENT install than the one the
|
|
manifest resolved, running code the resolution chain never validated.
|
|
"""
|
|
resolved_driver_cmd = os.path.realpath(driver_cmd)
|
|
marker = ".app/Contents/MacOS/"
|
|
marker_index = resolved_driver_cmd.find(marker)
|
|
if marker_index < 0:
|
|
return None
|
|
candidate = resolved_driver_cmd[: marker_index + len(".app")]
|
|
executable = os.path.join(candidate, "Contents", "MacOS", "cua-driver")
|
|
if os.path.isfile(executable) and os.access(executable, os.X_OK):
|
|
return candidate
|
|
return 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 require ``codesign -dv`` to 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,
|
|
)
|
|
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 = {}
|
|
for line in (proc.stderr or "").splitlines(): # codesign -dv reports on stderr
|
|
key, sep, value = line.partition("=")
|
|
if sep:
|
|
fields.setdefault(key.strip(), value.strip())
|
|
identifier = fields.get("Identifier", "")
|
|
team = 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:
|
|
return
|
|
if 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]
|
|
|
|
|
|
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;
|
|
the manifest, not a runtime prompt, is the authorization boundary.
|
|
|
|
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`` — that pairing bounds an approval-bypassed run. It stays
|
|
mandatory for ``bounded`` and 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 only
|
|
# accept 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 = driver_cmd
|
|
self._command = driver_cmd
|
|
self._mcp_args: List[str] = list(_cb._CUA_DRIVER_ARGS)
|
|
self._process: Any = None
|
|
self._owns_runtime = False
|
|
self._running = False
|
|
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]
|
|
if sys.platform == "win32":
|
|
self.socket_path = rf"\\.\pipe\hermes-cua-{token}"
|
|
else:
|
|
self.socket_path = 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:
|
|
stream = getattr(process, "stderr", None)
|
|
if stream is None:
|
|
return
|
|
try:
|
|
for line in stream:
|
|
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.extend([
|
|
"--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
|
|
|
|
if not self._driver_cmd:
|
|
self._driver_cmd = _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 as soon as 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}")
|
|
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):
|
|
probe = None
|
|
if probe is not None and probe.returncode == 0:
|
|
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 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:
|
|
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)
|
|
if sys.platform != "win32" and os.path.exists(self.socket_path):
|
|
try:
|
|
os.remove(self.socket_path)
|
|
except OSError:
|
|
pass
|