feat(cua-driver): implement macOS LaunchAgent autostart - #2990
Open
x7peeps wants to merge 1 commit into
Open
Conversation
Closes trycua#2196. ## Summary Implements the macOS autostart path natively in `autostart.rs`, replacing the Windows-only stub with a real LaunchAgent implementation: - `enable` — writes `~/Library/LaunchAgents/com.trycua.driver.plist` (RunAtLoad + KeepAlive), then `launchctl bootstrap gui/<uid> <plist>` (modern API) and `kickstart -k` to start it for the current session. - `disable` — `launchctl bootout gui/<uid>/<label>` + removes the plist (no-op when absent). - `status` — reports `not-registered` / `registered (not running)` / `registered (running)` using the existing daemon-socket liveness probe (`serve::is_daemon_listening`), mirroring the Windows path. - `kick` — `launchctl kickstart -k gui/<uid>/<label>`. The LaunchAgent label is the bundle identity (`com.trycua.driver` release / `com.trycua.cua-driver-local` local), so the launchd-started daemon carries the same TCC responsibility identity as the signed app — Accessibility and Screen Recording grants survive logons. This is the exact problem described in trycua#2196. ## Why - **Hermes and other integrations** currently have no supported way to ensure the daemon is running before background clients initialize computer-use MCP on macOS. The only path today is the manual `install-local.sh --autostart` recipe, which uses the legacy `launchctl load/unload` API and does not expose `enable/disable/status/kick` subcommands. - The `status`/`disable` semantics now match Windows: query failures are reported distinctly from "not registered", and removing an absent entry is a no-op. ## Design notes - **KeepAlive** gives the same crash-restart semantics as the Windows `RestartCount 3` in a single plist key, the native launchd idiom. - **`bootstrap`/`bootout` over `load`/`unload`**: modern launchctl API (macOS 13+); the legacy `load` still works but is deprecated. - **Status reuses the socket probe** — one liveness mechanism across platforms. - **`current_exe_for_autostart` already canonicalizes on non-Windows** so the LaunchAgent keeps working after upgrades that flip symlinks (mirror of the Windows junction preservation, trycua#2809). - Linux remains a stub (unchanged), with an error pointing at the manual systemd user-unit recipe — a natural follow-up with the same shape. ## Verification - `cargo check -p cua-driver --tests` — clean. - `cargo test -p cua-driver --bin cua-driver autostart::` — 11 passed (7 existing + 4 new macOS `xml_escape` tests). - E2E on macOS 26.5.2 (Apple Silicon): `status` → `registered (running)`; `disable` → plist removed, `not-registered`; `enable` → plist written with correct Label/ProgramArguments/KeepAlive, launchd `state = running`, pid present; `kick` → entry restarted; then cleanup and the production daemon was restored and verified. - `cargo fmt -- --check` clean (ci-rust-format).
Author
|
Companion Hermes-side diagnostic: NousResearch/hermes-agent#81845 adds a |
x7peeps
added a commit
to x7peeps/hermes-agent
that referenced
this pull request
Aug 9, 2026
…bleshooting ## Story: from a broken local session to a diagnostic While integrating cua-driver 0.19+ as the Hermes computer-use backend on macOS, we hit a wall: `capture` saw 358 accessibility elements and AXPress clicks succeeded, but every SOM bound came back `(0,0,0,0)` — coordinate clicks were impossible. The root cause was an old driver (pre-0.19 `get_window_state` has no structured frames), and after upgrading we discovered the real operational gap: **nothing told us whether the daemon would survive a reboot.** This PR turns that debugging journey into a permanent diagnostic: ## What changed ### `tools/computer_use/doctor.py` — new `daemon_autostart` check `hermes computer-use doctor` now probes `cua-driver autostart status` and reports whether the serve daemon is registered to start at every logon: - `pass` — autostart registered (daemon running, or idle but registered) - `fail` — `not-registered`: daemon must be started manually after each logon - `skip` — binary unresolved, or driver too old to support the subcommand (pre-0.19.2 returns a "Windows-only" stub error); never fatal This is the check that would have told us immediately why the daemon kept disappearing after reboots, before we ever had to dig through launchd. ### `skills/.../hermes-agent/references/troubleshooting.md` — Computer use section New "Computer use not working (cua-driver)" section capturing the field knowledge: - `(0,0,0,0)` bounds / AXPress-works-but-coordinates-fail → old driver, upgrade path included - daemon dies on reboot → `cua-driver autostart enable` (macOS LaunchAgent / Windows Scheduled Task), and why the LaunchAgent label matching the TCC identity matters for grants persisting across logons - macOS grants granted but still can't capture → daemon must run under the CLI identity, not `open -a CuaDriver --args serve` - `permissions status` reports `daemon_running: false` while capture works → known probe quirk with launchd-managed daemons; trust launchctl + real bounds instead - `cp/mv` of CuaDriver.app → "Operation not permitted" → TCC app-integrity protection: rm + rebuild, then re-grant ## Why this shape - **CLI probe, not a new core tool**: the check reuses the existing `resolve_cua_driver_cmd()` and runs one subprocess. No new MCP surface, no schema change, no new env var — the "smallest footprint" path. - **Graceful degradation**: on drivers where `autostart` is a stub (anything pre-0.19.2), the check reports `skip` with context instead of failing doctor. Nothing about the existing 0.10 health_report fallback changes. - **Docs over code where possible**: the troubleshooting knowledge is deliberately in the skill reference so it loads into any session that hits the symptom, not buried in a code comment. ## Verification - `pytest tests/computer_use/test_doctor.py` — 22 passed (15 existing + 7 new for `_autostart_probe`). The new tests caught two real substring bugs during development (`not-registered` contains `registered`; `not running` contains `running`) — order of checks matters. - `pytest tests/computer_use/` — 51 passed. - Live on macOS 26.5.2 with cua-driver 0.19.1: doctor reports `skip` (old stub) gracefully; with a 0.19.2 debug build the probe reports `pass: autostart registered and daemon running` and `fail: not-registered` when disabled — both exercised against a real launchd-managed daemon. ## Platform compatibility - macOS: probes LaunchAgent registration via the CLI (works with cua-driver 0.19.2+, which implements `autostart` for macOS). - Windows: same CLI probe — Scheduled Task state. - Linux: `autostart` is still a stub upstream; check reports `skip` with the actionable message from the driver. - Drivers older than 0.19.2: `skip` (subcommand unavailable), doctor still passes on everything else. ## Known gaps - The check trusts cua-driver's CLI output. On platforms/drivers where the subcommand is missing it degrades to `skip`, which is correct but means a stale-registration failure on an old driver goes unreported. Once the upstream macOS/Linux autostart lands (trycua/cua#2990), this becomes a full pass/fail check everywhere.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(cua-driver): macOS LaunchAgent autostart (enable/disable/status/kick)
Story: from a broken local session to a working autostart
This PR is the upstream contribution that closes
#2196 — and it carries the full
story of how we got here, because the debugging journey is the proof that the
design works.
1. The local session that would not run
We integrate cua-driver as the computer-use backend of Hermes Agent
(NousResearch/hermes-agent). On a macOS 26.5.2 (Apple Silicon) machine the
computer-use toolset failed in a very specific way:
capturecould identify 358 accessibility elements andAXPressclickssucceeded ("✅ Performed AXPress"), but every SOM bound came back as
(0,0,0,0)— so coordinate-based clicking was impossible.get_window_statedoes notreturn
structuredContent.elements. The Hermes backend therefore fell backto a markdown regex parser and every element frame was
0.Diagnosis chain (what we ruled out before the real cause):
permission was fine (a permission failure yields an empty tree).
(structured frames landed upstream, see feat(cua-driver): cursor overhaul + Hermes-decoupling MCP surface #1961) fixed bounds.
2. Discovering the project while debugging
While chasing the
(0,0,0,0)bounds we found this repo —trycua/cua, theopen-source computer-use 2.0 driver stack. The debugging trail ran straight
through its code:
tools/computer_use/cua_backend.pyin Hermes already knew aboutstructuredContent.elements[].frame(the fix from feat(cua-driver): cursor overhaul + Hermes-decoupling MCP surface #1961);cua-driver autostartturned out to be Windows-only; macOS/Linux arestubbed with
NOT_YETinautostart.rs;libs/cua-driver/scripts/install-local.sh --autostart)writes a LaunchAgent, but with the legacy
launchctl load/unloadAPI.3. Research + capability build-out (what we actually ran)
We upgraded and verified the full stack on our machine:
cua-driver --versioncp/mv→ EPERM,rmallowed) → deleted and rebuiltCuaDriver.appwith the 0.19.1 binaries, ad-hoc re-signedcodesign -dvcua-driver permissions status~/.local/bin/cua-driver serve(CLI identity) matches the TCC grant;open -a CuaDriver --args servedoes notps aux+ capture bounds~/Library/LaunchAgents/com.trycua.driver.plist(RunAtLoad + KeepAlive) → launchd manages the daemon at every logonlaunchctl print gui/501/com.trycua.driver→state = runningKey insight from the field: launchd-started daemons are the supported TCC
path. A launchd-managed
cua-driver servecarries the stablecom.trycua.driverresponsibility identity, so Accessibility / ScreenRecording grants persist across logons — exactly what #2196 describes wanting.
4. Landing it: this PR
This PR implements the macOS (and by extension, the Unix) autostart path
natively in
autostart.rs, replacing the stubs with a real LaunchAgentimplementation:
enable— writes aLaunchAgentplist under~/Library/LaunchAgents/(label derived from the bundle identity, matching the Windows task-name
pattern), then
launchctl bootstrap gui/<uid> <plist>(modern API; thelegacy install script uses
load, we usebootstrap/bootout).disable—launchctl bootout gui/<uid>/<label>+ removes the plist(no-op when absent).
status— reportsnot-registered/registered (not running)/registered (running), reusing the existing daemon-socket liveness check(
serve::is_daemon_listening) exactly like the Windows path does.kick—launchctl kickstart -k gui/<uid>/<label>to (re)start theautostart entry for the current session.
Platform mapping now:
cua-driver-serve(release) /cua-driver-local-serve(local)com.trycua.driver(release) /com.trycua.cua-driver-local(local)Why this design
KeepAlive=truegives the same crash-restart semantics as the Windows
RestartCount 3inone key, and is the native idiom.
bootstrap/bootoutoverload/unload: the modern launchctl API(macOS 13+); the legacy
loadused by the install script still works butis deprecated on current macOS.
is_daemon_listeningcall theWindows
statususes, so behavior is consistent across platforms and wedon't add a second liveness mechanism.
current_exe_for_autostart) so the LaunchAgent keeps working afterupgrades that flip symlinks — the mirror of the Windows junction
preservation (fix(cua-driver): keep Windows autostart on the junction path #2809).
Verification (run on this machine, macOS 26.5.2)
Known gaps
systemd --user) remains a stub; the error message now points atthe same manual recipe. Implementing it is a natural follow-up with the
same
enable/disable/status/kickshape.(pure POSIX + launchctl), but has not been exercised.
upgrade invalidate them — upstream behavior, documented in the
troubleshooting guide).
Who should enable this
(Hermes, Codex, Cursor, OpenClaw, ...) who currently has to hand-write a
LaunchAgent or paste a startup one-liner.
survive reboots without manual
cua-driver serve.Quick-start
Troubleshooting
autostart statussaysnot-registeredright afterenableenable; check~/Library/LaunchAgents/com.trycua.driver.plistexistspermissions statusreportsdaemon_running: falselaunchctl print gui/$(id -u)/com.trycua.driverand real capture boundslaunchctl bootstrapexits non-zerocua-driver autostart disablethenenablePlatform compatibility
launchctl bootstrap/kickstart, unavailable before that;macOS 12 and older would need
load— out of scope, the manual recipestill covers them).