diff --git a/hermes_cli/subcommands/computer_use.py b/hermes_cli/subcommands/computer_use.py index 31d39275a0..11e518ea51 100644 --- a/hermes_cli/subcommands/computer_use.py +++ b/hermes_cli/subcommands/computer_use.py @@ -63,7 +63,7 @@ def _cu_doctor(args) -> None: def _cu_perms_status(args) -> None: import json as _json - from tools.computer_use.permissions import computer_use_status + from tools.computer_use.permissions import TCC_FIELDS, computer_use_status, stale_tcc_grant_hint st = computer_use_status() if bool(getattr(args, "json", False)): print(_json.dumps(st, indent=2, sort_keys=True)) @@ -81,6 +81,8 @@ def _cu_perms_status(args) -> None: print(f" {glyph(st['screen_recording'])} Screen Recording") if not st["ready"]: print(" Grant: hermes computer-use permissions grant") + if hint := stale_tcc_grant_hint(*(f for f in TCC_FIELDS if st[f] is False)): + print(f" {hint}") else: # no TCC model — readiness is driver health print(f" {glyph(st['ready'])} driver health (no permission toggles on {st['platform']})") for c in st["checks"]: diff --git a/tests/computer_use/test_doctor.py b/tests/computer_use/test_doctor.py index 47d3203cf6..97f3ad22a8 100644 --- a/tests/computer_use/test_doctor.py +++ b/tests/computer_use/test_doctor.py @@ -450,3 +450,28 @@ class TestDoctorVersionIdentity: payload = json.loads(out.getvalue()) assert payload["hermes_identity"]["version_mismatch"] is False + + +def test_failed_tcc_row_from_health_report_names_the_stale_row_reset_for_that_service(): + """A failed ``tcc_*`` row from the driver's own health_report (0.22+, not only the 0.10 fallback probes) must + carry the stale-grant recovery for its own TCC service, because System Settings can show the toggle ON while + the daemon is denied (trycua/cua#3170).""" + from tools.computer_use import doctor + + report = _ok_report() + report["checks"] = [{"name": "tcc_screen_recording", "status": "fail", "message": "Screen Recording is not granted.", + "hint": "Grant it in System Settings."}, + {"name": "tcc_accessibility", "status": "pass", "message": "granted"}] + proc = _fake_proc_with_responses( + {"jsonrpc": "2.0", "id": 1, "result": {}}, + {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": report}}, + ) + with patch("shutil.which", return_value="/fake/cua-driver"), patch("subprocess.Popen", return_value=proc), \ + patch("sys.stdout", new_callable=StringIO) as out: + doctor.run_doctor(json_output=True) + checks = {c["name"]: c for c in json.loads(out.getvalue())["checks"]} + + assert checks["tcc_screen_recording"]["hint"].startswith("Grant it in System Settings.") + assert "tccutil reset ScreenCapture com.trycua.driver" in checks["tcc_screen_recording"]["hint"] + assert "reset Accessibility" not in checks["tcc_screen_recording"]["hint"] + assert "tccutil" not in checks["tcc_accessibility"].get("hint", "") diff --git a/tests/hermes_cli/test_computer_use_cli.py b/tests/hermes_cli/test_computer_use_cli.py index 1a711e9991..d71b5bddae 100644 --- a/tests/hermes_cli/test_computer_use_cli.py +++ b/tests/hermes_cli/test_computer_use_cli.py @@ -93,4 +93,24 @@ def test_computer_use_install_propagates_setup_result(monkeypatch, ready, upgrad monkeypatch.setattr(cua, "install_cua_driver", install) args = ("install", "--upgrade") if upgrade else ("install",) assert _invoke(monkeypatch, *args) == (0 if ready else 1) - install.assert_called_once_with(upgrade=upgrade) \ No newline at end of file + install.assert_called_once_with(upgrade=upgrade) + + +def test_permissions_status_names_the_stale_tcc_row_for_the_missing_grant( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A grant the daemon reports missing while System Settings shows it ON is a stale TCC row (trycua/cua#3170); + the status output must name the reset for exactly the missing service, never for one that is granted.""" + from tools.computer_use import permissions + + status = {"platform": "darwin", "platform_supported": True, "installed": True, "version": "cua-driver 0.28.2", + "ready": False, "can_grant": True, "checks": [], "source": None, "error": None, + "accessibility": False, "screen_recording": True, "screen_recording_capturable": True} + monkeypatch.setattr(permissions, "computer_use_status", lambda driver_cmd=None: status) + + assert _invoke(monkeypatch, "permissions", "status") == 1 + out = capsys.readouterr().out + assert "tccutil reset Accessibility com.trycua.driver" in out + assert "ScreenCapture" not in out + assert "hermes computer-use permissions grant" in out diff --git a/tools/computer_use/cua_backend_daemon.py b/tools/computer_use/cua_backend_daemon.py index 59f476b459..71dd46ec47 100644 --- a/tools/computer_use/cua_backend_daemon.py +++ b/tools/computer_use/cua_backend_daemon.py @@ -17,12 +17,13 @@ from collections import deque from typing import Any, Dict, List, Optional, Tuple from tools.computer_use import cua_backend_driver as _driver +from tools.computer_use.permissions import CUA_DRIVER_BUNDLE_ID 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_BUNDLE_ID = CUA_DRIVER_BUNDLE_ID _CUA_DRIVER_TEAM_IDS = ("4YEC26S9KF", "YCK386LBJ7") _QUIET_ERRORS = (OSError, subprocess.SubprocessError) diff --git a/tools/computer_use/doctor.py b/tools/computer_use/doctor.py index 646c66080a..dff47335c9 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tupl from hermes_cli._subprocess_compat import windows_hide_flags from tools.computer_use.permissions import _child_env as _sanitized_cua_env +from tools.computer_use.permissions import stale_tcc_grant_hint # Match the ALLOWED_STATUS_VALUES + ALLOWED_OVERALL_VALUES the cua-driver integration test pins. _STATUS_GLYPH = {"pass": "✅", "fail": "❌", "skip": "⏭️"} @@ -272,6 +273,19 @@ def _compose_fallback_report(binary: str, *, reason: str = "", timeout: float = "overall": _overall_from(checks), "checks": checks, "fallback": True, "fallback_reason": reason or "health_report unavailable"} +_TCC_CHECK_FIELDS = {"tcc_accessibility": "accessibility", "tcc_screen_recording": "screen_recording"} + +def _apply_stale_tcc_guard(report: Report) -> Report: + """Append the stale-row recovery to every failed ``tcc_*`` check. Applied at the report seam so the driver's + own health_report rows (0.22+) get it too, not only the 0.10 fallback probes — the users hit by a stale row + are on current drivers (trycua/cua#3170).""" + checks = report.get("checks") + for check in (c for c in (checks if isinstance(checks, list) else ()) if isinstance(c, dict)): + field = _TCC_CHECK_FIELDS.get(check.get("name")) + if field and check.get("status") == "fail": + check["hint"] = f"{check.get('hint') or ''} {stale_tcc_grant_hint(field)}".strip() + return report + def _apply_display_count_guard(report: Report) -> Report: """Fail an 'ok' screen_capture_capability with ``display_count=0``: macOS ScreenCaptureKit reports 0 on headless / asleep panels — TCC fine, health_report ok, yet every capture is 0x0. Turns a silent failure actionable; applied @@ -441,6 +455,7 @@ def run_doctor(driver_cmd: Optional[str] = None, *, include: Sequence[str] = (), print(f"cua-driver health_report failed: {e}", file=sys.stderr) return 2 report = _apply_display_count_guard(report) + report = _apply_stale_tcc_guard(report) report = _apply_stale_unit_guard(report) report = _apply_daemon_liveness_guard(report, binary) identity = _build_identity(binary, report) diff --git a/tools/computer_use/permissions.py b/tools/computer_use/permissions.py index b4a68acd7a..b7d6b1a699 100644 --- a/tools/computer_use/permissions.py +++ b/tools/computer_use/permissions.py @@ -18,6 +18,23 @@ from hermes_cli._subprocess_compat import windows_hide_flags _RUNTIME_PLATFORMS = frozenset({"darwin", "win32", "linux"}) # mirrors the toolset platform_gate _BOOLS = ("accessibility", "screen_recording", "screen_recording_capturable") +CUA_DRIVER_BUNDLE_ID = "com.trycua.driver" # the only identity TCC rows and the private daemon are keyed to +_TCC_SERVICES = {"accessibility": "Accessibility", "screen_recording": "ScreenCapture"} # status field -> tccutil service +TCC_FIELDS = tuple(_TCC_SERVICES) + +def stale_tcc_grant_hint(*missing: str) -> str: + """Recovery text for grants the daemon reports missing while System Settings shows the CuaDriver toggle ON. + + TCC keys each row to the app's code-signing requirement, so a row written for an earlier CuaDriver build can + stop matching after a driver update: the toggle stays ON, the daemon is denied, and flipping the toggle does + not rewrite the requirement (trycua/cua#3170). Only a reset + re-grant recovers it, so the hint names the + exact rows for the *missing* status fields. + """ + resets = " && ".join(f"tccutil reset {_TCC_SERVICES[f]} {CUA_DRIVER_BUNDLE_ID}" for f in missing if f in _TCC_SERVICES) + if not resets: + return "" + return ("If System Settings already shows CuaDriver ON, the stored grant is stale (it no longer matches the installed " + f"driver's signature): run `{resets}`, then `hermes computer-use permissions grant`.") def _child_env() -> Dict[str, str]: """cua-driver child env (telemetry policy + provider secrets stripped); ``os.environ`` on import error. @@ -101,7 +118,7 @@ def request_permissions_grant(driver_cmd: Optional[str] = None) -> int: print("cua-driver: not installed. Run: hermes computer-use install") return 2 print("Requesting Accessibility + Screen Recording for CuaDriver.\n" - "macOS will show a dialog attributed to CuaDriver (com.trycua.driver) — approve it, then return here.") + f"macOS will show a dialog attributed to CuaDriver ({CUA_DRIVER_BUNDLE_ID}) — approve it, then return here.") try: return int(subprocess.run([binary, "permissions", "grant"], env=_child_env(), stdin=subprocess.DEVNULL).returncode) except KeyboardInterrupt: # pragma: no cover - interactive diff --git a/website/docs/user-guide/features/computer-use.md b/website/docs/user-guide/features/computer-use.md index dced6a9b6f..b41c478361 100644 --- a/website/docs/user-guide/features/computer-use.md +++ b/website/docs/user-guide/features/computer-use.md @@ -84,7 +84,7 @@ platform-appropriate prereqs: | Platform | Prereqs | |---|---| -| **macOS** | System Settings → Privacy & Security → **Accessibility** + **Screen Recording**. Grant the identity named by `hermes computer-use doctor`. Standard mode uses CuaDriver.app; bounded and unrestricted modes use the Hermes host identity. | +| **macOS** | System Settings → Privacy & Security → **Accessibility** + **Screen Recording**. Grant the identity named by `hermes computer-use doctor` (CuaDriver, `com.trycua.driver`, in every permission mode — the driver daemon always launches through `CuaDriver.app`). | | **Windows** | None at install time. If you're driving over SSH (not RDP / console), you need the autostart pattern — see [cua.ai/docs/how-to-guides/driver/windows-ssh](https://cua.ai/docs/how-to-guides/driver/windows-ssh) for the Session 0 ↔ Session 1+ proxy. | | **Linux** | A reachable display server: `DISPLAY` set for X11, or `XDG_SESSION_TYPE=wayland`. Wayland sessions need an XWayland bridge for capture. AT-SPI must be on (default on GNOME/KDE/Xfce). | @@ -154,8 +154,8 @@ resetting or closing the Hermes session, cancellation cleanup, or process exit closes that transport session. Hermes also stops private runtimes that it launched for bounded or unrestricted access. One Hermes conversation cannot change another runtime's mode or grants. Bounded and -unrestricted modes use a private -embedded service under the Hermes host identity. +unrestricted modes use a private embedded daemon, launched through +`CuaDriver.app` on macOS (see above). `smart` approval remains `standard`: an LLM classification cannot stand in for a reviewed manifest. @@ -610,6 +610,19 @@ run `hermes tools` and enable the Computer Use toolset. didn't see may be blocking input. Dismiss it with `escape` or the close button. +**macOS: System Settings shows CuaDriver ON, but `hermes computer-use +permissions status` / `doctor` report Accessibility or Screen Recording as +not granted** — the stored grant is stale. macOS keys each permission row to +the app's code-signing requirement; a row written for an earlier CuaDriver +build stops matching after a driver update, and flipping the toggle does not +rewrite it. Reset the affected rows and re-grant: + +``` +tccutil reset Accessibility com.trycua.driver +tccutil reset ScreenCapture com.trycua.driver +hermes computer-use permissions grant +``` + **Element indices are stale** — SOM indices are only valid until the next `capture`. Re-capture after any state-changing action. The wrapper carries opaque `element_token`s for stale detection — you'll