From 35172da577e9a518ad30c5b4e0d506e53702bdea Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:09:05 +0200 Subject: [PATCH 01/10] feat(cua-driver-rs): package as macOS .app bundle for TCC attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/cd-rust-cua-driver.yml | 43 ++++++++- .../CuaDriverRs.app/Contents/Info.plist | 45 +++++++++ .../CuaDriverRs.app/Contents/MacOS/.gitkeep | 14 +++ libs/cua-driver-rs/scripts/install.sh | 92 ++++++++++++++++--- 4 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plist create mode 100644 libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/MacOS/.gitkeep diff --git a/.github/workflows/cd-rust-cua-driver.yml b/.github/workflows/cd-rust-cua-driver.yml index a8c0fc8b0a..9e251b4381 100644 --- a/.github/workflows/cd-rust-cua-driver.yml +++ b/.github/workflows/cd-rust-cua-driver.yml @@ -176,6 +176,32 @@ jobs: mkdir -p release/universal lipo -create "$ARM64" "$X86" -output release/universal/cua-driver lipo -info release/universal/cua-driver + - 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 working-directory: libs/cua-driver-rs run: | @@ -187,11 +213,18 @@ jobs: # that download by arch still get the universal slice # (matches the Swift `cd-swift-cua-driver.yml` convention). cp release/universal/cua-driver "release/${STAGE}/" + # Ship the .app bundle in every macOS tarball so install.sh + # can drop it into /Applications/CuaDriverRs.app for TCC + # attribution (issue #1525). Tarball callers that want only + # the bare binary can grab the *-binary.tar.gz below. + cp -R release/CuaDriverRs.app "release/${STAGE}/CuaDriverRs.app" cp ../../LICENSE.md "release/${STAGE}/LICENSE" 2>/dev/null || true (cd release && tar -czf "${STAGE}.tar.gz" "${STAGE}") done # Bare universal binary — single-file tarball matching Swift's - # `cua-driver-${VERSION}-darwin-universal-binary.tar.gz`. + # `cua-driver-${VERSION}-darwin-universal-binary.tar.gz`. NO + # bundle here: callers that fetch the bare tarball deliberately + # skipped the .app workflow. tar -czf "release/cua-driver-rs-${VERSION}-darwin-universal-binary.tar.gz" \ -C release/universal cua-driver ls -lh release/*.tar.gz @@ -311,10 +344,10 @@ jobs: ### Artifacts **macOS (universal — arm64 + x86_64 in one binary, like the Swift cua-driver)** - - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-universal.tar.gz` — directory tarball with LICENSE - - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz` — same universal binary, named for arm64 callers - - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-x86_64.tar.gz` — same universal binary, named for x86_64 callers - - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-universal-binary.tar.gz` — bare universal binary (single file at archive root) + - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-universal.tar.gz` — directory tarball with LICENSE + `CuaDriverRs.app` bundle (install.sh expects this layout) + - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz` — same payload, named for arm64 callers + - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-x86_64.tar.gz` — same payload, named for x86_64 callers + - `cua-driver-rs-${{ steps.version.outputs.version }}-darwin-universal-binary.tar.gz` — bare universal binary (single file at archive root; **no** .app — bypasses the TCC auto-relaunch path) **Linux** - `cua-driver-rs-${{ steps.version.outputs.version }}-linux-x86_64.tar.gz` — directory tarball diff --git a/libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plist b/libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plist new file mode 100644 index 0000000000..552dad462e --- /dev/null +++ b/libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plist @@ -0,0 +1,45 @@ + + + + + + CFBundleIdentifier + com.trycua.cuadriverrs + CFBundleName + Cua Driver RS + CFBundleDisplayName + Cua Driver RS + CFBundleExecutable + cua-driver + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.3 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + + diff --git a/libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/MacOS/.gitkeep b/libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/MacOS/.gitkeep new file mode 100644 index 0000000000..32cec09275 --- /dev/null +++ b/libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/MacOS/.gitkeep @@ -0,0 +1,14 @@ +# Placeholder so the empty MacOS/ directory is tracked in git. +# +# At build / install time the cua-driver release binary (universal +# macOS slice — arm64 + x86_64 lipo'd together) is copied into this +# directory as `cua-driver`. The Info.plist's CFBundleExecutable +# points at that name. +# +# See: +# - libs/cua-driver-rs/scripts/install.sh (downloads the binary +# from GitHub Releases, copies it into Contents/MacOS, installs +# the bundle to /Applications/CuaDriverRs.app, and creates the +# ~/.local/bin/cua-driver symlink pointing into the bundle) +# - .github/workflows/cd-rust-cua-driver.yml (bakes the assembled +# .app into the darwin release tarball at CD time) diff --git a/libs/cua-driver-rs/scripts/install.sh b/libs/cua-driver-rs/scripts/install.sh index 55a050bf38..c3303401e1 100644 --- a/libs/cua-driver-rs/scripts/install.sh +++ b/libs/cua-driver-rs/scripts/install.sh @@ -32,6 +32,16 @@ TAG_PREFIX="cua-driver-rs-v" BIN_DIR="${CUA_DRIVER_RS_BIN_DIR:-$HOME/.local/bin}" NO_MODIFY_PATH="${CUA_DRIVER_RS_NO_MODIFY_PATH:-0}" +# macOS-only: name and install location of the .app bundle that wraps +# the bare binary so the TCC auto-relaunch path in `cua-driver-rs mcp` +# has a stable bundle id (com.trycua.cuadriverrs) to attribute the +# daemon to. See libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/ +# Info.plist and the matching docs on `cua-driver-rs mcp`'s auto- +# relaunch behavior. Distinct from the Swift driver's CuaDriver.app +# (com.trycua.driver) so the two installs coexist on the same machine. +APP_NAME="CuaDriverRs.app" +APP_DEST="/Applications/$APP_NAME" + while [[ $# -gt 0 ]]; do case "$1" in --bin-dir) BIN_DIR="$2"; shift 2 ;; @@ -98,19 +108,21 @@ VERSION="${TAG#${TAG_PREFIX}}" # --- Download bare-binary tarball --------------------------------------- -# Prefer the bare-binary tarball (single `cua-driver` file at the root) — -# the directory tarball would require unpacking and copying, but the bare -# form is curl-pipe-able. +# Tarball selection: # -# macOS: the release workflow publishes one universal binary (arm64 + x86_64 -# lipo'd together) named `darwin-universal-binary`. There are NO per-arch -# bare-binary tarballs for macOS — only the directory tarballs are split -# by arch (darwin-arm64 / darwin-x86_64). The universal binary works on -# both Apple Silicon and Intel, so we always fetch it on macOS. +# macOS — fetch the directory tarball (cua-driver-rs-vN-darwin-universal.tar.gz). +# The directory layout includes `CuaDriverRs.app/` alongside the bare +# binary, which we need to install into /Applications so the TCC +# auto-relaunch path in `cua-driver-rs mcp` can resolve +# `com.trycua.cuadriverrs` via `open -n -g -a CuaDriverRs`. The +# directory variant carries the same universal binary as the +# bare-binary tarball, so users on both Apple Silicon and Intel +# get a working install from one download. # -# Linux: per-arch bare-binary tarballs exist (e.g. linux-x86_64-binary). +# Linux / Windows-via-WSL — keep using the bare-binary tarball. +# No bundle on these platforms, no TCC, no need to unpack a directory. case "$LABEL" in - darwin-*) TARBALL="cua-driver-rs-${VERSION}-darwin-universal-binary.tar.gz" ;; + darwin-*) TARBALL="cua-driver-rs-${VERSION}-darwin-universal.tar.gz" ;; *) TARBALL="cua-driver-rs-${VERSION}-${LABEL}-binary.tar.gz" ;; esac URL="https://github.com/$REPO/releases/download/$TAG/$TARBALL" @@ -124,8 +136,26 @@ fi log "extracting" tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" -# The bare-binary tarball contains exactly `cua-driver` at the root. -SRC="$TMP_DIR/$BINARY_NAME" +# Layout detection: +# macOS dir tarball expands to: +# cua-driver-rs-${VERSION}-darwin-universal/ +# ├── cua-driver (bare universal binary) +# ├── CuaDriverRs.app/ (minimal bundle; copy of the same binary +# │ lives at Contents/MacOS/cua-driver) +# └── LICENSE +# Linux bare-binary tarball expands to: +# cua-driver (single file at the archive root) +case "$LABEL" in + darwin-*) + STAGE="cua-driver-rs-${VERSION}-darwin-universal" + SRC="$TMP_DIR/$STAGE/$BINARY_NAME" + SRC_APP="$TMP_DIR/$STAGE/$APP_NAME" + ;; + *) + SRC="$TMP_DIR/$BINARY_NAME" + SRC_APP="" + ;; +esac if [[ ! -f "$SRC" ]]; then err "expected $BINARY_NAME in tarball but didn't find it" ls -la "$TMP_DIR" @@ -135,8 +165,42 @@ fi # --- Install ------------------------------------------------------------ mkdir -p "$BIN_DIR" -install -m 0755 "$SRC" "$BIN_LINK" -log "installed $BIN_LINK (version $VERSION)" + +# macOS: install the .app to /Applications first, then symlink the +# bin into the bundle so `~/.local/bin/cua-driver` resolves into +# `/Applications/CuaDriverRs.app/Contents/MacOS/cua-driver`. The +# `realpath` walk in `is_executable_inside_cuadriverrs_app()` keys on +# that resolved path to know whether the auto-relaunch heuristic +# should fire. Same shape as the Swift `cua-driver` install path — +# different bundle id (com.trycua.cuadriverrs) so the two coexist. +# +# Linux / WSL: drop the bare binary directly into BIN_DIR (no .app). +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 # Auto-extend PATH for users whose shell doesn't already include BIN_DIR. if [[ "$NO_MODIFY_PATH" != "1" ]] && [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then From fb1e10593e1fef2f8df42f70deb3a7e9448749b3 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:11:30 +0200 Subject: [PATCH 02/10] feat(platform-macos): bundle-context detection helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- libs/cua-driver-rs/Cargo.lock | 15 +- .../crates/cua-driver/Cargo.toml | 6 + .../crates/cua-driver/src/bundle.rs | 149 ++++++++++++++++++ .../crates/cua-driver/src/main.rs | 1 + 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 libs/cua-driver-rs/crates/cua-driver/src/bundle.rs diff --git a/libs/cua-driver-rs/Cargo.lock b/libs/cua-driver-rs/Cargo.lock index 1779741749..895ed28ed1 100644 --- a/libs/cua-driver-rs/Cargo.lock +++ b/libs/cua-driver-rs/Cargo.lock @@ -207,13 +207,14 @@ dependencies = [ [[package]] name = "cua-driver" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", "base64", "cursor-overlay", "image", + "libc", "mcp-server", "platform-linux", "platform-macos", @@ -227,7 +228,7 @@ dependencies = [ [[package]] name = "cursor-overlay" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "image", @@ -325,7 +326,7 @@ checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "focus-monitor-win" -version = "0.1.2" +version = "0.1.3" dependencies = [ "windows", ] @@ -626,7 +627,7 @@ dependencies = [ [[package]] name = "mcp-server" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -900,7 +901,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "platform-linux" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -919,7 +920,7 @@ dependencies = [ [[package]] name = "platform-macos" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -947,7 +948,7 @@ dependencies = [ [[package]] name = "platform-windows" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", diff --git a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml index a7c31f469d..34cc6461cf 100644 --- a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml @@ -21,6 +21,12 @@ cursor-overlay = { path = "../cursor-overlay" } async-trait = "0.1" base64 = { workspace = true } +# Used by crate::bundle::parent_is_not_launchd() for the TCC +# auto-relaunch detection path on Unix (only the macOS heuristic +# actually fires, but the crate compiles on Linux too). +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "macos")'.dependencies] platform-macos = { path = "../platform-macos" } diff --git a/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs b/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs new file mode 100644 index 0000000000..3d9c42a095 --- /dev/null +++ b/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs @@ -0,0 +1,149 @@ +// Functions in this module are wired up by `MCPCommand` in a follow-up +// commit (the daemon-proxy / auto-relaunch path). Suppress the +// dead-code warning that fires until then so `cargo build --release` +// stays warning-clean. +#![allow(dead_code)] + +//! macOS bundle-context detection for the TCC auto-relaunch path. +//! +//! Mirrors `libs/cua-driver/Sources/CuaDriverCLI/BundleHelpers.swift`'s +//! `isExecutableInsideCuaDriverApp()` — the heuristic that decides +//! whether `cua-driver-rs mcp` was spawned from an IDE terminal as a +//! bare CLI symlinked into our .app bundle. When true and the parent +//! isn't launchd, we re-launch the daemon via `open -n -g -a +//! CuaDriverRs --args serve` so it picks up the bundle's TCC grants, +//! then proxy stdio MCP traffic through the daemon's Unix socket. +//! +//! Non-macOS targets compile to no-ops so the cross-platform call +//! sites stay tidy. + +/// Returns `true` when the currently-running binary resolves into an +/// installed `CuaDriverRs.app` bundle (Rust port). The check is the +/// same shape as the Swift driver's `isExecutableInsideCuaDriverApp` +/// (`/CuaDriver.app/Contents/MacOS/`) but keyed on the Rust port's +/// distinct bundle name so the two installs don't collide. +/// +/// `false` for raw `cargo run` / `target/release/cua-driver` dev +/// invocations — there's no installed bundle to relaunch into, so the +/// caller should stay in-process. +/// +/// Implementation: +/// 1. Resolve `std::env::current_exe()` (preferred; absolute path +/// to the running image). +/// 2. Walk symlinks via `std::fs::canonicalize` — the install layout +/// is `~/.local/bin/cua-driver` → `/Applications/CuaDriverRs.app/ +/// Contents/MacOS/cua-driver`, so without the canonicalize step +/// we'd see the bare symlink path and miss the bundle. +/// 3. Substring-match the canonical path for the bundle marker. +#[cfg(target_os = "macos")] +pub fn is_executable_inside_cuadriverrs_app() -> bool { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return false, + }; + let canonical = match std::fs::canonicalize(&exe) { + Ok(p) => p, + Err(_) => return false, + }; + let s = match canonical.to_str() { + Some(s) => s, + None => return false, + }; + s.contains("/CuaDriverRs.app/Contents/MacOS/") +} + +#[cfg(not(target_os = "macos"))] +pub fn is_executable_inside_cuadriverrs_app() -> bool { + false +} + +/// Returns `true` when the parent process is *not* `launchd` (pid 1). +/// Combined with [`is_executable_inside_cuadriverrs_app`], a `true` +/// here means the binary was spawned from a shell / IDE terminal that +/// inherits the wrong TCC responsibility — i.e. the case we want to +/// auto-relaunch from. +/// +/// `ppid == 1` means launchd reparented us (we're already running as +/// the LaunchServices-spawned daemon). In that case we stay +/// in-process: TCC grants are already correct, and relaunching would +/// fork-bomb the daemon back into existence on every `mcp` startup. +/// +/// Mirrors Swift's `if getppid() == 1 { return false }` gate in +/// `MCPCommand.shouldUseDaemonProxy()`. +#[cfg(unix)] +pub fn parent_is_not_launchd() -> bool { + // SAFETY: `libc::getppid` is a thread-safe POSIX getter that + // takes no args and returns the parent pid. No invariants to + // uphold, no UB to risk. + let ppid = unsafe { libc::getppid() }; + ppid != 1 +} + +#[cfg(not(unix))] +pub fn parent_is_not_launchd() -> bool { + // No launchd on non-Unix; the heuristic is macOS-only anyway. + // Returning false keeps the caller in-process on unsupported + // platforms (same effective outcome as the macOS check failing). + false +} + +/// Returns `true` when the env var is one of `1|true|yes|on` +/// (case-insensitive). Anything else, including unset, is falsy. +/// +/// Mirrors Swift's `isEnvTruthy` helper on `MCPCommand`. +pub fn is_env_truthy(name: &str) -> bool { + match std::env::var(name) { + Ok(v) => matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cargo_run_is_not_inside_bundle() { + // The unit-test runner image lives under `target// + // deps/`, never inside a .app bundle. Should always return + // false in CI / local dev, which is exactly the behavior we + // want so `cargo run` callers stay in-process. + assert!(!is_executable_inside_cuadriverrs_app()); + } + + #[test] + fn unset_env_is_falsy() { + // Use a deliberately unlikely name so we don't depend on the + // surrounding shell environment. + std::env::remove_var("CUA_DRIVER_RS_TEST_UNSET_NAME"); + assert!(!is_env_truthy("CUA_DRIVER_RS_TEST_UNSET_NAME")); + } + + #[test] + fn truthy_env_values_recognized() { + let name = "CUA_DRIVER_RS_TEST_TRUTHY"; + for v in ["1", "true", "TRUE", "Yes", "on", " 1 "] { + std::env::set_var(name, v); + assert!(is_env_truthy(name), "expected truthy for {v:?}"); + } + for v in ["0", "false", "no", "off", ""] { + std::env::set_var(name, v); + assert!(!is_env_truthy(name), "expected falsy for {v:?}"); + } + std::env::remove_var(name); + } + + #[test] + #[cfg(unix)] + fn parent_is_not_launchd_in_tests() { + // The cargo test harness is reparented under whatever + // launched it (cargo / IDE / shell), not directly under + // launchd. The helper should report true. + assert!(parent_is_not_launchd()); + } +} diff --git a/libs/cua-driver-rs/crates/cua-driver/src/main.rs b/libs/cua-driver-rs/crates/cua-driver/src/main.rs index c8f8504187..af638d95dc 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/main.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/main.rs @@ -24,6 +24,7 @@ //! //! On all other platforms `#[tokio::main]` is used directly. +mod bundle; mod cli; mod serve; From 4b24a316881cf1d0dfe8912269a759776588d889 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:13:54 +0200 Subject: [PATCH 03/10] feat(mcp): daemon-proxy mode for stdio MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../crates/cua-driver/src/main.rs | 1 + .../crates/cua-driver/src/proxy.rs | 270 ++++++++++++++++++ .../crates/cua-driver/src/serve.rs | 26 +- 3 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 libs/cua-driver-rs/crates/cua-driver/src/proxy.rs diff --git a/libs/cua-driver-rs/crates/cua-driver/src/main.rs b/libs/cua-driver-rs/crates/cua-driver/src/main.rs index af638d95dc..c34efcc4af 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/main.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/main.rs @@ -26,6 +26,7 @@ mod bundle; mod cli; +mod proxy; mod serve; use std::sync::Arc; diff --git a/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs b/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs new file mode 100644 index 0000000000..aa3d9358e1 --- /dev/null +++ b/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs @@ -0,0 +1,270 @@ +// The proxy entry point is wired into `MCPCommand` in the next commit. +// Silence the dead-code warning that fires until then so `cargo +// build --release` stays warning-clean. +#![allow(dead_code)] + +//! Stdio MCP proxy that forwards `tools/list` and `tools/call` through +//! the 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). When `cua-driver-rs mcp` is invoked from +//! an IDE terminal — Claude Code, Cursor, VS Code, Warp — macOS TCC +//! attributes the process to the calling terminal, not to +//! `CuaDriverRs.app`. The MCP client side sees a normal stdio server, +//! but every AX probe silently fails because the binary is running +//! against the wrong bundle id. +//! +//! The fix: detect that context (see `crate::bundle`), ensure a daemon +//! is running under `LaunchServices` (which gives it the right TCC +//! attribution), then proxy every MCP request through the daemon's +//! socket. The MCP client never sees the redirection — same JSON-RPC +//! envelope, same tool semantics. +//! +//! Why this lives in `cua-driver` and not `mcp-server`: +//! `mcp_server::server::run` already speaks JSON-RPC over stdio +//! against an in-process `ToolRegistry`. The proxy speaks the same +//! protocol on the client side but the server side is the daemon's +//! line-delimited JSON UDS protocol, owned by `crate::serve`. +//! Putting the proxy here avoids `mcp-server → cua-driver` reverse +//! coupling. + +use std::sync::Arc; + +use mcp_server::protocol::{initialize_result, Request, Response}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tracing::{debug, error, warn}; + +use crate::serve::{is_daemon_listening, send_request, DaemonRequest}; + +/// Run the MCP stdio proxy. Reads JSON-RPC lines from stdin, forwards +/// the body of each `tools/list` / `tools/call` to the daemon at +/// `socket_path`, and writes the daemon's response back as a proper +/// JSON-RPC envelope. +/// +/// Mirrors `mcp_server::server::run`'s control flow exactly — same +/// EOF + parse-error + notification handling — only the per-method +/// branches change. +/// +/// Fails fast if the daemon isn't reachable, so MCP clients see a +/// clear startup error instead of a "successful" handshake that +/// advertises zero tools and then errors on every call. Matches +/// Swift `makeProxy`'s `fetchProxyToolList` pre-check. +pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { + if !is_daemon_listening(&socket_path) { + anyhow::bail!( + "cua-driver-rs daemon not reachable on {socket_path}. Start it \ + with `open -n -g -a CuaDriverRs --args serve` and retry." + ); + } + + // Cache the tool list once at startup. The daemon's registry is + // static for the lifetime of the daemon, so polling on every + // `tools/list` would waste a round-trip per call. Swift does the + // same caching in `fetchProxyToolList`. + let cached_tools_list = fetch_tools_list_from_daemon(&socket_path)?; + let cached_tools_list = Arc::new(cached_tools_list); + + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + let mut reader = BufReader::new(stdin); + let mut writer = tokio::io::BufWriter::new(stdout); + let mut line = String::new(); + + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + break; // EOF + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + debug!(raw = trimmed, "→ proxy request"); + + let response = match serde_json::from_str::(trimmed) { + Err(e) => { + error!("JSON parse error: {e}"); + Response::parse_error() + } + Ok(req) if req.is_notification() => { + // Notifications get dropped, same as `server::run`. + continue; + } + Ok(req) => { + let id = req.id.clone().unwrap_or(serde_json::Value::Null); + handle_proxy_request(req, id, &socket_path, &cached_tools_list).await + } + }; + + let serialized = serde_json::to_string(&response).unwrap_or_else(|e| { + format!( + r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"serialize error: {e}"}}}}"# + ) + }); + debug!(raw = %serialized, "← proxy response"); + + writer.write_all(serialized.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + + Ok(()) +} + +/// One-shot daemon `list` over the UDS, reshaped into a MCP +/// `tools/list` result. The daemon now returns the full ToolDef +/// (`name`, `description`, `input_schema`, annotation hints) per +/// commit 3's `serve.rs` change. +fn fetch_tools_list_from_daemon(socket_path: &str) -> anyhow::Result { + let req = DaemonRequest { method: "list".into(), name: None, args: None }; + let resp = send_request(socket_path, &req)?; + if !resp.ok { + anyhow::bail!( + "daemon refused tool list on {socket_path}: {}", + resp.error.unwrap_or_else(|| "(no error message)".into()) + ); + } + let result = resp.result.ok_or_else(|| { + anyhow::anyhow!("daemon list response missing `result` field") + })?; + let tools_array = result + .get("tools") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + anyhow::anyhow!("daemon list response missing `tools` array") + })?; + + // Reshape the daemon's `{name, description, input_schema, read_only, + // ...}` envelope into MCP's `{name, description, inputSchema, + // annotations: {...}}` shape. Same translation + // `ToolDef::to_list_entry` does for the in-process path so MCP + // clients see identical tools/list output either way. + let mcp_tools: Vec = tools_array + .iter() + .map(|t| { + let name = t.get("name").cloned().unwrap_or(serde_json::Value::Null); + let description = t + .get("description") + .cloned() + .unwrap_or(serde_json::Value::String(String::new())); + let input_schema = t.get("input_schema").cloned().unwrap_or_else( + || serde_json::json!({"type": "object", "properties": {}}), + ); + let read_only = t.get("read_only").and_then(|v| v.as_bool()).unwrap_or(false); + let destructive = + t.get("destructive").and_then(|v| v.as_bool()).unwrap_or(false); + let idempotent = + t.get("idempotent").and_then(|v| v.as_bool()).unwrap_or(false); + let open_world = + t.get("open_world").and_then(|v| v.as_bool()).unwrap_or(false); + serde_json::json!({ + "name": name, + "description": description, + "inputSchema": input_schema, + "annotations": { + "readOnlyHint": read_only, + "destructiveHint": destructive, + "idempotentHint": idempotent, + "openWorldHint": open_world, + } + }) + }) + .collect(); + + Ok(serde_json::json!({ "tools": mcp_tools })) +} + +/// JSON-RPC method dispatcher for the proxy. Mirrors +/// `mcp_server::server::handle_request`: +/// - `initialize` → static `initialize_result()` (same envelope +/// the in-process path returns; the daemon's +/// identity is hidden from the MCP client). +/// - `tools/list` → return the cached daemon tool list. +/// - `tools/call` → forward to the daemon and reshape the +/// response into MCP's `CallTool.Result`. +/// - other → method-not-found, same as in-process. +async fn handle_proxy_request( + req: Request, + id: serde_json::Value, + socket_path: &str, + cached_tools_list: &Arc, +) -> Response { + match req.method.as_str() { + "initialize" => Response::ok(id, initialize_result()), + + "tools/list" => Response::ok(id, (**cached_tools_list).clone()), + + "tools/call" => match req.tool_call() { + Err(e) => Response::error(id, -32602, format!("Invalid params: {e}")), + Ok(call) => forward_tool_call(id, call.name, call.args, socket_path).await, + }, + + other => { + warn!(method = other, "unknown method"); + Response::method_not_found(id, other) + } + } +} + +/// Forward a single MCP `tools/call` to the daemon as a `call` +/// request, then translate the `DaemonResponse` back into an MCP +/// `CallTool.Result` envelope. Tool-level errors (`isError: true`) +/// round-trip cleanly inside the result. Daemon-level failures +/// (socket gone, unknown tool, decode error) surface as JSON-RPC +/// errors so the MCP client sees the same shape it would for any +/// other server-side failure. +async fn forward_tool_call( + id: serde_json::Value, + name: String, + args: serde_json::Value, + socket_path: &str, +) -> Response { + let req = DaemonRequest { + method: "call".into(), + name: Some(name.clone()), + args: Some(args), + }; + + // The daemon client is sync, so jump to a blocking thread to keep + // the tokio reactor responsive while the AX-heavy call (e.g. + // `screenshot`, `get_window_state`) does its thing on the daemon + // side. + let socket = socket_path.to_owned(); + let blocking = tokio::task::spawn_blocking(move || send_request(&socket, &req)).await; + + let resp = match blocking { + Err(join_err) => { + return Response::error( + id, + -32603, + format!("internal join error forwarding to daemon: {join_err}"), + ); + } + Ok(Err(e)) => { + return Response::error( + id, + -32603, + format!("daemon transport error forwarding `{name}`: {e}"), + ); + } + Ok(Ok(r)) => r, + }; + + 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); + } + + let result = resp.result.unwrap_or_else(|| { + serde_json::json!({ + "content": [], + "isError": false + }) + }); + Response::ok(id, result) +} diff --git a/libs/cua-driver-rs/crates/cua-driver/src/serve.rs b/libs/cua-driver-rs/crates/cua-driver/src/serve.rs index 34a69a8886..e6c0198bfb 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/serve.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/serve.rs @@ -264,10 +264,20 @@ pub async fn run_serve( return; } "list" => { + // Include full ToolDef (input_schema + annotation + // hints) so MCP proxy callers can build a complete + // `tools/list` response from one daemon round-trip. + // Older clients that only read name/description + // still work — the extra fields are ignored. let tools: Vec = reg.iter_defs() .map(|(name, def)| serde_json::json!({ "name": name, - "description": def.description + "description": def.description, + "input_schema": def.input_schema, + "read_only": def.read_only, + "destructive": def.destructive, + "idempotent": def.idempotent, + "open_world": def.open_world, })) .collect(); let resp = DaemonResponse::ok(serde_json::json!({"tools": tools})); @@ -441,8 +451,20 @@ pub async fn run_serve( return; } "list" => { + // Include full ToolDef so MCP proxy callers can + // build a complete `tools/list` response from + // one daemon round-trip. See the unix branch + // above for rationale. let tools: Vec = reg.iter_defs() - .map(|(name, def)| serde_json::json!({"name": name, "description": def.description})) + .map(|(name, def)| serde_json::json!({ + "name": name, + "description": def.description, + "input_schema": def.input_schema, + "read_only": def.read_only, + "destructive": def.destructive, + "idempotent": def.idempotent, + "open_world": def.open_world, + })) .collect(); let resp = DaemonResponse::ok(serde_json::json!({"tools": tools})); let _ = writer.write_all( From 200648ac8843e11994897edd95fda8071fff067d Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:19:13 +0200 Subject: [PATCH 04/10] feat(cli): auto-relaunch mcp from IDE terminal context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` (new CLI flags `--no-daemon-relaunch` and `--socket `). - `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) --- .../crates/cua-driver/src/bundle.rs | 6 - .../crates/cua-driver/src/cli.rs | 148 +++++++++++++++++- .../crates/cua-driver/src/main.rs | 27 +++- .../crates/cua-driver/src/proxy.rs | 5 - 4 files changed, 171 insertions(+), 15 deletions(-) diff --git a/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs b/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs index 3d9c42a095..02cc841bcd 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs @@ -1,9 +1,3 @@ -// Functions in this module are wired up by `MCPCommand` in a follow-up -// commit (the daemon-proxy / auto-relaunch path). Suppress the -// dead-code warning that fires until then so `cargo build --release` -// stays warning-clean. -#![allow(dead_code)] - //! macOS bundle-context detection for the TCC auto-relaunch path. //! //! Mirrors `libs/cua-driver/Sources/CuaDriverCLI/BundleHelpers.swift`'s diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs index 4215bdfff3..d8e10acddc 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs @@ -17,7 +17,19 @@ use mcp_server::{protocol::Content, tool::ToolRegistry}; /// Which CLI command was requested. pub enum Command { - Mcp, + Mcp { + /// Force in-process MCP execution — skip the TCC auto-relaunch + /// path that would spawn a daemon via `open -n -g -a CuaDriverRs + /// --args serve` and proxy stdio MCP requests through its Unix + /// socket. Useful when the calling context already has the right + /// TCC grants (CuaDriverRs.app launched us directly), or when + /// diagnosing in-process failures. Also toggleable via + /// `CUA_DRIVER_RS_MCP_NO_RELAUNCH=1`. + no_daemon_relaunch: bool, + /// Override the daemon Unix socket path used by the proxy + /// fallback. Defaults to `serve::default_socket_path()`. + socket: Option, + }, ListTools, Describe(String), Call { tool: String, json_args: Option, screenshot_out_file: Option }, @@ -65,6 +77,11 @@ pub fn parse_command() -> Command { println!("cua-driver {} — cross-platform computer-use automation driver", env!("CARGO_PKG_VERSION")); println!("Usage: cua-driver [SUBCOMMAND] [OPTIONS]"); println!("Subcommands: mcp, list-tools, describe, call, serve, stop, status, config, recording, update, doctor, diagnose"); + println!(); + println!("mcp options (macOS):"); + println!(" --no-daemon-relaunch Stay in-process; skip auto-launching the CuaDriverRs daemon."); + println!(" Also: CUA_DRIVER_RS_MCP_NO_RELAUNCH=1"); + println!(" --socket Override the daemon UDS path used by the proxy fallback."); std::process::exit(0); } @@ -88,9 +105,14 @@ pub fn parse_command() -> Command { } } + let no_daemon_relaunch = args.iter().any(|a| a == "--no-daemon-relaunch"); + let mut pos = positionals.into_iter(); match pos.next() { - None | Some("mcp") => Command::Mcp, + None | Some("mcp") => Command::Mcp { + no_daemon_relaunch, + socket: socket.clone(), + }, Some("list-tools") => Command::ListTools, Some("mcp-config") => Command::McpConfig { client: mcp_client }, Some("serve") => Command::Serve { socket }, @@ -203,6 +225,128 @@ pub fn run_describe(registry: &ToolRegistry, name: &str) { } } +/// Decide whether `mcp` should auto-launch a daemon and proxy MCP +/// requests through its Unix socket instead of running in-process. +/// +/// Mirrors Swift `MCPCommand.shouldUseDaemonProxy` in spirit: +/// the trigger is "shell-spawned bare binary that resolves into an +/// installed `CuaDriverRs.app` bundle, with a non-launchd parent". +/// When any of those conditions fails — explicit opt-out, dev-mode +/// `cargo run` invocation, already-relaunched-via-launchd — we stay +/// in-process. The proxy path is purely additive. +/// +/// `false` on non-macOS targets: TCC is a macOS-only concern and +/// there's no `open -a` equivalent on Linux / Windows. +#[cfg(target_os = "macos")] +pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool { + use crate::bundle::{is_env_truthy, is_executable_inside_cuadriverrs_app, parent_is_not_launchd}; + if no_daemon_relaunch { + return false; + } + if is_env_truthy("CUA_DRIVER_RS_MCP_NO_RELAUNCH") { + return false; + } + if !is_executable_inside_cuadriverrs_app() { + // Raw `cargo run` / dev binary — no installed bundle to land + // in, so relaunching would fail. Stay in-process. + return false; + } + if !parent_is_not_launchd() { + // ppid == 1 — already running as the LaunchServices-spawned + // daemon. TCC context is already correct. + return false; + } + true +} + +#[cfg(not(target_os = "macos"))] +pub fn should_use_daemon_proxy(_no_daemon_relaunch: bool) -> bool { + false +} + +/// Spawn `/usr/bin/open -n -g -a CuaDriverRs --args serve` to launch +/// the daemon under `LaunchServices` (so it inherits the bundle's +/// TCC attribution), then poll the socket for up to `timeout_secs` +/// seconds. Returns Err with a diagnostic message if `open` failed +/// or the daemon never came up. +/// +/// Mirror of Swift `MCPCommand.launchDaemonViaOpen` + +/// `waitForDaemon`. Split into one Rust function because we don't +/// need the post-launch probe separation Swift has. +#[cfg(target_os = "macos")] +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()) + .stderr(Stdio::null()) + .status(); + + let status = status.map_err(|e| { + anyhow::anyhow!( + "failed to exec `/usr/bin/open`: {e}. Pass --no-daemon-relaunch to bypass." + ) + })?; + + if !status.success() { + anyhow::bail!( + "`open -n -g -a CuaDriverRs --args serve` exited {:?}. \ + Check that `/Applications/CuaDriverRs.app` is installed, or \ + pass --no-daemon-relaunch to bypass.", + status.code() + ); + } + + // Poll the UDS until the daemon answers a probe or we time out. + // 100ms tick matches Swift's `usleep(100_000)`. + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + while Instant::now() < deadline { + if crate::serve::is_daemon_listening(socket_path) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + + anyhow::bail!( + "daemon did not appear on {socket_path} within {timeout_secs}s. If this \ + is the first launch, grant Accessibility + Screen Recording to \ + CuaDriverRs.app in System Settings and retry. Pass --no-daemon-relaunch \ + to stay in-process." + ); +} + +/// Run the MCP proxy path: ensure a daemon is up (spawning via +/// `open` if needed), then `crate::proxy::run_proxy` against its +/// socket. Builds its own tokio runtime — same shape as the other +/// `run_*` helpers in this file that own their event loop. +#[cfg(target_os = "macos")] +pub fn run_mcp_via_daemon_proxy(socket: Option) -> anyhow::Result<()> { + let socket_path = socket.unwrap_or_else(crate::serve::default_socket_path); + + if !crate::serve::is_daemon_listening(&socket_path) { + eprintln!( + "cua-driver-rs: mcp launched without CuaDriverRs.app's TCC grants; \ + auto-launching the daemon via `open -n -g -a CuaDriverRs --args serve` \ + and proxying MCP requests through it. Pass --no-daemon-relaunch to stay in-process." + ); + launch_daemon_and_wait(&socket_path, 10)?; + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + rt.block_on(crate::proxy::run_proxy(socket_path)) +} + /// Print the MCP server config snippet or a client-specific install command. /// /// `--client ` selects one of: claude, codex, cursor, hermes, openclaw, diff --git a/libs/cua-driver-rs/crates/cua-driver/src/main.rs b/libs/cua-driver-rs/crates/cua-driver/src/main.rs index c34efcc4af..24bbd10e14 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/main.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/main.rs @@ -146,7 +146,25 @@ fn main() { cli::run_config_cmd(reg, subcommand.as_deref(), key.as_deref(), value.as_deref(), socket.as_deref()); return; } - cli::Command::Mcp => {} // fall through to MCP server startup below + cli::Command::Mcp { no_daemon_relaunch, socket } => { + // TCC sidestep: if we're a shell-spawned bare binary that + // resolves into /Applications/CuaDriverRs.app, run the + // proxy path instead of the in-process MCP server. The + // proxy ensures a daemon is up under the bundle's TCC + // attribution and forwards stdio MCP through its socket. + // Issue #1525 / mirror of Swift PR #1479. + if cli::should_use_daemon_proxy(no_daemon_relaunch) { + if let Err(e) = cli::run_mcp_via_daemon_proxy(socket) { + eprintln!("cua-driver-rs: {e}"); + std::process::exit(1); + } + return; + } + // Fall through to the in-process MCP server below. The + // `socket` flag is daemon-proxy-only; it has no meaning + // in the in-process path, so we drop it on the floor. + let _ = socket; + } } let cursor_cfg = cursor_overlay::CursorConfig::from_args(); @@ -307,7 +325,12 @@ fn main() -> anyhow::Result<()> { }).join().ok(); return Ok(()); } - cli::Command::Mcp => {} // fall through to MCP server startup below + cli::Command::Mcp { no_daemon_relaunch, socket } => { + // Non-macOS: TCC doesn't exist, no daemon proxy path. The + // flags parse cleanly so cross-platform MCP config + // snippets work, but we ignore them and run in-process. + let _ = (no_daemon_relaunch, socket); + } } // MCP server mode: this needs a full async tokio runtime. diff --git a/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs b/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs index aa3d9358e1..f9596316bb 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs @@ -1,8 +1,3 @@ -// The proxy entry point is wired into `MCPCommand` in the next commit. -// Silence the dead-code warning that fires until then so `cargo -// build --release` stays warning-clean. -#![allow(dead_code)] - //! Stdio MCP proxy that forwards `tools/list` and `tools/call` through //! the running `cua-driver-rs serve` daemon over its Unix socket. //! From abc1794b1690803c433b613242f7ef4adce4456e Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:25:36 +0200 Subject: [PATCH 05/10] docs(parity): document TCC auto-relaunch / daemon-proxy path for mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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) --- libs/cua-driver-rs/PARITY.md | 80 +++++++++++++++++++ .../crates/cua-driver/src/cli.rs | 20 +++++ 2 files changed, 100 insertions(+) diff --git a/libs/cua-driver-rs/PARITY.md b/libs/cua-driver-rs/PARITY.md index 9ff1dc1f6a..ae0850e069 100644 --- a/libs/cua-driver-rs/PARITY.md +++ b/libs/cua-driver-rs/PARITY.md @@ -1045,6 +1045,86 @@ Swift. --- +## CLI subcommand: `mcp` (TCC auto-relaunch / daemon proxy) +- Swift: + - `libs/cua-driver/Sources/CuaDriverCLI/CuaDriverCommand.swift` — + `MCPCommand`, `shouldUseDaemonProxy`, `runViaDaemonProxy`, + `launchDaemonViaOpen`, `waitForDaemon`. + - `libs/cua-driver/Sources/CuaDriverCLI/BundleHelpers.swift` — + `isExecutableInsideCuaDriverApp()`. + - `libs/cua-driver/Sources/CuaDriverServer/CuaDriverMCPServer.swift` — + `makeProxy` (the actor that re-implements `ListTools` / + `CallTool` over the daemon UDS). +- Rust: + - `libs/cua-driver-rs/crates/cua-driver/src/bundle.rs` — + `is_executable_inside_cuadriverrs_app`, + `parent_is_not_launchd`, `is_env_truthy`. + - `libs/cua-driver-rs/crates/cua-driver/src/cli.rs` — + `should_use_daemon_proxy`, `launch_daemon_and_wait`, + `run_mcp_via_daemon_proxy`. + - `libs/cua-driver-rs/crates/cua-driver/src/proxy.rs` — + `run_proxy` (the stdio loop forwarding `tools/list` and + `tools/call` through the daemon socket). + - `libs/cua-driver-rs/scripts/CuaDriverRs.app/Contents/Info.plist` — + the bundle the auto-relaunch path lands in. + - `libs/cua-driver-rs/scripts/install.sh` — drops the bundle to + `/Applications/CuaDriverRs.app` and symlinks the bin into it. +- Status: implemented on macOS (issue #1525); smoke-tested manually + before merge. + +### Why this exists +When `cua-driver-rs mcp` is invoked from an IDE terminal (Claude +Code, Cursor, VS Code, Warp), macOS attributes the spawned process +to the parent terminal's TCC responsibility chain — *not* to +`com.trycua.cuadriverrs`. AX probes against the process silently +fail because the user granted Accessibility to the bundle, not to +the IDE terminal. The Swift driver hit the same pathology and fixed +it in PR #1479; the Rust port hit it on the macOS GA flip path and +fixed it here. See issue #1525 for the full background. + +### Bundle id divergence (intentional) +Swift `CuaDriver.app` → `com.trycua.driver`. +Rust `CuaDriverRs.app` → `com.trycua.cuadriverrs`. +The two bundles coexist on disk and in TCC; a user can grant +Accessibility + Screen Recording to each independently. The Rust +port has its own bundle name + identifier so: + - `open -n -g -a CuaDriverRs --args serve` never accidentally + relaunches into the Swift bundle (and vice versa). + - TCC grants are per-cdhash, so granting one doesn't carry into + the other — users explicitly opt in to each binary. + +### Escape hatches +- `--no-daemon-relaunch` flag — same flag Swift exposes. +- `CUA_DRIVER_RS_MCP_NO_RELAUNCH=1` env var — Rust-specific name + (Swift uses `CUA_DRIVER_MCP_NO_RELAUNCH`). +- `--socket ` flag — override the daemon UDS path used by the + proxy. +- `CUA_DRIVER_RS_MCP_FORCE_PROXY=1` env var (Rust-only) — force + proxy mode without the bundle-context check. Useful when wrapping + the binary in a custom .app, or for manual smoke-testing of the + proxy path against a daemon you've already started by hand. Skips + the `open -a` step entirely; caller must supply a daemon on + `--socket`. + +### Daemon protocol divergence +The daemon's `list` method now returns full `ToolDef` +(`input_schema` + annotation hints), not just `{name, description}`. +The proxy uses this to 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. + +### 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. +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`). + +--- + ## CLI subcommands: `status` + `stop` - Swift: `libs/cua-driver/Sources/CuaDriverCLI/ServeCommand.swift:368-470` - Rust: `libs/cua-driver-rs/crates/cua-driver/src/serve.rs::run_status_cmd, run_stop_cmd` diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs index d8e10acddc..0bfdcd2b2f 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs @@ -246,6 +246,15 @@ pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool { if is_env_truthy("CUA_DRIVER_RS_MCP_NO_RELAUNCH") { return false; } + // Hidden test/escape hook: force proxy mode without requiring the + // executable to live inside CuaDriverRs.app. Used by the + // integration test (which spawns a daemon manually) and by users + // who've wrapped the binary in a custom bundle. Skips the + // launch_daemon_and_wait `open -a` step too — caller is expected + // to have a daemon already running on the chosen socket. + if is_env_truthy("CUA_DRIVER_RS_MCP_FORCE_PROXY") { + return true; + } if !is_executable_inside_cuadriverrs_app() { // Raw `cargo run` / dev binary — no installed bundle to land // in, so relaunching would fail. Stay in-process. @@ -332,6 +341,17 @@ pub fn run_mcp_via_daemon_proxy(socket: Option) -> anyhow::Result<()> { let socket_path = socket.unwrap_or_else(crate::serve::default_socket_path); if !crate::serve::is_daemon_listening(&socket_path) { + // CUA_DRIVER_RS_MCP_FORCE_PROXY callers (test harness, custom + // bundle setups) supply their own daemon — skip the `open -a` + // step, since they don't have an installed CuaDriverRs.app to + // relaunch into. Fail fast if no daemon is up at this point. + if crate::bundle::is_env_truthy("CUA_DRIVER_RS_MCP_FORCE_PROXY") { + anyhow::bail!( + "CUA_DRIVER_RS_MCP_FORCE_PROXY=1 but no daemon listening on \ + {socket_path}. Start one with `cua-driver serve --socket {socket_path}` \ + and retry." + ); + } eprintln!( "cua-driver-rs: mcp launched without CuaDriverRs.app's TCC grants; \ auto-launching the daemon via `open -n -g -a CuaDriverRs --args serve` \ From e520ccabd89d6d52a073a1213c6acb15836c129f Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:42:12 +0200 Subject: [PATCH 06/10] fix(cli): forward --socket to relaunched daemon (CodeRabbit #1) 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 ` 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) --- .../crates/cua-driver/src/cli.rs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs index 0bfdcd2b2f..60b2857dfb 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs @@ -287,6 +287,20 @@ pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::R use std::process::{Command as Cmd, Stdio}; use std::time::{Duration, Instant}; + // Forward `--socket ` to the relaunched daemon when the caller + // passed a non-default socket via `cua-driver mcp --socket /path`. + // Without this the daemon would listen on `default_socket_path()`, + // and the proxy would block forever waiting for a daemon on the + // user-supplied path that never comes up. Only added when the path + // actually differs from the default, so the common case keeps the + // shorter `open` argv (and matches Swift's invocation byte-for-byte). + let pass_socket = socket_path != crate::serve::default_socket_path(); + let mut open_args: Vec<&str> = vec!["-n", "-g", "-a", "CuaDriverRs", "--args", "serve"]; + if pass_socket { + open_args.push("--socket"); + open_args.push(socket_path); + } + 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 @@ -294,7 +308,7 @@ pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::R // 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"]) + .args(&open_args) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); @@ -307,9 +321,10 @@ pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::R if !status.success() { anyhow::bail!( - "`open -n -g -a CuaDriverRs --args serve` exited {:?}. \ + "`open -n -g -a CuaDriverRs --args serve{}` exited {:?}. \ Check that `/Applications/CuaDriverRs.app` is installed, or \ pass --no-daemon-relaunch to bypass.", + if pass_socket { format!(" --socket {socket_path}") } else { String::new() }, status.code() ); } @@ -352,9 +367,14 @@ pub fn run_mcp_via_daemon_proxy(socket: Option) -> anyhow::Result<()> { and retry." ); } + let socket_suffix = if socket_path != crate::serve::default_socket_path() { + format!(" --socket {socket_path}") + } else { + String::new() + }; eprintln!( "cua-driver-rs: mcp launched without CuaDriverRs.app's TCC grants; \ - auto-launching the daemon via `open -n -g -a CuaDriverRs --args serve` \ + auto-launching the daemon via `open -n -g -a CuaDriverRs --args serve{socket_suffix}` \ and proxying MCP requests through it. Pass --no-daemon-relaunch to stay in-process." ); launch_daemon_and_wait(&socket_path, 10)?; From b82d8c4a5405672e9651119c228a189b8ae4e262 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:44:17 +0200 Subject: [PATCH 07/10] fix(proxy): wrap daemon tool failures as MCP CallTool.Result (CodeRabbit #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../crates/cua-driver/src/proxy.rs | 118 ++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs b/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs index f9596316bb..09be4ee832 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/proxy.rs @@ -204,11 +204,17 @@ async fn handle_proxy_request( /// Forward a single MCP `tools/call` to the daemon as a `call` /// request, then translate the `DaemonResponse` back into an MCP -/// `CallTool.Result` envelope. Tool-level errors (`isError: true`) -/// round-trip cleanly inside the result. Daemon-level failures -/// (socket gone, unknown tool, decode error) surface as JSON-RPC -/// errors so the MCP client sees the same shape it would for any -/// other server-side failure. +/// `CallTool.Result` envelope. +/// +/// Error mapping: +/// - Tool ran and reported failure (`!resp.ok`, including unknown +/// tool / bad params) → JSON-RPC success with `result.isError = +/// true`. Mirrors the in-process `mcp_server::server` path so +/// MCP clients see identical envelopes either way. +/// - Transport failure (UDS unreachable, decode error, blocking +/// task panic) → JSON-RPC error (`-32603`), because the MCP +/// client really does need to distinguish "tool said no" from +/// "I couldn't reach the tool at all." async fn forward_tool_call( id: serde_json::Value, name: String, @@ -247,12 +253,30 @@ async fn forward_tool_call( }; if !resp.ok { + // MCP separates two failure modes: + // - JSON-RPC errors → `Response::error(...)`, used for + // transport / protocol failures (unknown method, bad + // params shape, server crash). + // - Tool-level errors → `Response::ok(...)` carrying a + // `CallTool.Result` with `isError: true` and the error + // message in `content[]`. The tool ran, returned a + // well-formed result that says "I failed." + // + // A non-`ok` daemon response means the tool call reached the + // daemon and the daemon decided the tool returned an error + // (or rejected the call). That's tool-level, not transport- + // level, so the in-process `mcp_server::server` would surface + // it as `Response::ok` with `isError: true`. Mirror that + // shape here so MCP clients see identical envelopes either + // way — CodeRabbit #2. 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); + let exit_code = resp.exit_code.unwrap_or(1); + let result = serde_json::json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true, + "structuredContent": { "exit_code": exit_code } + }); + return Response::ok(id, result); } let result = resp.result.unwrap_or_else(|| { @@ -263,3 +287,77 @@ async fn forward_tool_call( }); Response::ok(id, result) } + +// ── Tests ──────────────────────────────────────────────────────────────────── +// +// Unit-test only the JSON shape of the proxy's tool-error envelope. +// The full proxy loop is exercised by the macOS integration test +// (CUA_DRIVER_RS_MCP_FORCE_PROXY harness in PARITY.md §"Manual smoke +// test"); these tests just lock in the per-branch reshape so a +// regression to `Response::error` for tool-level failures would fail +// fast in CI on every platform. + +#[cfg(test)] +mod tests { + use super::*; + use crate::serve::DaemonResponse; + + /// Reconstruct the `!resp.ok` branch in isolation so we can assert + /// on the serialized shape without spinning up a real daemon / + /// tokio runtime. Keep this in sync with `forward_tool_call`. + fn build_tool_error_response( + id: serde_json::Value, + resp: DaemonResponse, + ) -> Response { + let msg = resp.error.unwrap_or_else(|| "daemon reported failure".into()); + let exit_code = resp.exit_code.unwrap_or(1); + let result = serde_json::json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true, + "structuredContent": { "exit_code": exit_code } + }); + Response::ok(id, result) + } + + #[test] + fn daemon_tool_failure_wraps_as_jsonrpc_success_with_iserror_true() { + let daemon_resp = DaemonResponse { + ok: false, + result: None, + error: Some("missing required field `pid`".into()), + exit_code: Some(64), + }; + let resp = build_tool_error_response(serde_json::json!(7), daemon_resp); + let value = serde_json::to_value(&resp).expect("serialize"); + + // Top-level JSON-RPC envelope: success (`result`), not error. + assert_eq!(value["jsonrpc"], "2.0"); + assert_eq!(value["id"], serde_json::json!(7)); + assert!(value.get("error").is_none(), + "tool-level failure must NOT surface as JSON-RPC error: got {value}"); + assert!(value.get("result").is_some(), + "tool-level failure must carry a `result` payload: got {value}"); + + // CallTool.Result inside `result`: isError + content text. + let result = &value["result"]; + assert_eq!(result["isError"], serde_json::json!(true)); + assert_eq!(result["content"][0]["type"], "text"); + assert_eq!(result["content"][0]["text"], "missing required field `pid`"); + assert_eq!(result["structuredContent"]["exit_code"], 64); + } + + #[test] + fn daemon_failure_with_no_error_message_uses_fallback_text() { + let daemon_resp = DaemonResponse { + ok: false, + result: None, + error: None, + exit_code: None, + }; + let resp = build_tool_error_response(serde_json::json!("abc"), daemon_resp); + let value = serde_json::to_value(&resp).expect("serialize"); + assert_eq!(value["result"]["isError"], serde_json::json!(true)); + assert_eq!(value["result"]["content"][0]["text"], "daemon reported failure"); + assert_eq!(value["result"]["structuredContent"]["exit_code"], 1); + } +} From 980b89e5899547800bae2a37dbc25ec32a6268e0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:44:35 +0200 Subject: [PATCH 08/10] fix(install): fail fast on Darwin if .app bundle is missing (CodeRabbit #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- libs/cua-driver-rs/scripts/install.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/libs/cua-driver-rs/scripts/install.sh b/libs/cua-driver-rs/scripts/install.sh index c3303401e1..60877fec60 100644 --- a/libs/cua-driver-rs/scripts/install.sh +++ b/libs/cua-driver-rs/scripts/install.sh @@ -175,6 +175,18 @@ mkdir -p "$BIN_DIR" # different bundle id (com.trycua.cuadriverrs) so the two coexist. # # Linux / WSL: drop the bare binary directly into BIN_DIR (no .app). +# +# Fail fast on Darwin if the .app is missing — falling through to the +# bare-binary install would silently produce a CLI that can never +# auto-relaunch into a TCC-correct daemon. CodeRabbit #3. +if [[ "$OS" == "Darwin" ]]; then + if [[ -z "${SRC_APP:-}" || ! -d "$SRC_APP" ]]; then + err "macOS install requires the .app bundle (SRC_APP not found at ${SRC_APP:-})" + err " This usually means the downloaded tarball is missing CuaDriverRs.app — re-run the installer or" + err " pin a known-good release via CUA_DRIVER_RS_VERSION=." + exit 1 + fi +fi 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." From 5a88ecadc684e14b12b7a396598fa5fa30116bc3 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:45:05 +0200 Subject: [PATCH 09/10] fix(ci): stamp release version into CuaDriverRs.app Info.plist (CodeRabbit #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) --- .github/workflows/cd-rust-cua-driver.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/cd-rust-cua-driver.yml b/.github/workflows/cd-rust-cua-driver.yml index 9e251b4381..87784e0148 100644 --- a/.github/workflows/cd-rust-cua-driver.yml +++ b/.github/workflows/cd-rust-cua-driver.yml @@ -193,15 +193,26 @@ jobs: # BETA release; production signing will land in a separate # change that wires up the notarization script the way the # Swift driver does. + VERSION="${{ steps.version.outputs.version }}" 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 + # Stamp the release version into Info.plist so the bundle + # version tracks the tag instead of whatever was last + # checked in. Without this the in-tree Info.plist's + # CFBundleShortVersionString drifts from the release tag on + # every cut. CodeRabbit #4. + plutil -replace CFBundleShortVersionString -string "$VERSION" \ + release/CuaDriverRs.app/Contents/Info.plist + plutil -replace CFBundleVersion -string "$VERSION" \ + release/CuaDriverRs.app/Contents/Info.plist # 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 + plutil -p release/CuaDriverRs.app/Contents/Info.plist | grep -E 'CFBundle(Short)?Version' - name: Package working-directory: libs/cua-driver-rs run: | From 41892dcefb39b17b3cd5509a9d0a24d1856a8794 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 16 May 2026 23:46:17 +0200 Subject: [PATCH 10/10] docs(parity): show expected smoke-test outputs for daemon proxy (CodeRabbit #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) --- libs/cua-driver-rs/PARITY.md | 79 +++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/libs/cua-driver-rs/PARITY.md b/libs/cua-driver-rs/PARITY.md index ae0850e069..4430d4966b 100644 --- a/libs/cua-driver-rs/PARITY.md +++ b/libs/cua-driver-rs/PARITY.md @@ -1118,10 +1118,85 @@ older clients that only read name/description still work. 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. Concretely: + + `tools/list` response (the daemon caches and returns it once at + proxy startup — same shape as the in-process server's `tools/list`): + + ```json + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { "name": "browser_eval", "description": "…", "inputSchema": {…}, "annotations": {…} }, + { "name": "check_permissions", "description": "…", "inputSchema": {…}, "annotations": {…} }, + { "name": "click", "description": "…", "inputSchema": {…}, "annotations": {…} }, + { "name": "double_click", "…": "…" }, + { "name": "drag", "…": "…" }, + { "name": "get_accessibility_tree", "…": "…" }, + { "name": "get_config", "…": "…" }, + { "name": "get_cursor_position", "…": "…" }, + { "name": "get_recording_state", "…": "…" }, + { "name": "get_screen_size", "…": "…" }, + { "name": "get_window_state", "…": "…" }, + { "name": "hotkey", "…": "…" }, + { "name": "launch_app", "…": "…" }, + { "name": "list_apps", "…": "…" }, + { "name": "list_windows", "…": "…" }, + { "name": "page", "…": "…" }, + { "name": "press_key", "…": "…" }, + { "name": "replay_trajectory", "…": "…" }, + { "name": "right_click", "…": "…" }, + { "name": "screenshot", "…": "…" }, + { "name": "scroll", "…": "…" }, + { "name": "set_config", "…": "…" }, + { "name": "set_recording", "…": "…" }, + { "name": "set_value", "…": "…" }, + { "name": "type_text", "…": "…" }, + { "name": "zoom", "…": "…" } + // …plus the agent_cursor.* family when overlay is enabled. + // For an exact snapshot run: `cua-driver list-tools` + ] + } + } + ``` + + `tools/call get_screen_size` request + response: + + ```json + // → stdin + {"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"get_screen_size","arguments":{}}} + + // ← stdout + {"jsonrpc":"2.0","id":2,"result":{ + "content":[{"type":"text","text":"{\"width\":1920,\"height\":1080}"}], + "structuredContent":{"width":1920,"height":1080}, + "isError":false + }} + ``` + + The `result` envelope is identical to the in-process path — + structuredContent + text mirror, no proxy-specific wrapping. + 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`). + (the fail-fast contract that matches Swift `makeProxy`). Exact + stderr text emitted by `main.rs`'s proxy-error branch (wrapping + `proxy::run_proxy`'s pre-check): + + ``` + cua-driver-rs: cua-driver-rs daemon not reachable on /tmp/test.sock. Start it with `open -n -g -a CuaDriverRs --args serve` and retry. + ``` + + Process exits with status `1` before reading any MCP request on + stdin. With `CUA_DRIVER_RS_MCP_FORCE_PROXY=1` set, `cli.rs`'s + `run_mcp_via_daemon_proxy` emits the more specific: + + ``` + cua-driver-rs: CUA_DRIVER_RS_MCP_FORCE_PROXY=1 but no daemon listening on /tmp/test.sock. Start one with `cua-driver serve --socket /tmp/test.sock` and retry. + ``` ---