feat(platform-macos): TCC auto-relaunch for cua-driver-rs mcp (#1525) - #1530
Conversation
Adds the .app bundle skeleton needed for issue #1525's TCC auto-relaunch path. The Rust port currently ships as a bare binary at ~/.local/bin/ cua-driver, which inherits the calling shell/IDE-terminal's TCC responsibility when invoked as an MCP stdio server — the same pathology the Swift driver hit before #1479. The fix mirrors the Swift approach: ship a minimal .app bundle (CuaDriverRs.app, bundle id com.trycua.cuadriverrs) wrapping the same universal binary, and resolve the bare CLI symlink into it. Future commits wire up the detection + relaunch + proxy logic. This commit does not change runtime behavior yet. It only: - Adds libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plist with the bundle id, LSUIElement=true (headless), distinct from the Swift driver's com.trycua.driver so both installs coexist. - Updates scripts/install.sh on macOS to download the directory tarball (which carries the .app), ditto it to /Applications/ CuaDriverRs.app, and symlink ~/.local/bin/cua-driver into the bundle (matching the Swift install layout). - Updates .github/workflows/cd-rust-cua-driver.yml to assemble the .app at release time, drop it into every macOS directory tarball, and keep the existing bare-binary tarball untouched (so users who explicitly want only the binary can still grab it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds crates/cua-driver/src/bundle.rs with three small helpers used by
the upcoming TCC auto-relaunch path:
- `is_executable_inside_cuadriverrs_app()` — Rust mirror of Swift's
`isExecutableInsideCuaDriverApp()`. Resolves `current_exe()` through
symlinks via `canonicalize` and substring-matches
`/CuaDriverRs.app/Contents/MacOS/`. False for raw `cargo run` / dev
invocations, true for the installed `~/.local/bin/cua-driver` symlink
resolving into `/Applications/CuaDriverRs.app/Contents/MacOS/`.
- `parent_is_not_launchd()` — `unsafe { libc::getppid() } != 1`. When
the parent is launchd, TCC attribution is already correct (we're
the daemon LaunchServices spawned). Otherwise we're shell-spawned
and need to relaunch.
- `is_env_truthy(name)` — recognizes `1|true|yes|on`. Used for
`CUA_DRIVER_RS_MCP_NO_RELAUNCH=1` escape hatch.
Unit tests cover all three. The dead-code allow at the top of the
file silences warnings until commit 4 wires the helpers into
`MCPCommand::run`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds crates/cua-driver/src/proxy.rs — a stdio MCP server whose `tools/list` and `tools/call` handlers forward through a running `cua-driver-rs serve` daemon over its Unix socket. This is the runtime half of the TCC auto-relaunch path (issue #1525, mirror of Swift PR #1479's `CuaDriverMCPServer.makeProxy`). The proxy lives in `cua-driver` (not `mcp-server`) because the daemon protocol is owned by `crate::serve` — `mcp-server` already speaks JSON-RPC against an in-process registry, the proxy speaks the same protocol on the client side but the server side is the daemon's UDS protocol. Putting it here avoids `mcp-server → cua-driver` reverse coupling. Behavior: - Fails fast at startup if the daemon isn't reachable, so MCP clients see a clear error rather than a successful handshake that advertises zero tools (matches Swift `fetchProxyToolList`). - Caches the daemon's tool list once at startup (registry is static for the daemon's lifetime). - Forwards `tools/call` via `tokio::task::spawn_blocking` so the sync UDS client doesn't block the reactor during AX-heavy calls like `screenshot` / `get_window_state`. - Reshapes the daemon's `{name, description, input_schema, ...}` envelope into MCP's `{name, description, inputSchema, annotations: {...}}` shape, identical to `ToolDef::to_list_entry`'s in-process output. Drive-by: extend the daemon's `list` handler (both Unix + Windows paths) to include `input_schema` + annotation hints so proxy callers can build a complete `tools/list` from one round-trip instead of N+1 list+describe calls. Backwards compatible — older clients that only read name/description still work. Wired into `MCPCommand::run` in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires up the TCC auto-relaunch path so `cua-driver-rs mcp` invoked from an IDE terminal (Claude Code, Cursor, VS Code, Warp) transparently delegates to a daemon running under CuaDriverRs.app's TCC attribution. This is the user-facing payoff for issue #1525 — the equivalent of Swift PR #1479's `MCPCommand` for the Rust port. Changes: - `cli::Command::Mcp` becomes a struct variant carrying `no_daemon_relaunch: bool` and `socket: Option<String>` (new CLI flags `--no-daemon-relaunch` and `--socket <path>`). - `cli::should_use_daemon_proxy()` — Rust mirror of Swift's `shouldUseDaemonProxy`: returns true only when (1) opt-out flag/env not set, (2) bundle-context detection fires, (3) ppid != 1. - `cli::launch_daemon_and_wait()` — `Command::new("/usr/bin/open") .args(["-n", "-g", "-a", "CuaDriverRs", "--args", "serve"])` then poll the socket up to 10s. Same flags Swift uses; -n forces a new instance, -g keeps it backgrounded. - `cli::run_mcp_via_daemon_proxy()` — orchestrate: ensure daemon is up, then `proxy::run_proxy` against its socket on a fresh tokio runtime. - `main.rs` (macOS): dispatch through the proxy path when `should_use_daemon_proxy` is true, otherwise fall through to the in-process MCP server exactly as before. Non-macOS targets parse the flags cleanly so cross-platform MCP configs work, but ignore them (no TCC, no proxy). - Drop the `#[allow(dead_code)]` shims from bundle.rs and proxy.rs now that the helpers are wired up. Escape hatches: `--no-daemon-relaunch` flag or `CUA_DRIVER_RS_MCP_NO_RELAUNCH=1` env var. Build verified clean; existing tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a PARITY.md entry under the lifecycle/process-model section
linking Swift's `MCPCommand` (in CuaDriverCommand.swift) + Bundle
helpers + `CuaDriverMCPServer.makeProxy` to the new Rust modules
(`bundle.rs`, `cli.rs::{should_use_daemon_proxy,
launch_daemon_and_wait, run_mcp_via_daemon_proxy}`, `proxy.rs`).
Documents:
- Why the bundle id intentionally diverges from Swift
(`com.trycua.cuadriverrs` vs `com.trycua.driver`) so the two
installs coexist in TCC.
- All four escape hatches: `--no-daemon-relaunch` flag,
`CUA_DRIVER_RS_MCP_NO_RELAUNCH=1`, `--socket <path>`, and the
Rust-only `CUA_DRIVER_RS_MCP_FORCE_PROXY=1` for users who've
wrapped the binary in a custom bundle or want to smoke-test the
proxy against a manually-started daemon.
- The daemon `list` protocol extension (now returns full ToolDef
so the proxy can build `tools/list` in one round-trip).
- A manual smoke-test recipe to verify the path end-to-end on
macOS.
Also wires up the `CUA_DRIVER_RS_MCP_FORCE_PROXY=1` knob in
`cli::should_use_daemon_proxy` + `cli::run_mcp_via_daemon_proxy` so
the proxy path can be exercised without an installed `.app` bundle
(skips both the bundle-context check and the `open -a` daemon
spawn — caller must supply a daemon on `--socket`).
Integration test deferred per coordinator request — the substantive
detection + proxy + relaunch logic ships in commits 1–4; this PR
will be smoke-tested manually before merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR implements macOS TCC auto-relaunch for ChangesmacOS MCP daemon proxy
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
libs/cua-driver-rs/PARITY.md (1)
1116-1125: ⚡ Quick winConsider adding expected output examples to the smoke test.
The smoke test procedure is actionable but would be clearer with concrete expected outputs. For example:
- Step 3: Show a sample
tools/listresponse snippet or confirm specific tools appear- Step 4: Include the actual error message text that "daemon not reachable" produces
This would make the smoke test easier to execute correctly and help verify the proxy path is working as intended.
📝 Suggested enhancement
### Manual smoke test (macOS) 1. `cua-driver serve --socket /tmp/test.sock &` 2. `CUA_DRIVER_RS_MCP_FORCE_PROXY=1 cua-driver mcp --socket /tmp/test.sock` 3. From an MCP client, run the standard initialize → tools/list → tools/call get_screen_size handshake. Expect identical envelope - shape to the in-process path. + shape to the in-process path. The `tools/list` response should + include all registered tools (e.g., `move_cursor`, `click`, + `get_screen_size`, etc.) with full `inputSchema` definitions. 4. Without spawning the daemon first, repeat step 2. Expect - non-zero exit and a "daemon not reachable" diagnostic on stderr - (the fail-fast contract that matches Swift `makeProxy`). + non-zero exit and an error message on stderr indicating the + daemon socket is not reachable (the fail-fast contract that + matches Swift `makeProxy`). Example error: "Failed to connect + to daemon at /tmp/test.sock".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/cua-driver-rs/PARITY.md` around lines 1116 - 1125, Add concrete expected output examples to the Manual smoke test: show a sample tools/list JSON snippet (or at least expected tool names) for step 3 and a sample envelope shape for the tools/call get_screen_size handshake, and for step 4 include the exact stderr text returned when the daemon is unreachable (the "daemon not reachable" message produced when running `CUA_DRIVER_RS_MCP_FORCE_PROXY=1 cua-driver mcp --socket /tmp/test.sock` without a running `cua-driver serve`), referencing the existing commands `cua-driver serve`, `CUA_DRIVER_RS_MCP_FORCE_PROXY`, `cua-driver mcp`, `tools/list`, and `tools/call get_screen_size` so readers can match their outputs against the expected snippets.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/cd-rust-cua-driver.yml:
- Around line 179-205: The static Info.plist copied into release/CuaDriverRs.app
can drift from the release tag; update the "Assemble CuaDriverRs.app bundle"
step to stamp CFBundleShortVersionString and CFBundleVersion in
release/CuaDriverRs.app/Contents/Info.plist with the value from ${{
steps.version.outputs.version }} (use a plist editor such as
/usr/libexec/PlistBuddy or xcrun defaults/plutil equivalent) after copying the
skeleton and before packaging so both keys reflect the actual release tag;
ensure you reference the Info.plist path and the keys CFBundleShortVersionString
and CFBundleVersion in the step.
In `@libs/cua-driver-rs/crates/cua-driver/src/cli.rs`:
- Around line 286-298: In launch_daemon_and_wait, the relaunched daemon is
invoked with args ["serve"] only, so when a custom socket is requested the new
process still listens on the default socket; modify the Command built in
launch_daemon_and_wait (and the equivalent invocation used at the other block
around the later spawn) to include the socket override flag and value (the same
"--socket" and socket_path variable) in the .args list so the relaunched
CuaDriverRs receives the custom socket path and readiness polling matches where
the daemon actually listens.
In `@libs/cua-driver-rs/crates/cua-driver/src/proxy.rs`:
- Around line 249-256: The current branch turning daemon failures into a
JSON-RPC internal error should instead wrap tool failures in a successful
JSON-RPC response; in the !resp.ok branch (using resp, resp.error,
resp.exit_code and id) replace the Response::error(...) call with a
Response::ok(...) that returns a payload indicating a tool-level failure (e.g.,
include isError: true, the error message from resp.error.unwrap_or(...), and the
original exit code or None). Keep special-case mapping for exit_code == Some(64)
only in the payload metadata if needed, but do not emit a JSON-RPC error code;
return a normal Response::ok to preserve transport-level success while signaling
the tool failure in the payload.
In `@libs/cua-driver-rs/scripts/install.sh`:
- Around line 178-203: The Darwin install branch currently allows falling
through to the non-macOS install when the app bundle is missing; change the
logic so on macOS (OS == "Darwin") we require SRC_APP to be set and a directory
and fail fast with a clear error if it's absent or not a bundle (do this where
the if [[ "$OS" == "Darwin" && -n "$SRC_APP" && -d "$SRC_APP" ]] check is,
emitting an err message referencing SRC_APP/APP_DEST and exit 1), instead of
letting the script continue to the else path that installs a bare binary
(BIN_LINK), ensuring we do not silently bypass the TCC relaunch path.
---
Nitpick comments:
In `@libs/cua-driver-rs/PARITY.md`:
- Around line 1116-1125: Add concrete expected output examples to the Manual
smoke test: show a sample tools/list JSON snippet (or at least expected tool
names) for step 3 and a sample envelope shape for the tools/call get_screen_size
handshake, and for step 4 include the exact stderr text returned when the daemon
is unreachable (the "daemon not reachable" message produced when running
`CUA_DRIVER_RS_MCP_FORCE_PROXY=1 cua-driver mcp --socket /tmp/test.sock` without
a running `cua-driver serve`), referencing the existing commands `cua-driver
serve`, `CUA_DRIVER_RS_MCP_FORCE_PROXY`, `cua-driver mcp`, `tools/list`, and
`tools/call get_screen_size` so readers can match their outputs against the
expected snippets.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: dc6b8fd4-4e7a-48fb-acd2-5e99f60afbfd
⛔ Files ignored due to path filters (1)
libs/cua-driver-rs/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (11)
.github/workflows/cd-rust-cua-driver.ymllibs/cua-driver-rs/PARITY.mdlibs/cua-driver-rs/crates/cua-driver/Cargo.tomllibs/cua-driver-rs/crates/cua-driver/src/bundle.rslibs/cua-driver-rs/crates/cua-driver/src/cli.rslibs/cua-driver-rs/crates/cua-driver/src/main.rslibs/cua-driver-rs/crates/cua-driver/src/proxy.rslibs/cua-driver-rs/crates/cua-driver/src/serve.rslibs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plistlibs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/MacOS/.gitkeeplibs/cua-driver-rs/scripts/install.sh
| - name: Assemble CuaDriverRs.app bundle | ||
| working-directory: libs/cua-driver-rs | ||
| run: | | ||
| # Copy the bundle skeleton (Info.plist) from scripts/ and drop | ||
| # the universal binary into Contents/MacOS/cua-driver. The | ||
| # assembled bundle goes into every directory tarball so | ||
| # install.sh can `ditto` it to /Applications/CuaDriverRs.app | ||
| # for the TCC auto-relaunch path. | ||
| # | ||
| # No codesigning at this layer — the bundle ships ad-hoc | ||
| # signed (the bare binary inherits whatever signature was | ||
| # applied at build/notarize time, currently none for the | ||
| # Rust port). TCC keys grants on the cdhash of the binary | ||
| # the user grants permission to, so ad-hoc is fine for the | ||
| # BETA release; production signing will land in a separate | ||
| # change that wires up the notarization script the way the | ||
| # Swift driver does. | ||
| mkdir -p release/CuaDriverRs.app | ||
| cp -R scripts/CuaDriverRs.app/Contents release/CuaDriverRs.app/Contents | ||
| cp release/universal/cua-driver \ | ||
| release/CuaDriverRs.app/Contents/MacOS/cua-driver | ||
| chmod +x release/CuaDriverRs.app/Contents/MacOS/cua-driver | ||
| # Remove the .gitkeep we use in source control — it's not | ||
| # part of the runtime bundle. | ||
| rm -f release/CuaDriverRs.app/Contents/MacOS/.gitkeep | ||
| ls -la release/CuaDriverRs.app/Contents/MacOS | ||
| - name: Package |
There was a problem hiding this comment.
Bundle version metadata should be stamped at package time.
The workflow copies a static Info.plist, so CFBundleShortVersionString / CFBundleVersion can drift from the actual release tag. Please stamp both from ${{ steps.version.outputs.version }} during bundle assembly.
Suggested patch
- name: Assemble CuaDriverRs.app bundle
working-directory: libs/cua-driver-rs
run: |
+ VERSION="${{ steps.version.outputs.version }}"
mkdir -p release/CuaDriverRs.app
cp -R scripts/CuaDriverRs.app/Contents release/CuaDriverRs.app/Contents
+ /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" \
+ release/CuaDriverRs.app/Contents/Info.plist
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" \
+ release/CuaDriverRs.app/Contents/Info.plist
cp release/universal/cua-driver \
release/CuaDriverRs.app/Contents/MacOS/cua-driver
chmod +x release/CuaDriverRs.app/Contents/MacOS/cua-driver🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/cd-rust-cua-driver.yml around lines 179 - 205, The static
Info.plist copied into release/CuaDriverRs.app can drift from the release tag;
update the "Assemble CuaDriverRs.app bundle" step to stamp
CFBundleShortVersionString and CFBundleVersion in
release/CuaDriverRs.app/Contents/Info.plist with the value from ${{
steps.version.outputs.version }} (use a plist editor such as
/usr/libexec/PlistBuddy or xcrun defaults/plutil equivalent) after copying the
skeleton and before packaging so both keys reflect the actual release tag;
ensure you reference the Info.plist path and the keys CFBundleShortVersionString
and CFBundleVersion in the step.
| pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::Result<()> { | ||
| use std::process::{Command as Cmd, Stdio}; | ||
| use std::time::{Duration, Instant}; | ||
|
|
||
| let status = Cmd::new("/usr/bin/open") | ||
| // `-n` forces a new instance: CuaDriverRs.app might already be | ||
| // running from a previous MCP session, and without `-n`, `open | ||
| // -a` would re-use it and drop our `--args serve`, leaving no | ||
| // daemon up. `-g` keeps the new instance backgrounded — | ||
| // LSUIElement=true in Info.plist already does this but the | ||
| // flag makes it explicit and matches Swift's invocation. | ||
| .args(["-n", "-g", "-a", "CuaDriverRs", "--args", "serve"]) | ||
| .stdout(Stdio::null()) |
There was a problem hiding this comment.
Pass the socket override into the relaunched daemon command.
When mcp --socket <custom> hits auto-relaunch, the daemon is started on the default socket (serve only), but readiness is polled on the custom socket path. That makes proxy bootstrap fail consistently for custom socket users.
💡 Suggested fix
#[cfg(target_os = "macos")]
pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::Result<()> {
@@
let status = Cmd::new("/usr/bin/open")
@@
- .args(["-n", "-g", "-a", "CuaDriverRs", "--args", "serve"])
+ .args([
+ "-n",
+ "-g",
+ "-a",
+ "CuaDriverRs",
+ "--args",
+ "serve",
+ "--socket",
+ socket_path,
+ ])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();Also applies to: 340-361
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@libs/cua-driver-rs/crates/cua-driver/src/cli.rs` around lines 286 - 298, In
launch_daemon_and_wait, the relaunched daemon is invoked with args ["serve"]
only, so when a custom socket is requested the new process still listens on the
default socket; modify the Command built in launch_daemon_and_wait (and the
equivalent invocation used at the other block around the later spawn) to include
the socket override flag and value (the same "--socket" and socket_path
variable) in the .args list so the relaunched CuaDriverRs receives the custom
socket path and readiness polling matches where the daemon actually listens.
| if !resp.ok { | ||
| let msg = resp.error.unwrap_or_else(|| "daemon reported failure".into()); | ||
| // exit_code 64 is EX_USAGE — bad params, surfaces as a | ||
| // JSON-RPC InvalidParams. Any other non-zero is treated as | ||
| // an internal error. | ||
| let code = if resp.exit_code == Some(64) { -32602 } else { -32603 }; | ||
| return Response::error(id, code, msg); | ||
| } |
There was a problem hiding this comment.
Keep tool failures in tools/call results instead of JSON-RPC internal errors.
This branch converts daemon call failures into -32603, which makes normal tool failures look like transport/protocol failures. For MCP compatibility, tool failures should return Response::ok with isError: true payload.
💡 Suggested fix
if !resp.ok {
let msg = resp.error.unwrap_or_else(|| "daemon reported failure".into());
- // exit_code 64 is EX_USAGE — bad params, surfaces as a
- // JSON-RPC InvalidParams. Any other non-zero is treated as
- // an internal error.
- let code = if resp.exit_code == Some(64) { -32602 } else { -32603 };
- return Response::error(id, code, msg);
+ // Keep argument/usage failures as JSON-RPC InvalidParams.
+ if resp.exit_code == Some(64) {
+ return Response::error(id, -32602, msg);
+ }
+ // Tool-level failures should remain MCP CallTool results.
+ return Response::ok(id, serde_json::json!({
+ "content": [{ "type": "text", "text": msg }],
+ "isError": true
+ }));
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@libs/cua-driver-rs/crates/cua-driver/src/proxy.rs` around lines 249 - 256,
The current branch turning daemon failures into a JSON-RPC internal error should
instead wrap tool failures in a successful JSON-RPC response; in the !resp.ok
branch (using resp, resp.error, resp.exit_code and id) replace the
Response::error(...) call with a Response::ok(...) that returns a payload
indicating a tool-level failure (e.g., include isError: true, the error message
from resp.error.unwrap_or(...), and the original exit code or None). Keep
special-case mapping for exit_code == Some(64) only in the payload metadata if
needed, but do not emit a JSON-RPC error code; return a normal Response::ok to
preserve transport-level success while signaling the tool failure in the
payload.
| if [[ "$OS" == "Darwin" && -n "$SRC_APP" && -d "$SRC_APP" ]]; then | ||
| if [[ ! -w "/Applications" ]]; then | ||
| err "/Applications is not writable. Re-run this installer in a shell where it is, or grant write access." | ||
| err " Without the .app bundle, \`cua-driver-rs mcp\` from an IDE terminal will not auto-relaunch into a TCC-correct daemon." | ||
| exit 1 | ||
| fi | ||
| if [[ -e "$APP_DEST" ]]; then | ||
| log "removing existing $APP_DEST" | ||
| rm -rf "$APP_DEST" | ||
| fi | ||
| log "installing $APP_DEST" | ||
| # `ditto` preserves the bundle's metadata + nested symlinks the way | ||
| # Apple's installer would. `cp -R` works but doesn't preserve as | ||
| # much, and ditto is always present on macOS. | ||
| ditto "$SRC_APP" "$APP_DEST" | ||
| APP_BINARY="$APP_DEST/Contents/MacOS/$BINARY_NAME" | ||
| if [[ ! -x "$APP_BINARY" ]]; then | ||
| err "binary missing at $APP_BINARY (refusing to create broken symlink)" | ||
| exit 1 | ||
| fi | ||
| ln -sf "$APP_BINARY" "$BIN_LINK" | ||
| log "symlinked $BIN_LINK -> $APP_BINARY" | ||
| else | ||
| install -m 0755 "$SRC" "$BIN_LINK" | ||
| log "installed $BIN_LINK (version $VERSION)" | ||
| fi |
There was a problem hiding this comment.
Avoid silent bare-binary fallback on macOS when the app bundle is missing.
On Darwin, if SRC_APP is absent, the script currently succeeds via the non-macOS branch. That can silently bypass the TCC relaunch path. Prefer failing fast with a clear error in the Darwin path.
Suggested patch
-if [[ "$OS" == "Darwin" && -n "$SRC_APP" && -d "$SRC_APP" ]]; then
+if [[ "$OS" == "Darwin" ]]; then
+ if [[ -z "$SRC_APP" || ! -d "$SRC_APP" ]]; then
+ err "expected $APP_NAME in macOS tarball but didn't find it"
+ err "refusing bare-binary fallback on macOS because it disables TCC-correct relaunch behavior"
+ exit 1
+ fi
if [[ ! -w "/Applications" ]]; then
err "/Applications is not writable. Re-run this installer in a shell where it is, or grant write access."
err " Without the .app bundle, \`cua-driver-rs mcp\` from an IDE terminal will not auto-relaunch into a TCC-correct daemon."
exit 1
fi
@@
-else
+else
install -m 0755 "$SRC" "$BIN_LINK"
log "installed $BIN_LINK (version $VERSION)"
fi📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if [[ "$OS" == "Darwin" && -n "$SRC_APP" && -d "$SRC_APP" ]]; then | |
| if [[ ! -w "/Applications" ]]; then | |
| err "/Applications is not writable. Re-run this installer in a shell where it is, or grant write access." | |
| err " Without the .app bundle, \`cua-driver-rs mcp\` from an IDE terminal will not auto-relaunch into a TCC-correct daemon." | |
| exit 1 | |
| fi | |
| if [[ -e "$APP_DEST" ]]; then | |
| log "removing existing $APP_DEST" | |
| rm -rf "$APP_DEST" | |
| fi | |
| log "installing $APP_DEST" | |
| # `ditto` preserves the bundle's metadata + nested symlinks the way | |
| # Apple's installer would. `cp -R` works but doesn't preserve as | |
| # much, and ditto is always present on macOS. | |
| ditto "$SRC_APP" "$APP_DEST" | |
| APP_BINARY="$APP_DEST/Contents/MacOS/$BINARY_NAME" | |
| if [[ ! -x "$APP_BINARY" ]]; then | |
| err "binary missing at $APP_BINARY (refusing to create broken symlink)" | |
| exit 1 | |
| fi | |
| ln -sf "$APP_BINARY" "$BIN_LINK" | |
| log "symlinked $BIN_LINK -> $APP_BINARY" | |
| else | |
| install -m 0755 "$SRC" "$BIN_LINK" | |
| log "installed $BIN_LINK (version $VERSION)" | |
| fi | |
| if [[ "$OS" == "Darwin" ]]; then | |
| if [[ -z "$SRC_APP" || ! -d "$SRC_APP" ]]; then | |
| err "expected $APP_NAME in macOS tarball but didn't find it" | |
| err "refusing bare-binary fallback on macOS because it disables TCC-correct relaunch behavior" | |
| exit 1 | |
| fi | |
| if [[ ! -w "/Applications" ]]; then | |
| err "/Applications is not writable. Re-run this installer in a shell where it is, or grant write access." | |
| err " Without the .app bundle, \`cua-driver-rs mcp\` from an IDE terminal will not auto-relaunch into a TCC-correct daemon." | |
| exit 1 | |
| fi | |
| if [[ -e "$APP_DEST" ]]; then | |
| log "removing existing $APP_DEST" | |
| rm -rf "$APP_DEST" | |
| fi | |
| log "installing $APP_DEST" | |
| # `ditto` preserves the bundle's metadata + nested symlinks the way | |
| # Apple's installer would. `cp -R` works but doesn't preserve as | |
| # much, and ditto is always present on macOS. | |
| ditto "$SRC_APP" "$APP_DEST" | |
| APP_BINARY="$APP_DEST/Contents/MacOS/$BINARY_NAME" | |
| if [[ ! -x "$APP_BINARY" ]]; then | |
| err "binary missing at $APP_BINARY (refusing to create broken symlink)" | |
| exit 1 | |
| fi | |
| ln -sf "$APP_BINARY" "$BIN_LINK" | |
| log "symlinked $BIN_LINK -> $APP_BINARY" | |
| else | |
| install -m 0755 "$SRC" "$BIN_LINK" | |
| log "installed $BIN_LINK (version $VERSION)" | |
| fi |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@libs/cua-driver-rs/scripts/install.sh` around lines 178 - 203, The Darwin
install branch currently allows falling through to the non-macOS install when
the app bundle is missing; change the logic so on macOS (OS == "Darwin") we
require SRC_APP to be set and a directory and fail fast with a clear error if
it's absent or not a bundle (do this where the if [[ "$OS" == "Darwin" && -n
"$SRC_APP" && -d "$SRC_APP" ]] check is, emitting an err message referencing
SRC_APP/APP_DEST and exit 1), instead of letting the script continue to the else
path that installs a bare binary (BIN_LINK), ensuring we do not silently bypass
the TCC relaunch path.
When the caller passed `cua-driver mcp --socket /custom/path`, the auto-relaunched daemon was still listening on `default_socket_path()`, so the proxy would block waiting for a daemon that never came up on the user-supplied path. Append `--socket <path>` to the `open -n -g -a CuaDriverRs --args serve` argv when the socket differs from the default. Keep the common case (default socket) byte-for-byte identical to Swift's invocation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bit #2) When the daemon returned `!resp.ok`, the proxy was building a `Response::error(...)` (JSON-RPC envelope error). MCP separates two failure modes: - JSON-RPC errors → transport / protocol failures (unreachable socket, decode error, unknown method). - Tool-level errors → tool ran but returned `isError: true` with the error text in `content[]`. JSON-RPC envelope stays success. A non-`ok` daemon response means the tool reached the daemon and the daemon reported the tool returned an error. That's tool-level, so `Response::ok(...)` with `isError: true` is the right shape — same envelope the in-process `mcp_server::server` path returns. Transport failures (UDS gone, decode error, join panic) still surface as JSON-RPC `-32603` errors, since the client really does need to distinguish "tool said no" from "I couldn't reach the tool." Adds two unit tests pinning the serialized shape of the tool-error envelope so a regression to `Response::error` fails fast in CI on every platform. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#3) When SRC_APP is unset or not a directory on macOS, the installer was falling through to the bare-binary `install -m 0755` branch — leaving a working CLI but no /Applications/CuaDriverRs.app, which silently breaks the TCC auto-relaunch path in `cua-driver-rs mcp`. Now exits 1 with a diagnostic before touching BIN_DIR. Linux / WSL path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…abbit #4) The in-tree Info.plist's CFBundleShortVersionString / CFBundleVersion drifted from the release tag on every cut because the workflow just copied the skeleton verbatim. Use `plutil -replace` to stamp ${{ steps.version.outputs.version }} into both keys after copying the skeleton. Echoes the resulting values back for build-log auditing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Rabbit #5) Adds: - representative tools/list response envelope so the reader knows what "identical envelope shape to the in-process path" looks like in practice - tools/call get_screen_size request + response showing the structuredContent + text mirror the proxy passes through - exact stderr text both `main.rs` (unreachable) and `cli.rs` (CUA_DRIVER_RS_MCP_FORCE_PROXY) emit when no daemon is up, plus the exit status Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed 5 fixup commits addressing the CodeRabbit review:
Build + tests on macOS arm64: @coderabbitai please re-review. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain
🏁 Script executed: Length of output: 63302
✅ Addressed cleanly
Minor nit (non-blocking)
// proxy.rs – line ~64
let cached_tools_list = fetch_tools_list_from_daemon(&socket_path)?;
let sp = socket_path.clone();
let cached_tools_list = tokio::task::spawn_blocking(move || fetch_tools_list_from_daemon(&sp)).await??;This is a pure quality-of-life nit — the current code works fine in practice. One Overall this is in great shape. The TCC auto-relaunch flow, proxy error handling, bundle packaging, and installer hardening all look correct. Ready to merge once you're happy with the nit above. [approve] |
…#1532) * feat(telemetry): TelemetryClient with PostHog integration + opt-out Port the Swift `TelemetryClient` from `libs/cua-driver/.../TelemetryClient.swift` to a new `crates/cua-driver/src/telemetry.rs` module. Same PostHog ingest endpoint, same event names, same payload shape — so Rust and Swift telemetry aggregate cleanly on the same dashboards. Public API: - `telemetry::capture(event, properties)` — fire-and-forget HTTP POST, no-op when opt-out is active. - `telemetry::capture_install()` — one-shot install ping guarded by a marker file, **only** path that bypasses the opt-out check (for adoption counting parity with Swift). - `telemetry::is_enabled()` — single env-var check. - `telemetry::event::*` constants — canonical event names mirrored 1:1 from Swift's `TelemetryEvent` enum. Differences from Swift (deliberate, documented in module docs): - Install ID at `~/.cua-driver-rs/.telemetry_id` (Swift uses `~/.cua-driver/`). Independent so opting out of one port doesn't silence the other. - Opt-out env var is `CUA_DRIVER_RS_TELEMETRY_ENABLED=false` (Swift uses `CUA_DRIVER_TELEMETRY_ENABLED`). Same independence rationale. - `$lib = "cua-driver-rs"` so dashboards split Rust vs Swift adoption. - No persisted config flag (YAGNI — env var only). HTTP client: `ureq` v3 with rustls (default features). One transitive dep tree, no system OpenSSL needed. POST runs on `spawn_blocking` when a tokio runtime is live, otherwise a short-lived OS thread — covers both the async MCP server and sync CLI subcommands. 3s timeout, all errors logged via `tracing::debug!` only. Tests cover: env-var bool parsing, opt-out default semantics, CI detection, payload shape (incl. privacy assertion that no usernames / paths / argv leak into the envelope), default-envelope precedence on key collision, install-ID idempotent persistence, ISO-8601 format. No call-site wiring yet — commit 2 adds telemetry emission at the mcp/serve/call CLI entry points. Refs #1528. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): emit telemetry events from mcp/serve/call subcommands Wire `telemetry::capture(...)` at the CLI dispatch boundary so every invocation emits its entry-event (e.g. `cua_driver_mcp`, `cua_driver_api_click`) once before any work starts. Mirrors Swift's `TelemetryClient.shared.record(event: entryEvent)` at the top of `CuaDriverCommand.main()`. - New `cli::telemetry_entry_event(&Command) -> Option<String>` maps each parsed subcommand to its canonical event name. `call <tool>` reports as `cua_driver_api_<tool>` so per-tool adoption is visible without ever recording the args. Implicit-call form (`cua-driver <tool>`) reuses the same path via `Command::Call`. - New hidden `cua-driver telemetry install-event` subcommand (`Command::TelemetryInstallEvent`) — installer-only entry point that fires the one-shot `cua_driver_install` ping via `telemetry::capture_install()`. Bypasses opt-out (only path that does so); guarded by the `.installation_recorded` marker file so repeat invocations are no-ops. Both macOS and non-macOS `main()` paths now call `emit_entry_telemetry` right after `parse_command()` and before dispatch — fire-and-forget, respects the env-var opt-out, never blocks the actual work. Verified locally on macOS: - `cua-driver list-tools` with `CUA_DRIVER_RS_TELEMETRY_ENABLED=false` silently skips the POST. - `cua-driver list-tools` with debug enabled prints `[telemetry] sending event: cua_driver_list_tools`. - `cua-driver telemetry install-event` returns PostHog HTTP 200 on first call; second call is silent (marker file present). - `cua-driver --version` still exits cleanly without firing anything (handled before parse_command). Refs #1528. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(install.sh): emit cua_driver_install event post-install After dropping the binary into place, fire `cua-driver telemetry install-event` once in the background. Bypasses the `CUA_DRIVER_RS_TELEMETRY_ENABLED` opt-out by design so we count adoption even from users who immediately disable telemetry (every subsequent event from the binary respects the opt-out normally). The binary's own `.installation_recorded` marker guards against re-sends, so re-running `install.sh` (e.g. after `cua-driver update`) is a no-op for telemetry. Run in the background with `&` + `disown` so a slow or failed POST can never delay the install — keeps the user-facing "installed" log line snappy. Refs #1528. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(PARITY.md): document telemetry parity + opt-out New "Telemetry (PostHog)" section. Covers: - Endpoint + event names (identical to Swift so dashboards aggregate) - Payload shape table (every key + its source) - Privacy posture: explicit list of what we DO NOT send, backed by a unit-test assertion in build_payload_contains_required_keys. - Opt-out env var (CUA_DRIVER_RS_TELEMETRY_ENABLED) and the single exception (install ping bypasses for adoption counting). - Independence-from-Swift table: separate marker dir + UUID + env var so opting out of one port doesn't silence the other. - HTTP client choice (ureq v3 + rustls) and timeout/error-handling. - Intentional divergences (no persisted config flag, no GUI launch emission, env-var-only CI detection). Refs #1528. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): sanitize tool name before building API telemetry event name Per-tool `call <tool>` events were concatenating the raw user-provided tool string onto `cua_driver_api_`, so path-like or non-ASCII tool names would flow verbatim into PostHog event names (privacy + dashboard pollution). Add `sanitize_tool_name` that lowercases, keeps only `[a-z0-9_]`, caps at 64 chars, and falls back to `"unknown"` when the input strips to empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telemetry): synchronous install POST; only write marker on HTTP success Previously `capture_install()` fired the install event via the async `spawn_capture` path and immediately wrote the `.installation_recorded` marker — so a failed POST (network, PostHog outage, non-2xx) silently dropped the only adoption signal because the marker still prevented retries on the next launch. Switch the install path to a synchronous POST via a new internal `capture_install_with_poster` seam (the seam exists for testability — public callers use `capture_install`). The marker is only written when the POST returns HTTP 2xx; any other outcome (Err, 4xx, 5xx) leaves the marker absent so the next `cua-driver` launch retries. Other telemetry paths still use the async `spawn_capture` fire-and-forget flow — only the install one-shot blocks. Bypass-opt-out semantics are preserved (install path still skips `is_enabled()`). Drops the now-unused 2s sleep in `main.rs` (the comment claimed it was waiting for a spawned thread; the POST is sync now). Adds two unit tests verifying the marker is not written on Err or on non-2xx responses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): update Command::Mcp pattern after #1530 merge --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nstead of SIGABRT-ing (#1724) (#1781) When `cua-driver mcp` falls through to the in-process server (dev binary not inside CuaDriver.app, `--no-daemon-relaunch`, or a launchd parent), the cursor overlay brings up AppKit on the main thread. `+[NSApplication sharedApplication]` registers the process with the Window Server, and that registration **aborts the whole process** (SIGABRT in `_RegisterApplication`) when the process has no graphic-session access — e.g. `mcp` spawned as a stdio child from an SSH session, a LaunchDaemon, or a headless CI runner. The crash happens *before* the existing `mainScreen.is_null()` headless guard, so that guard never gets a chance to run. Reported in #1724. Probe `SessionGetInfo`'s `sessionHasGraphicAccess` bit — which answers "can this session talk to the Window Server?" without touching AppKit — and skip the overlay when it's unset, parking the main thread exactly as the overlay-disabled path already does. The MCP server keeps serving on its background thread, so `mcp` degrades to headless instead of dying. This is the macOS analogue of the Windows Session-0 short-circuit guard. Note: the *primary* repro from #1724 (a bundle-resolved `mcp` spawned by a client) is already handled on current releases by the daemon-proxy re-exec (#1525/#1530), which routes that case away from AppKit entirely. This change hardens the remaining in-process path. - `platform-macos/src/session.rs` — `has_graphic_access()` via SessionGetInfo - `platform-macos/src/cursor/overlay.rs` — gate AppKit init on it - `platform-macos/src/lib.rs` — expose the module Verified: builds + links the Security framework; the probe returns true in a GUI session (attrs 0x6030, graphic bit set) and the smoke test passes. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes #1525.
Ports Swift PR #1479's TCC auto-relaunch / daemon-proxy fix to
cua-driver-rs. Whencua-driver-rs mcpis invoked from an IDEterminal (Claude Code, Cursor, VS Code, Warp), macOS attributes the
spawned process to the parent terminal's TCC responsibility chain —
not to
com.trycua.cuadriverrs— so AX probes silently fail againstthe wrong bundle id. This PR mirrors the Swift fix: detect that
context, spawn a daemon under LaunchServices (which gets the bundle's
TCC attribution), then proxy stdio MCP traffic through the daemon's
Unix socket. MCP clients see an ordinary stdio server; no Python
bridge required.
Packaging decision
The Swift driver ships as
/Applications/CuaDriver.appso itsauto-relaunch uses
open -n -g -a CuaDriver --args serve. The Rustport currently ships as a bare binary at
~/.local/bin/cua-driver,so
open -acan't be the trigger as-is.Chosen approach: ship a minimal
.appbundle for the Rust port too.libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plistwith bundle id
com.trycua.cuadriverrs— distinct from Swift'scom.trycua.driverso the two installs coexist in TCC and a usercan grant Accessibility + Screen Recording to each independently.
install.shinstalls the bundle to/Applications/CuaDriverRs.appvia
dittoand symlinks~/.local/bin/cua-driverintoContents/MacOS/cua-driver(same shape as the Swift driver)..github/workflows/cd-rust-cua-driver.ymlassembles the bundle atCD time and bakes it into every macOS directory tarball. The
bare-binary tarball is preserved for callers who explicitly want
only the binary.
No new signing or notarization setup — the bundle inherits whatever
signature the binary has (currently ad-hoc on the BETA Rust port,
same as before this PR). Production signing is out of scope for
#1525 and would land in a separate change.
Commits
What changed
New modules (Rust):
crates/cua-driver/src/bundle.rs—is_executable_inside_cuadriverrs_app()(resolves
current_exe()through symlinks viacanonicalize,substring-matches
/CuaDriverRs.app/Contents/MacOS/),parent_is_not_launchd()(libc::getppid() != 1),is_env_truthy().crates/cua-driver/src/proxy.rs—run_proxy(): stdio MCP serverwhose
tools/list(cached) andtools/call(viatokio::task::spawn_blocking) forward through the daemon socket.Fails fast at startup if the daemon isn't reachable — matches
Swift
makeProxy'sfetchProxyToolListcontract.crates/cua-driver/src/cli.rs:should_use_daemon_proxy(),launch_daemon_and_wait()(/usr/bin/open -n -g -a CuaDriverRs --args serve),run_mcp_via_daemon_proxy().Modified:
crates/cua-driver/src/cli.rs::Command::Mcpis now a structvariant carrying
no_daemon_relaunchandsocket. New CLI flags:--no-daemon-relaunch,--socket <path>.crates/cua-driver/src/main.rs(macOS): dispatch through theproxy path when
should_use_daemon_proxyis true; else fallthrough to the existing in-process MCP server.
crates/cua-driver/src/serve.rs: daemon'slistmethod nowreturns full
ToolDef(input_schema + annotation hints) so theproxy can build
tools/listin one round-trip. Backwardscompatible (older clients ignore the extra fields).
crates/cua-driver/Cargo.toml:libc = \"0.2\"forgetppid.scripts/install.sh: macOS path downloads the directory tarball,installs
CuaDriverRs.appto/Applications, symlinks bin intothe bundle. Linux/WSL path unchanged.
.github/workflows/cd-rust-cua-driver.yml: assemble the bundleat CD time from
scripts/CuaDriverRs.app/Contents/Info.plist+the universal binary, ship it inside every macOS directory tarball.
PARITY.md: new "CLI subcommand: `mcp` (TCC auto-relaunch /daemon proxy)" entry under the lifecycle/process-model section,
linking Swift's
MCPCommand+BundleHelpers+makeProxytothe new Rust modules. Documents the bundle-id divergence
(intentional), the four escape hatches, the daemon protocol
extension, and a manual smoke-test recipe.
Escape hatches
--no-daemon-relaunchflag (matches Swift)CUA_DRIVER_RS_MCP_NO_RELAUNCH=1env (Rust-specific name; Swiftuses
CUA_DRIVER_MCP_NO_RELAUNCH)--socket <path>flag — override daemon UDS pathCUA_DRIVER_RS_MCP_FORCE_PROXY=1env (Rust-only) — force proxymode without the bundle-context check. Useful for custom bundles
or manual smoke-testing. Skips the
open -astep entirely;caller must supply a daemon on
--socket.Verification
cargo build --releaseclean on macOS (one pre-existing warningin
cli.rs::run_dump_docs, unchanged by this PR).cargo test -p cua-driver— bundle.rs unit tests (4) pass; theone pre-existing failure (
test_type_text_chars_tool) isenvironmental (CGEvent insertion against whatever has focus),
not introduced by this PR.
cua-driver serve --socket /tmp/test.sock &CUA_DRIVER_RS_MCP_FORCE_PROXY=1 cua-driver mcp --socket /tmp/test.socktools/call get_screen_size; expect identical envelope shape
to the in-process path.
exit + "daemon not reachable" diagnostic on stderr.
Test plan
scripts/install.sh, verify/Applications/CuaDriverRs.app/Contents/MacOS/cua-driverexistsand
~/.local/bin/cua-driveris a symlink that resolves into it./Applications/CuaDriverRs.appin System Settings.configure
cua-driver mcpas an MCP server. Confirm a TCC-correctdaemon spawns (visible in Activity Monitor as
CuaDriverRs) andtool calls succeed.
--no-daemon-relaunch; confirm we stay in-process(no daemon spawned, AX calls fail against the wrong bundle if TCC
isn't granted to the IDE terminal).
CUA_DRIVER_RS_MCP_NO_RELAUNCH=1env; same as above.🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Documentation