mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Cleans F401 unused imports and F841 dead local assignments across root *.py, agent/, hermes_cli/, tools/, gateway/, cron/, tui_gateway/ (tests/, plugins/, skills/ excluded). Intentionally KEPT (false positives / test-patch surfaces): - agent/transports/__init__.py package re-exports - cli.py browser_connect re-exports (DEFAULT_BROWSER_CDP_URL area, used by tests/cli/test_cli_browser_connect.py) - hermes_cli/main.py _prompt_auth_credentials_choice / _model_flow_bedrock_api_key (accessed via main_mod attr in tests) - gateway/run.py aliased replay_cleanup + whatsapp_identity re-exports and _PORT_BINDING_PLATFORM_VALUES (test-referenced) - hermes_cli/web_server.py get_running_pid (tests monkeypatch it) and _OAUTH_TOKEN_URL availability probe - hermes_cli/config.py get_process_hermes_home re-export (noqa'd F811 chain) and yaml availability-probe import - hermes_cli/nous_subscription.py managed_nous_tools_enabled (tests patch hermes_cli.nous_subscription.managed_nous_tools_enabled) - try/except ImportError availability probes (env_loader, tts_tool, mcp_tool, web_server anthropic OAuth block) - tools/web_tools.py noqa F401 re-exports - hermes_cli/setup_whatsapp_cloud.py:263 'proceed' skipped: possible missing-guard bug, flagged for separate review - unused function parameters (signature changes out of scope) Side-effect RHS calls preserved where only the binding was dead (e.g. web_server proc = _spawn_hermes_action -> bare call).
864 lines
31 KiB
Python
864 lines
31 KiB
Python
"""
|
|
`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 ``<binary> 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 `<binary> 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
|