""" `hermes computer-use doctor` — thin client for cua-driver's `health_report` MCP tool. cua-driver owns the health model (#1908 / be761fac on `main`). This module just drives the stdio JSON-RPC handshake, calls `health_report`, and renders the structured response. When the driver gets new checks, they flow through here without code changes on the Hermes side — the only contract is the stable `schema_version="1"` payload shape. cua-driver 0.10.x marks `health_report` with risk.class='unclassified', so MCP tools/call returns isError=true ("Permission denied: ... no reviewed risk classification") with structuredContent ``{"exit_code": 1}``. That is NOT a schema_version=1 report — we detect it and synthesize a composite report via working probes (check_permissions, list_apps, CLI --version). Exit code conventions: - 0: overall == "ok" - 1: overall in ("degraded", "failed") - 2: driver binary missing / unreachable / protocol error """ from __future__ import annotations import json import os import platform as _platform_mod import re import subprocess import sys from typing import Any, Dict, List, Optional, Sequence, Tuple from hermes_cli._subprocess_compat import windows_hide_flags # Match the ALLOWED_STATUS_VALUES + ALLOWED_OVERALL_VALUES the cua-driver # integration test pins. If health_report widens its vocabulary, add here. _STATUS_GLYPH = { "pass": "✅", "fail": "❌", "skip": "⏭️", } _OVERALL_GLYPH = { "ok": "✅", "degraded": "⚠️", "failed": "❌", } class HealthReportUnavailable(RuntimeError): """health_report MCP tool denied or returned a non-schema payload. Raised so ``run_doctor`` can fall back to composite probes that work on cua-driver builds where ``health_report`` is risk-unclassified (0.10.x). """ def _cua_child_env() -> Dict[str, str]: """cua-driver child env with the Hermes telemetry policy applied. Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by default unless the user opts in). Falls back to the current environment if that import fails, so doctor never breaks on a telemetry-helper error. """ try: from tools.computer_use.cua_backend import cua_driver_child_env return cua_driver_child_env() except Exception: return dict(os.environ) def _sanitized_cua_env() -> Dict[str, str]: """Telemetry-policy env with Hermes provider secrets stripped. cua-driver is a third-party binary — it must never inherit provider API keys (#53503/#55709/#58889 lineage). Falls back to the unsanitized telemetry env if the sanitizer can't be imported, so doctor keeps working in stripped-down environments. """ env = _cua_child_env() try: from tools.environments.local import _sanitize_subprocess_env return _sanitize_subprocess_env(env) except Exception: return env def _is_valid_health_report(payload: Any) -> bool: """True when *payload* looks like a schema_version=1 health_report.""" if not isinstance(payload, dict): return False if "schema_version" not in payload: return False if "overall" not in payload: return False if not isinstance(payload.get("checks"), list): return False return True def _read_cli_version(binary: str, *, timeout: float = 5.0) -> Optional[str]: """Return ``cua-driver --version`` stdout (stripped), or None on failure. health_report's ``driver_version`` / binary_version check can disagree with the actual binary (observed on Windows: health_report claims 0.8.3 while ``--version`` and the on-disk release are 0.12.6). Doctor surfaces both so operators are not misled when debugging session issues against a "wrong" version string. """ try: completed = subprocess.run( [binary, "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=_sanitized_cua_env(), ) except (OSError, subprocess.TimeoutExpired, ValueError, TypeError): return None text = (completed.stdout or completed.stderr or "").strip() if not text: return None # First non-empty line only — keep the banner compact. return text.splitlines()[0].strip() def _normalize_version_token(text: str) -> str: """Pull a dotted version-ish token out of a free-form version string.""" if not text: return "" m = re.search(r"(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?)", text) return m.group(1) if m else text.strip().lower() def _build_identity(binary: str, report: Dict[str, Any]) -> Dict[str, Any]: """Hermes-side identity block comparing resolved binary vs health_report.""" cli = _read_cli_version(binary) or "" report_v = str(report.get("driver_version") or "") cli_tok = _normalize_version_token(cli) report_tok = _normalize_version_token(report_v) mismatch = bool(cli_tok and report_tok and cli_tok != report_tok) return { "resolved_binary": binary, "cli_version": cli or None, "health_report_driver_version": report_v or None, "version_mismatch": mismatch, } def _extract_health_report_from_result(result: Dict[str, Any]) -> Dict[str, Any]: """Pull a schema_version=1 report out of an MCP tools/call result. Raises ``HealthReportUnavailable`` when the tool denied the call (isError) or the payload is not a real health report (e.g. 0.10's ``{"exit_code": 1}`` structuredContent on unclassified denial). Raises ``RuntimeError`` when the response shape is unusable for other reasons (no content at all). """ if result.get("isError") is True: # Prefer the human text; fall back to a generic denial message. denial = "health_report returned isError=true" for item in result.get("content") or []: if isinstance(item, dict) and item.get("type") == "text": text = (item.get("text") or "").strip() if text: denial = text break raise HealthReportUnavailable(denial) sc = result.get("structuredContent") if _is_valid_health_report(sc): return sc # type: ignore[return-value] # Older builds: JSON text block with schema_version. for item in result.get("content") or []: if not isinstance(item, dict) or item.get("type") != "text": continue text = item.get("text", "") try: parsed = json.loads(text) except (ValueError, TypeError): continue if _is_valid_health_report(parsed): return parsed # structuredContent present but not a real report (the 0.10 unclassified # path ships {"exit_code": 1}) — treat as unavailable, not fatal protocol. if isinstance(sc, dict): raise HealthReportUnavailable( "health_report structuredContent lacks schema_version/overall/checks " f"(keys={sorted(sc.keys())})" ) raise RuntimeError( "health_report response carried neither structuredContent nor a parseable " f"JSON text block. Result keys: {list(result.keys())}" ) def _open_mcp(binary: str) -> subprocess.Popen: """Spawn `` mcp`` with UTF-8 + sanitized env.""" # cua-driver emits UTF-8 (containing emoji in check messages on macOS # and arbitrary file paths on Windows). The Python default # text-mode encoding follows the system locale — `cp1252` on a # default Windows install — which raises UnicodeDecodeError on the # first non-ASCII byte. Pin the codec. return subprocess.Popen( [binary, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1, creationflags=windows_hide_flags(), env=_sanitized_cua_env(), ) def _mcp_rpc(proc: subprocess.Popen, msg_id: int, method: str, params: Any = None) -> Dict[str, Any]: """Write one JSON-RPC request and read one response line.""" assert proc.stdin is not None and proc.stdout is not None payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": msg_id, "method": method} if params is not None: payload["params"] = params proc.stdin.write(json.dumps(payload) + "\n") proc.stdin.flush() line = proc.stdout.readline() if not line: stderr_tail: List[str] = [] if proc.stderr is not None: try: raw_err = proc.stderr.read() or "" stderr_tail = [str(x) for x in raw_err.strip().splitlines()[-3:]] except Exception: pass raise RuntimeError( f"cua-driver mcp produced no response for {method!r}. " f"stderr tail: {stderr_tail or '(empty)'}" ) try: resp = json.loads(line) except (ValueError, TypeError) as e: raise RuntimeError(f"{method} response was not valid JSON: {e}\nraw: {line[:200]}") if "error" in resp: raise RuntimeError(f"{method} JSON-RPC error: {resp['error']}") return resp def _close_mcp(proc: subprocess.Popen, timeout: float) -> None: try: if proc.stdin is not None: proc.stdin.close() except Exception: pass try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: proc.kill() proc.wait() def _drive_health_report( binary: str, *, include: Sequence[str] = (), skip: Sequence[str] = (), timeout: float = 12.0, ) -> Dict[str, Any]: """Spawn ` mcp`, perform the JSON-RPC handshake, call `health_report`, and return the parsed schema_version=1 report. Raises: HealthReportUnavailable: tool denied (isError) or non-schema payload (cua-driver 0.10 unclassified). Caller should fall back. RuntimeError: protocol-level failure (binary crash, malformed JSON, JSON-RPC error, empty content). """ args: Dict[str, Any] = {} if include: args["include"] = list(include) if skip: args["skip"] = list(skip) proc = _open_mcp(binary) try: # 1. initialize init_resp = _mcp_rpc(proc, 1, "initialize", {}) _ = init_resp # handshake only # 2. tools/call health_report call_resp = _mcp_rpc( proc, 2, "tools/call", {"name": "health_report", "arguments": args}, ) finally: _close_mcp(proc, timeout) result = call_resp.get("result") or {} if not isinstance(result, dict): raise RuntimeError(f"health_report result was not an object: {type(result).__name__}") return _extract_health_report_from_result(result) def _cli_driver_version(binary: str, timeout: float = 5.0) -> Tuple[str, Optional[str]]: """Return (status, version_or_message) from ``cua-driver --version``.""" try: completed = subprocess.run( [binary, "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=_sanitized_cua_env(), ) except (OSError, subprocess.TimeoutExpired) as e: return "fail", f"--version failed: {e}" text = ((completed.stdout or "") + (completed.stderr or "")).strip() if completed.returncode != 0 and not text: return "fail", f"--version exited {completed.returncode}" # Typical: "cua-driver 0.10.0" m = re.search(r"(\d+\.\d+\.\d+(?:[-+][\w.]+)?)", text) version = m.group(1) if m else (text.splitlines()[0] if text else "unknown") if completed.returncode != 0: return "fail", version return "pass", version def _cli_doctor_snippet(binary: str, timeout: float = 8.0) -> Optional[str]: """Optional one-shot ``cua-driver doctor`` text (best-effort, never fatal).""" try: completed = subprocess.run( [binary, "doctor"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=_sanitized_cua_env(), ) except (OSError, subprocess.TimeoutExpired): return None out = ((completed.stdout or "") + (completed.stderr or "")).strip() return out or None def _drive_fallback_probes( binary: str, *, timeout: float = 12.0, ) -> Dict[str, Any]: """Call working MCP tools (check_permissions, list_apps) in one session. Returns a dict with keys: - init_version: str | None (from initialize serverInfo) - permissions: structuredContent dict | None - permissions_error: str | None - list_apps_ok: bool | None - list_apps_error: str | None - list_apps_count: int | None """ out: Dict[str, Any] = { "init_version": None, "permissions": None, "permissions_error": None, "list_apps_ok": None, "list_apps_error": None, "list_apps_count": None, } proc = _open_mcp(binary) try: init_resp = _mcp_rpc(proc, 1, "initialize", {}) server_info = ((init_resp.get("result") or {}).get("serverInfo") or {}) if isinstance(server_info, dict): out["init_version"] = server_info.get("version") # check_permissions — primary TCC signal on 0.10 try: perm_resp = _mcp_rpc( proc, 2, "tools/call", {"name": "check_permissions", "arguments": {}} ) perm_result = perm_resp.get("result") or {} if perm_result.get("isError") is True: msg = "check_permissions isError" for item in perm_result.get("content") or []: if isinstance(item, dict) and item.get("type") == "text": t = (item.get("text") or "").strip() if t: msg = t break out["permissions_error"] = msg else: sc = perm_result.get("structuredContent") out["permissions"] = sc if isinstance(sc, dict) else {} except RuntimeError as e: out["permissions_error"] = str(e) # list_apps — light AX capability probe try: apps_resp = _mcp_rpc( proc, 3, "tools/call", {"name": "list_apps", "arguments": {}} ) apps_result = apps_resp.get("result") or {} if apps_result.get("isError") is True: msg = "list_apps isError" for item in apps_result.get("content") or []: if isinstance(item, dict) and item.get("type") == "text": t = (item.get("text") or "").strip() if t: msg = t break out["list_apps_ok"] = False out["list_apps_error"] = msg else: sc = apps_result.get("structuredContent") or {} apps = sc.get("apps") if isinstance(sc, dict) else None if isinstance(apps, list): out["list_apps_ok"] = True out["list_apps_count"] = len(apps) else: # text-only success still counts as AX working out["list_apps_ok"] = True out["list_apps_count"] = None except RuntimeError as e: out["list_apps_ok"] = False out["list_apps_error"] = str(e) finally: _close_mcp(proc, timeout) return out def _platform_name() -> str: sysname = (_platform_mod.system() or "").lower() if sysname == "darwin": return "darwin" if sysname == "windows": return "windows" if sysname == "linux": return "linux" return sysname or "unknown" def _compose_fallback_report( binary: str, *, reason: str = "", timeout: float = 12.0, ) -> Dict[str, Any]: """Build a schema_version=1 report from CLI + working MCP probes. Used when ``health_report`` is denied (unclassified risk on 0.10) or returns a non-schema payload. Compatible with ``_print_text_report``. """ plat = _platform_name() checks: List[Dict[str, Any]] = [] ver_status, ver_value = _cli_driver_version(binary) driver_version = ver_value if ver_status == "pass" else (ver_value or "?") # Prefer MCP initialize version when CLI parse is messy probes = _drive_fallback_probes(binary, timeout=timeout) if probes.get("init_version"): driver_version = str(probes["init_version"]) ver_status = "pass" ver_msg = f"cua-driver {driver_version}" else: ver_msg = ( f"cua-driver {ver_value}" if ver_status == "pass" else (ver_value or "version unknown") ) checks.append({ "name": "binary_version", "status": ver_status, "message": ver_msg, }) # platform_supported — doctor runs wherever the binary runs supported = plat in ("darwin", "linux", "windows") checks.append({ "name": "platform_supported", "status": "pass" if supported else "fail", "message": f"platform={plat}" + ("" if supported else " (unsupported)"), }) # session_active — we don't start a session in doctor; mark skip checks.append({ "name": "session_active", "status": "skip", "message": "not probed (doctor does not open a cua session)", }) perms = probes.get("permissions") if isinstance(probes.get("permissions"), dict) else None perm_err = probes.get("permissions_error") if perms is not None: ax = perms.get("accessibility") scr = perms.get("screen_recording") capturable = perms.get("screen_recording_capturable") if ax is True: checks.append({ "name": "tcc_accessibility", "status": "pass", "message": "Accessibility is granted.", "data": {"accessibility": True}, }) elif ax is False: checks.append({ "name": "tcc_accessibility", "status": "fail", "message": "Accessibility is not granted.", "hint": "Grant Accessibility to CuaDriver in System Settings → Privacy & Security.", "data": {"accessibility": False}, }) else: checks.append({ "name": "tcc_accessibility", "status": "skip", "message": "accessibility field absent from check_permissions", }) if scr is True and capturable is False: checks.append({ "name": "tcc_screen_recording", "status": "fail", "message": "Screen Recording granted but not capturable.", "hint": ( "Screen Recording permission may need a restart of CuaDriver " "or a re-grant in System Settings." ), "data": { "screen_recording": True, "screen_recording_capturable": False, }, }) elif scr is True: checks.append({ "name": "tcc_screen_recording", "status": "pass", "message": "Screen Recording is granted.", "data": { "screen_recording": True, "screen_recording_capturable": capturable, }, }) elif scr is False: checks.append({ "name": "tcc_screen_recording", "status": "fail", "message": "Screen Recording is not granted.", "hint": "Grant Screen Recording to CuaDriver in System Settings → Privacy & Security.", "data": {"screen_recording": False}, }) else: # Non-macOS or field absent if plat == "darwin": checks.append({ "name": "tcc_screen_recording", "status": "skip", "message": "screen_recording field absent from check_permissions", }) else: checks.append({ "name": "tcc_screen_recording", "status": "skip", "message": f"not applicable on {plat}", }) else: checks.append({ "name": "tcc_accessibility", "status": "fail" if perm_err else "skip", "message": perm_err or "check_permissions unavailable", }) checks.append({ "name": "tcc_screen_recording", "status": "fail" if perm_err else "skip", "message": perm_err or "check_permissions unavailable", }) # ax_capability — infer from list_apps success or accessibility grant list_ok = probes.get("list_apps_ok") list_err = probes.get("list_apps_error") list_count = probes.get("list_apps_count") ax_granted = bool(perms and perms.get("accessibility") is True) if list_ok is True: count_msg = f" ({list_count} apps)" if isinstance(list_count, int) else "" checks.append({ "name": "ax_capability", "status": "pass", "message": f"list_apps succeeded{count_msg}", }) elif list_ok is False: checks.append({ "name": "ax_capability", "status": "fail", "message": ( list_err or ( "list_apps failed despite accessibility grant" if ax_granted else "list_apps failed" ) ), }) elif ax_granted: checks.append({ "name": "ax_capability", "status": "pass", "message": "inferred from accessibility grant (list_apps not probed)", }) else: checks.append({ "name": "ax_capability", "status": "skip", "message": "not probed", }) # Annotate that we used the fallback path reason_short = (reason or "health_report unavailable").strip() if len(reason_short) > 160: reason_short = reason_short[:157] + "..." checks.append({ "name": "health_report_path", "status": "skip", "message": ( "fallback composite (cua-driver 0.10 unclassified health_report); " f"cause: {reason_short}" ), }) # Optional CLI doctor text (best-effort) doctor_txt = _cli_doctor_snippet(binary) if doctor_txt: first = doctor_txt.splitlines()[0].strip() cli_ok = "[ok" in doctor_txt.lower() or "ok ]" in doctor_txt checks.append({ "name": "cli_doctor", "status": "pass" if cli_ok else "skip", "message": first, "data": {"snippet": doctor_txt[:2000]}, }) # Normalize any accidental non-vocab status values for c in checks: if c.get("status") not in ("pass", "fail", "skip"): c["status"] = "fail" # overall: ok if TCC+binary ok; degraded if partial; failed if binary missing/bad status_by_name = {c.get("name"): c.get("status") for c in checks} binary_ok = status_by_name.get("binary_version") == "pass" tcc_ax_status = status_by_name.get("tcc_accessibility") tcc_ok = tcc_ax_status in ("pass", "skip", None) fail_count = sum(1 for c in checks if c.get("status") == "fail") if not binary_ok: overall = "failed" elif tcc_ok and fail_count == 0: overall = "ok" elif tcc_ok and fail_count > 0: # Binary + accessibility fine, but something else failed (e.g. screen # recording) → degraded rather than failed. overall = "degraded" else: # Accessibility denied or broken — computer-use is partially/fully blocked. overall = "degraded" return { "schema_version": "1", "platform": plat, "driver_version": str(driver_version), "overall": overall, "checks": checks, "fallback": True, "fallback_reason": reason or "health_report unavailable", } def _drive_health_report_or_fallback( binary: str, *, include: Sequence[str] = (), skip: Sequence[str] = (), timeout: float = 12.0, ) -> Dict[str, Any]: """Prefer real health_report; on denial/non-schema, synthesize via probes.""" try: return _drive_health_report( binary, include=include, skip=skip, timeout=timeout, ) except HealthReportUnavailable as e: return _compose_fallback_report( binary, reason=str(e), timeout=timeout, ) def _print_text_report( report: Dict[str, Any], color: bool, *, identity: Optional[Dict[str, Any]] = None, ) -> None: """Render the report in the same style as `cua-driver call health_report` would (one line per check + a summary footer). When *identity* is provided (resolved binary + ``--version``), the header prefers the CLI version if health_report's ``driver_version`` disagrees, and a short identity block is printed under the header. """ schema = report.get("schema_version", "?") platform = report.get("platform", "?") report_v = report.get("driver_version", "?") overall = report.get("overall", "?") identity = identity or {} cli_v = identity.get("cli_version") or "" mismatch = bool(identity.get("version_mismatch")) # Prefer the binary's own --version when health_report is wrong/stale. header_v = cli_v or report_v header_glyph = _OVERALL_GLYPH.get(overall, "•") if color and overall in _OVERALL_GLYPH: # No external color library — keep ANSI inline so the doctor # command stays a single self-contained module. col_red = "\033[31m" col_yellow = "\033[33m" col_green = "\033[32m" col_reset = "\033[0m" col_dim = "\033[2m" col_for = {"failed": col_red, "degraded": col_yellow, "ok": col_green}.get(overall, "") else: col_red = col_yellow = col_green = col_reset = col_dim = "" col_for = "" print( f"{header_glyph} cua-driver {header_v} on {platform} — " f"{col_for}{overall}{col_reset}" ) if identity.get("resolved_binary"): print(f" {col_dim}binary: {identity['resolved_binary']}{col_reset}") if cli_v and report_v and str(report_v) not in str(cli_v) and str(cli_v) not in str(report_v): # Only annotate when the free-form strings clearly differ. print( f" {col_dim}--version: {cli_v}{col_reset}" ) print( f" {col_dim}health_report.driver_version: {report_v}{col_reset}" ) elif cli_v and not mismatch: # Still show the resolved path; version already matches header. pass if mismatch: warn = col_yellow if color else "" print( f" {warn}⚠️ version mismatch: health_report says {report_v!r} " f"but binary --version is {cli_v!r}{col_reset}" ) print( f" {col_dim}→ trust --version / packages/current for debugging; " f"health_report's binary_version check can lag on Windows{col_reset}" ) for check in report.get("checks", []): name = check.get("name", "?") status = check.get("status", "?") glyph = _STATUS_GLYPH.get(status, "•") message = check.get("message") or "" if color: status_col = { "pass": col_green, "fail": col_red, "skip": col_dim, }.get(status, "") print(f" {glyph} {status_col}{name}{col_reset}: {message}") else: print(f" {glyph} {name}: {message}") hint = check.get("hint") if hint: print(f" → {col_dim}{hint}{col_reset}") # `data` is the structured payload some checks attach (bundle id, # AX permission state, version triple, etc.). Surface when present # because users / support staff frequently need it. data = check.get("data") if isinstance(data, dict) and data: for key, value in data.items(): rendered = value if not isinstance(value, (dict, list)) else json.dumps(value) print(f" {col_dim}{key}={rendered}{col_reset}") _ = schema # acknowledge field for forward-compat readers def run_doctor( driver_cmd: Optional[str] = None, *, include: Sequence[str] = (), skip: Sequence[str] = (), json_output: bool = False, color: Optional[bool] = None, ) -> int: """Resolve the cua-driver binary, call `health_report`, render the result. Honors `HERMES_CUA_DRIVER_CMD` via the shared runtime resolver, so the doctor diagnoses what your `computer_use` toolset will actually invoke. On cua-driver 0.10.x, ``health_report`` may be risk-unclassified and denied; doctor then synthesizes a schema_version=1 report from check_permissions / list_apps / CLI probes instead of printing ``• cua-driver ? on ? — ?``. """ # Windows ships stdout/stderr wrapped with the system ANSI codec # (`cp1252` on a US locale, `cp936` on zh-CN, etc.). The check-matrix # output below contains ✅ ❌ ⚠️ ⏭️ glyphs — none of them encodable # in those codepages. Switch stdout to UTF-8 once, idempotently: every # supported TextIOWrapper (Py3.7+) has `.reconfigure`, and a no-op # re-encode is cheap if we were already UTF-8. for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] except (AttributeError, OSError): pass from tools.computer_use.cua_backend import resolve_cua_driver_cmd binary = resolve_cua_driver_cmd(driver_cmd) if not binary: looked_for = driver_cmd or "cua-driver (PATH and canonical install paths)" print(f"cua-driver: not installed (looked for {looked_for!r}).") print(" Run: hermes computer-use install") return 2 try: report = _drive_health_report_or_fallback( binary, include=include, skip=skip, ) except RuntimeError as e: print(f"cua-driver health_report failed: {e}", file=sys.stderr) return 2 identity = _build_identity(binary, report) if json_output: # Additive envelope: preserve the upstream health_report keys and # attach Hermes identity under hermes_identity so existing parsers # that only read overall/checks keep working. payload = dict(report) payload["hermes_identity"] = identity json.dump(payload, sys.stdout, indent=2, sort_keys=True) sys.stdout.write("\n") else: if color is None: color = sys.stdout.isatty() _print_text_report(report, color=bool(color), identity=identity) overall = report.get("overall") if overall in ("degraded", "failed"): return 1 if overall != "ok": # Unknown / missing overall after fallback should not look like success. return 1 return 0