From 42ae8b4410b2da15d6cec298e73dec4cd1728c9e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:01:11 +0530 Subject: [PATCH 1/2] fix(computer-use): name the stale TCC row when CuaDriver shows ON but is denied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes computer-use permissions status` and the doctor's fallback `tcc_*` rows told a user whose System Settings toggle already showed CuaDriver ON to "grant it in System Settings" — the one step that cannot help. macOS keys the TCC 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 (trycua/cua#3170, repaired by the cua-driver >= 0.22 installer on update). The daemon then reports Accessibility / Screen Recording false while the pane shows ON, and every click silently no-ops (#99732). `permissions.stale_tcc_grant_hint(*missing)` renders the reset for exactly the missing services (`tccutil reset Accessibility|ScreenCapture com.trycua.driver`, then `hermes computer-use permissions grant`); both surfaces append it only when a grant is actually reported False. Docs: the computer-use page still said bounded/unrestricted daemons run "under the Hermes host identity" — they launch through CuaDriver.app since #95381 — and gains the stale-row troubleshooting entry. --- hermes_cli/subcommands/computer_use.py | 5 ++++- tests/computer_use/test_doctor.py | 14 ++++++++++++ tests/hermes_cli/test_computer_use_cli.py | 20 +++++++++++++++++ tools/computer_use/doctor.py | 3 ++- tools/computer_use/permissions.py | 16 ++++++++++++++ .../docs/user-guide/features/computer-use.md | 22 ++++++++++++++++--- 6 files changed, 75 insertions(+), 5 deletions(-) diff --git a/hermes_cli/subcommands/computer_use.py b/hermes_cli/subcommands/computer_use.py index 76476aca7d..82c9472196 100644 --- a/hermes_cli/subcommands/computer_use.py +++ b/hermes_cli/subcommands/computer_use.py @@ -89,7 +89,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 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)) @@ -107,6 +107,9 @@ 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") + missing = [f for f in ("accessibility", "screen_recording") if st[f] is False] + if missing: + print(f" {stale_tcc_grant_hint(*missing)}") 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 89b524fd8f..5cf1b09f20 100644 --- a/tests/computer_use/test_doctor.py +++ b/tests/computer_use/test_doctor.py @@ -440,3 +440,17 @@ class TestDoctorVersionIdentity: payload = json.loads(out.getvalue()) assert payload["hermes_identity"]["version_mismatch"] is False + + +def test_fallback_tcc_row_hint_names_the_stale_row_reset_for_that_service(): + """The fallback ``tcc_*`` fail row must carry the stale-grant recovery for its own TCC service (Screen Recording + is the ``ScreenCapture`` row), because System Settings can show the toggle ON while the daemon is denied.""" + from tools.computer_use import doctor + + ctx = {"perms": {"accessibility": True, "screen_recording": False}, "perm_err": None, "plat": "darwin"} + status, message, extra = doctor._tcc_row("screen_recording", "Screen Recording", True, ctx) + + assert status == "fail" + assert "tccutil reset ScreenCapture com.trycua.driver" in extra["hint"] + assert "reset Accessibility" not in extra["hint"] + assert extra["data"] == {"screen_recording": False} diff --git a/tests/hermes_cli/test_computer_use_cli.py b/tests/hermes_cli/test_computer_use_cli.py index 756a26df26..ba85b45299 100644 --- a/tests/hermes_cli/test_computer_use_cli.py +++ b/tests/hermes_cli/test_computer_use_cli.py @@ -192,3 +192,23 @@ def test_computer_use_install_returns_nonzero_for_unrepairable_custom_override( assert _invoke(monkeypatch, "install") == 1 install.assert_called_once_with(upgrade=False) contract.assert_not_called() + + +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/doctor.py b/tools/computer_use/doctor.py index 66d2832d4f..62d322e730 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": "⏭️"} @@ -207,7 +208,7 @@ def _tcc_row(field: str, label: str, platform_bound: bool, ctx: Report) -> _Row: off_platform = platform_bound and ctx["plat"] != "darwin" return "skip", f"not applicable on {ctx['plat']}" if off_platform else f"{field} field absent from check_permissions", {} if not granted: - return "fail", f"{label} is not granted.", {"hint": _TCC_HINT.format(label), "data": {field: False}} + return "fail", f"{label} is not granted.", {"hint": f"{_TCC_HINT.format(label)} {stale_tcc_grant_hint(field)}", "data": {field: False}} data = {field: True, **({"screen_recording_capturable": perms.get("screen_recording_capturable")} if field == "screen_recording" else {})} if data.get("screen_recording_capturable") is False: # the granted-but-not-capturable row wins over plain pass return "fail", "Screen Recording granted but not capturable.", {"hint": ( diff --git a/tools/computer_use/permissions.py b/tools/computer_use/permissions.py index b4a68acd7a..9a19cf0c9b 100644 --- a/tools/computer_use/permissions.py +++ b/tools/computer_use/permissions.py @@ -18,6 +18,22 @@ 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") +_TCC_SERVICES = {"accessibility": "Accessibility", "screen_recording": "ScreenCapture"} # status field -> tccutil service + +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; the cua-driver >= 0.22 installer repairs this on update). 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]} com.trycua.driver" 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`. " + "`hermes computer-use install --upgrade` moves to a driver whose installer repairs stale rows on update.") def _child_env() -> Dict[str, str]: """cua-driver child env (telemetry policy + provider secrets stripped); ``os.environ`` on import error. diff --git a/website/docs/user-guide/features/computer-use.md b/website/docs/user-guide/features/computer-use.md index dced6a9b6f..8d382de99a 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,22 @@ 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 +``` + +cua-driver 0.22+ installers repair this automatically on update, so +`hermes computer-use install --upgrade` prevents a recurrence. + **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 From db1f3f4564eb0f1a4b75744064057f07d9a5c6e5 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:15:18 +0530 Subject: [PATCH 2/2] fix(computer-use): doctor names the stale TCC row on the health_report path too The first cut appended the recovery only in `_tcc_row`, which is built by the 0.10 fallback probes. Drivers that serve health_report (0.22+, the ones a stale row actually bites) render their own tcc_* rows untouched, so `doctor` never showed it. Apply the hint at the report seam like the display-count guard so both paths get it. Also: one CUA_DRIVER_BUNDLE_ID (permissions.py is the leaf; the daemon imports it), the CLI derives the field set from the same table, and the unverified "--upgrade repairs stale rows" claim is dropped from hint + docs (the user hitting this is already on a current driver). --- hermes_cli/subcommands/computer_use.py | 7 ++--- tests/computer_use/test_doctor.py | 29 +++++++++++++------ tools/computer_use/cua_backend_daemon.py | 3 +- tools/computer_use/doctor.py | 16 +++++++++- tools/computer_use/permissions.py | 13 +++++---- .../docs/user-guide/features/computer-use.md | 3 -- 6 files changed, 47 insertions(+), 24 deletions(-) diff --git a/hermes_cli/subcommands/computer_use.py b/hermes_cli/subcommands/computer_use.py index 82c9472196..f1e2709f51 100644 --- a/hermes_cli/subcommands/computer_use.py +++ b/hermes_cli/subcommands/computer_use.py @@ -89,7 +89,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, stale_tcc_grant_hint + 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)) @@ -107,9 +107,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") - missing = [f for f in ("accessibility", "screen_recording") if st[f] is False] - if missing: - print(f" {stale_tcc_grant_hint(*missing)}") + 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 5cf1b09f20..ae95a94f39 100644 --- a/tests/computer_use/test_doctor.py +++ b/tests/computer_use/test_doctor.py @@ -442,15 +442,26 @@ class TestDoctorVersionIdentity: -def test_fallback_tcc_row_hint_names_the_stale_row_reset_for_that_service(): - """The fallback ``tcc_*`` fail row must carry the stale-grant recovery for its own TCC service (Screen Recording - is the ``ScreenCapture`` row), because System Settings can show the toggle ON while the daemon is denied.""" +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 - ctx = {"perms": {"accessibility": True, "screen_recording": False}, "perm_err": None, "plat": "darwin"} - status, message, extra = doctor._tcc_row("screen_recording", "Screen Recording", True, ctx) + 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 status == "fail" - assert "tccutil reset ScreenCapture com.trycua.driver" in extra["hint"] - assert "reset Accessibility" not in extra["hint"] - assert extra["data"] == {"screen_recording": False} + 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/tools/computer_use/cua_backend_daemon.py b/tools/computer_use/cua_backend_daemon.py index 3f9bab5dee..8c90e9e27b 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 62d322e730..e653a1e17f 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -208,7 +208,7 @@ def _tcc_row(field: str, label: str, platform_bound: bool, ctx: Report) -> _Row: off_platform = platform_bound and ctx["plat"] != "darwin" return "skip", f"not applicable on {ctx['plat']}" if off_platform else f"{field} field absent from check_permissions", {} if not granted: - return "fail", f"{label} is not granted.", {"hint": f"{_TCC_HINT.format(label)} {stale_tcc_grant_hint(field)}", "data": {field: False}} + return "fail", f"{label} is not granted.", {"hint": _TCC_HINT.format(label), "data": {field: False}} data = {field: True, **({"screen_recording_capturable": perms.get("screen_recording_capturable")} if field == "screen_recording" else {})} if data.get("screen_recording_capturable") is False: # the granted-but-not-capturable row wins over plain pass return "fail", "Screen Recording granted but not capturable.", {"hint": ( @@ -273,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 @@ -442,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 9a19cf0c9b..b7d6b1a699 100644 --- a/tools/computer_use/permissions.py +++ b/tools/computer_use/permissions.py @@ -18,22 +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; the cua-driver >= 0.22 installer repairs this on update). Only a - reset + re-grant recovers it, so the hint names the exact rows for the *missing* status fields. + 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]} com.trycua.driver" for f in missing if f in _TCC_SERVICES) + 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`. " - "`hermes computer-use install --upgrade` moves to a driver whose installer repairs stale rows on update.") + 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. @@ -117,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 8d382de99a..b41c478361 100644 --- a/website/docs/user-guide/features/computer-use.md +++ b/website/docs/user-guide/features/computer-use.md @@ -623,9 +623,6 @@ tccutil reset ScreenCapture com.trycua.driver hermes computer-use permissions grant ``` -cua-driver 0.22+ installers repair this automatically on update, so -`hermes computer-use install --upgrade` prevents a recurrence. - **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