From 870b9f592f499a4a0d15126ac4b03a647fb2e0f0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 30 May 2026 13:47:39 -0700 Subject: [PATCH] fix(cua-driver-rs)(macos): bind serve socket before the permissions gate (#1761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On first launch with `com.trycua.driver` ungranted, the macOS `Serve` arm ran the blocking permissions gate BEFORE `run_serve_cmd` bound the Unix socket. While the gate sat in `wait_for_grants` (prompting + re-exec looping), the socket never appeared, so a daemon launched via `open -n -g -a CuaDriver --args serve` was unreachable for minutes — `permissions grant` and MCP clients couldn't even get a "pending" answer. Reorder the macOS serve arm: run serve on a background thread first (it binds the socket — a Unix socket + tokio accept loop has no main-thread requirement) and run the gate on the main thread (its prompt APIs and the NSPanel must stay on main). The daemon is reachable within ~1s while the gate works toward the grant. On grant the gate's `reexec_self()` restarts the whole daemon cleanly; `run_serve` already unlinks the stale socket file before re-binding, so the rebind is fast. Because serve now runs concurrently, each re-exec restarts the daemon and flaps the socket. Raise `EXEC_AFTER_POLLS` 5 -> 25 (~25s between re-execs) to trade grant-detection latency for socket stability, and log "restarting daemon" before each re-exec. The re-exec stays — it's the only way to pick up an Accessibility grant (`AXIsProcessTrusted` is cached per process). `permissions grant` now polls `check_permissions` via the daemon every 2s up to 180s (tolerating transient failures during a re-exec restart) until both grants flip true, instead of a single query that returns "pending" now that the socket appears before the grant. Refs #1761 Co-Authored-By: Claude Opus 4.8 --- .../rust/crates/cua-driver/src/cli.rs | 48 +++++++--- .../rust/crates/cua-driver/src/main.rs | 88 ++++++++++++------- .../platform-macos/src/permissions/gate.rs | 16 +++- 3 files changed, 105 insertions(+), 47 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index fab6a07101..c33a949964 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -1559,19 +1559,45 @@ fn run_permissions_grant() { process::exit(1); } } + // Since #1761 the daemon binds its socket IMMEDIATELY — before the + // permissions gate completes — so the first `check_permissions` + // query returns "pending" while the grant is still missing. Poll + // the daemon until both grants flip true (success) or we time out. + // + // The gate re-execs the daemon (~every 25s) to pick up an + // Accessibility grant — `AXIsProcessTrusted` is cached per process + // and only a fresh process image sees a later grant. During each + // restart the socket briefly disappears, so tolerate transient + // connection failures rather than bailing on the first one. let req = crate::serve::DaemonRequest { method: "call".into(), name: Some("check_permissions".into()), args: Some(serde_json::json!({ "prompt": false })), }; - let structured = crate::serve::send_request(&socket, &req) - .ok() - .filter(|r| r.ok) - .and_then(|r| r.result) - .and_then(|res| res.get("structuredContent").cloned()) - .unwrap_or_else(|| serde_json::json!({})); - let ax = structured.get("accessibility").and_then(|v| v.as_bool()).unwrap_or(false); - let sr = structured.get("screen_recording").and_then(|v| v.as_bool()).unwrap_or(false); + let poll_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(180); + let mut ax = false; + let mut sr = false; + loop { + if let Some(structured) = crate::serve::send_request(&socket, &req) + .ok() + .filter(|r| r.ok) + .and_then(|r| r.result) + .and_then(|res| res.get("structuredContent").cloned()) + { + ax = structured.get("accessibility").and_then(|v| v.as_bool()).unwrap_or(false); + sr = structured.get("screen_recording").and_then(|v| v.as_bool()).unwrap_or(false); + if ax && sr { + break; + } + } + // `send_request` failing (None / !ok) means the daemon is + // mid-restart (re-exec) or briefly down — keep polling. + if std::time::Instant::now() >= poll_deadline { + break; + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } if ax && sr { println!("\n✅ CuaDriver has Accessibility + Screen Recording. You're set."); } else { @@ -1581,10 +1607,10 @@ fn run_permissions_grant() { (true, false) => "Screen Recording", (true, true) => unreachable!(), }; - println!("\n⚠️ CuaDriver is running but still missing: {missing}."); + println!("\n⚠️ Timed out waiting on: {missing}."); println!( - "Approve it for \u{201c}Cua Driver\u{201d} in System Settings \u{2192} Privacy & \ - Security, then re-run `cua-driver permissions status`." + "Approve CuaDriver for \u{201c}Cua Driver\u{201d} in System Settings \u{2192} \ + Privacy & Security, then re-run `cua-driver permissions grant`." ); } } diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index a541368df1..3c6b9d8dfe 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -229,24 +229,9 @@ fn main() { // before any blocking work so the banner can land on stderr // early in the serve lifecycle. version_check::maybe_announce_update(); - // First-launch permissions gate (Swift PermissionsGate parity). - // Runs on every `serve` start; no-op when both grants are - // already active. Honors --no-permissions-gate and - // CUA_DRIVER_RS_PERMISSIONS_GATE=0 for CI / headless. - // - // Failures (e.g. deadline elapsed without grants) are logged - // and the daemon continues to start — individual tool calls - // will then fail with the underlying TCC error, mirroring - // Swift's "user closed the panel" fallback. let gate_opts = platform_macos::permissions::GateOpts::from_env_and_flag( no_permissions_gate, ); - if let Err(e) = platform_macos::permissions::run_if_needed(gate_opts) { - eprintln!("[cua-driver] permissions gate: {e}"); - eprintln!("[cua-driver] continuing serve startup anyway — \ - expect tool calls touching AX or Screen Recording \ - to fail until you grant the missing TCC permissions."); - } cua_driver_core::recording::set_screenshot_fn(|window_id, pid| { if let Some(wid) = window_id { platform_macos::capture::screenshot_window_bytes(wid as u32).ok() @@ -278,27 +263,66 @@ fn main() { reg.init_self_weak(); let sp = socket.unwrap_or_else(serve::default_socket_path); let pid_path = serve::default_pid_file_path(); + + // Bind the Unix socket FIRST, on a background thread, BEFORE + // running the (blocking) permissions gate (#1761). + // + // The gate's `wait_for_grants` blocks while `com.trycua.driver` + // is ungranted — it prompts and re-exec-loops until the user + // grants or the deadline elapses. If serve ran after the gate, + // the daemon's socket wouldn't appear for minutes on first + // launch, so `permissions grant` / MCP clients launched via + // `open -n -g -a CuaDriver --args serve` (the correct-TCC- + // attribution path) couldn't reach the daemon to even report + // "pending". Binding the socket first makes the daemon + // reachable within ~1s while the gate works toward the grant. + // + // A Unix socket + tokio accept loop has no main-thread + // requirement, so serve runs on a background thread. The gate + // stays on the MAIN thread: its prompt APIs + // (`request_accessibility` / `request_screen_recording`) and + // the NSPanel must run on main. On grant, the gate's + // `reexec_self()` execvp's the whole daemon — the socket + // re-binds fast on restart (run_serve unlinks the stale socket + // file first) and stabilizes once the grant sticks. + let serve_handle = std::thread::Builder::new() + .name("cua-serve".into()) + .spawn(move || { + serve::run_serve_cmd(reg, &sp, Some(&pid_path)); + std::process::exit(0); + }) + .expect("spawn serve thread"); + + // Socket is binding/bound now → daemon reachable while we gate. + // + // First-launch permissions gate (Swift PermissionsGate parity). + // Runs on every `serve` start; no-op when both grants are + // already active. Honors --no-permissions-gate and + // CUA_DRIVER_RS_PERMISSIONS_GATE=0 for CI / headless. + // + // Failures (e.g. deadline elapsed without grants) are logged + // and the daemon continues to serve — individual tool calls + // will then fail with the underlying TCC error, mirroring + // Swift's "user closed the panel" fallback. + if let Err(e) = platform_macos::permissions::run_if_needed(gate_opts) { + eprintln!("[cua-driver] permissions gate: {e}"); + eprintln!("[cua-driver] continuing — tool calls touching AX or \ + Screen Recording fail until you grant the missing TCC \ + permissions."); + } + + // Keep the main thread alive for the daemon. + // // PiP needs the AppKit main run loop to process the - // dispatch_async_f calls that push frames into NSImageView. - // When --experimental-pip is on, move the serve loop onto - // a background thread and park the main thread in - // NSApplication.run() so the dispatched blocks actually - // execute. Without this, frames queue forever and the - // window stays blank. The non-PiP path keeps the original - // run-on-main semantics so we don't change behaviour for - // existing users. + // dispatch_async_f calls that push frames into NSImageView; + // park main in NSApplication.run() when --experimental-pip is + // on. Otherwise just join the serve thread so the process + // stays up as long as the daemon does. if pip_cfg.enabled { - std::thread::Builder::new() - .name("cua-serve".into()) - .spawn(move || { - serve::run_serve_cmd(reg, &sp, Some(&pid_path)); - std::process::exit(0); - }) - .expect("spawn serve thread"); platform_macos::pip::run_appkit_main_loop(); - return; + } else { + let _ = serve_handle.join(); } - serve::run_serve_cmd(reg, &sp, Some(&pid_path)); return; } cli::Command::Stop { socket } => { diff --git a/libs/cua-driver/rust/crates/platform-macos/src/permissions/gate.rs b/libs/cua-driver/rust/crates/platform-macos/src/permissions/gate.rs index a96f69bee1..7460b1d3e0 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/permissions/gate.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/permissions/gate.rs @@ -384,14 +384,22 @@ pub fn wait_for_grants(opts: &GateOpts) -> Result<()> { // On macOS, when polling has gone several iterations without // any change, re-exec the binary to invalidate the per-process // TCC cache. See the function-level doc for the rationale. - // Threshold of 5 polls (~5s) is tuned to feel snappy without - // exec-spinning when the user is mid-grant. + // + // Since #1761 the serve loop runs concurrently with this gate on + // a background thread, so every `reexec_self()` restarts the whole + // daemon — including re-binding the Unix socket. A tight re-exec + // cadence would therefore make the socket flap (clients see brief + // connection failures on each restart). We trade grant-detection + // latency (fine for the grant flow — the user is clicking through + // System Settings on a human timescale) for socket stability: + // ~25 polls (~25s) between re-execs instead of ~5s. #[cfg(target_os = "macos")] { - const EXEC_AFTER_POLLS: u32 = 5; + const EXEC_AFTER_POLLS: u32 = 25; if polls_without_change >= EXEC_AFTER_POLLS { println!( - "[cua-driver] rechecking permissions (still missing: {})", + "[cua-driver] rechecking permissions — restarting daemon \ + (still missing: {})", fmt_missing(&last_missing) ); let _ = std::io::stdout().flush();