Skip to content
17 changes: 8 additions & 9 deletions libs/cua-driver/rust/crates/cua-driver/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3209,7 +3209,7 @@ fn run_permissions_status(json: bool) {
let bundle_id = crate::bundle::bundle_id();

// Only a listening daemon can answer for com.trycua.driver. A failed/!ok
// response (e.g. daemon mid-re-exec during the gate's recheck window) is
// response (e.g. daemon still inside its first-launch permission gate) is
// treated the same as "no daemon" → unknown.
let daemon_status: Option<serde_json::Value> = if crate::serve::is_daemon_listening(&socket) {
let req = crate::serve::DaemonRequest {
Expand Down Expand Up @@ -3545,7 +3545,7 @@ fn run_permissions_grant() {
and Screen Recording in System Settings, then this command continues."
);
// Preserve explicit Computer History admission across the
// permission host's daemon launch/re-exec cycle.
// permission host's daemon launch cycle.
if let Err(e) = launch_daemon_and_wait(
&socket,
180,
Expand All @@ -3568,11 +3568,10 @@ fn run_permissions_grant() {
// ScreenCaptureKit access has its own Tahoe consent and is requested
// explicitly below, after we explain the system dialog.
//
// 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.
// The gate uses short-lived probes because `AXIsProcessTrusted` is
// cached per process. While those probes are pending, the stable daemon
// rejects tool calls with a retryable response; tolerate that state
// rather than bailing on the first non-success response.
let req = permission_status_request();
// A dedicated LaunchServices child requests the grants under the
// CuaDriver app identity. No prompt-capable method exists on the
Expand All @@ -3598,8 +3597,8 @@ fn run_permissions_grant() {
break;
}
}
// `send_request` failing (None / !ok) means the daemon is
// mid-restart (re-exec) or briefly down — keep polling.
// `send_request` returning None / !ok means the daemon is still
// gated or briefly unavailable — keep polling.
if std::time::Instant::now() >= poll_deadline {
break;
}
Expand Down
41 changes: 21 additions & 20 deletions libs/cua-driver/rust/crates/cua-driver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,9 @@ mod mcp_runtime_selection_tests {

#[cfg(target_os = "macos")]
fn main() {
if let Some(code) = platform_macos::permissions::gate::run_permission_probe_if_requested() {
std::process::exit(code);
}
// The packaged uninstaller needs a truly offline, pre-telemetry purge
// path while this exact signed executable still exists on disk.
if let Some(code) = history_runtime::run_offline_purge_if_requested() {
Expand Down Expand Up @@ -682,12 +685,14 @@ fn main() {
);
}
}
if !platform_macos::permissions::gate::is_gate_reexec() {
telemetry::capture_start(
telemetry::event::SERVE_START_LEGACY,
telemetry::Transport::Daemon,
);
}
// Fail closed until a fresh helper-process probe completes. This
// also covers a probe launch failure without letting the serving
// process perform and cache its own negative TCC preflight.
serve::set_permission_gate_pending(!gate_opts.opt_out);
telemetry::capture_start(
telemetry::event::SERVE_START_LEGACY,
telemetry::Transport::Daemon,
);
// Long-running daemon — kick off the background update check
// before any blocking work so the banner can land on stderr
// early in the serve lifecycle.
Expand Down Expand Up @@ -724,7 +729,7 @@ fn main() {
// 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
// is ungranted. Fresh helper processes poll TCC 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
Expand All @@ -735,12 +740,10 @@ fn main() {
//
// 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.
// stays on the MAIN thread for its NSPanel; short-lived helper
// processes own prompt and status APIs. The serving process never performs
// a negative TCC preflight, so its socket and accepted connections
// remain stable while helper processes refresh permission state.
let serve_handle = std::thread::Builder::new()
.name("cua-serve".into())
.spawn(move || {
Expand All @@ -756,10 +759,6 @@ fn main() {
// 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.
let gate_result = platform_macos::permissions::run_if_needed_with_observer(
gate_opts,
|progress, context| match progress {
Expand All @@ -778,6 +777,9 @@ fn main() {
}
},
);
if gate_result.is_ok() {
serve::set_permission_gate_pending(false);
}
let gate_context = platform_macos::permissions::gate::telemetry_context();
if gate_context.engaged {
telemetry::capture_permissions_gate_completed(
Expand All @@ -795,9 +797,8 @@ fn main() {
if let Err(e) = gate_result {
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."
"[cua-driver] desktop tool calls remain gated; grant Accessibility and \
Screen Recording permissions, then restart the daemon."
);
}

Expand Down
67 changes: 67 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,9 @@ async fn invoke_daemon_tool(
sdk: &std::sync::Arc<crate::sdk_adapter::SdkAdapter>,
req: DaemonRequest,
) -> DaemonResponse {
if let Some(response) = permission_gate_pending_response(&req) {
return response;
}
let observation_transport = daemon_observation_transport(&req);
let direct_client_kind = req.client_kind;
let raw_name = req.name.as_deref().unwrap_or("").to_owned();
Expand Down Expand Up @@ -634,6 +637,36 @@ fn prepare_embedded_socket_path(socket_path: &str, embedded: bool) -> anyhow::Re
}
}

static PERMISSION_GATE_PENDING: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);

/// Mark whether the macOS first-launch gate is still waiting for TCC grants.
/// The daemon socket and lifecycle diagnostics remain reachable, but tool calls
/// are rejected before execution until fresh child-process probes confirm grants.
pub fn set_permission_gate_pending(pending: bool) {
PERMISSION_GATE_PENDING.store(pending, std::sync::atomic::Ordering::Release);
}

fn permission_gate_pending_response(request: &DaemonRequest) -> Option<DaemonResponse> {
permission_gate_response_for_state(
request,
PERMISSION_GATE_PENDING.load(std::sync::atomic::Ordering::Acquire),
)
}

fn permission_gate_response_for_state(
_request: &DaemonRequest,
pending: bool,
) -> Option<DaemonResponse> {
if !pending {
return None;
}
Some(DaemonResponse::err(
"permissions_pending: macOS Accessibility or Screen Recording permission is still pending; no action started, retry after the permission gate completes",
75,
))
}

fn daemon_metadata_response() -> DaemonResponse {
DaemonResponse::ok(
serde_json::to_value(cua_driver_core::daemon::current_daemon_metadata())
Expand Down Expand Up @@ -2689,6 +2722,40 @@ mod gate_tests {
}
}

#[cfg(test)]
mod permission_gate_routing_tests {
use super::{permission_gate_response_for_state, DaemonRequest};

fn call(name: &str) -> DaemonRequest {
DaemonRequest {
method: "call".into(),
name: Some(name.into()),
args: Some(serde_json::json!({})),
session_id: None,
observation_origin: None,
client_kind: None,
}
}

#[test]
fn pending_gate_rejects_desktop_calls_with_typed_retry() {
let response = permission_gate_response_for_state(&call("list_windows"), true)
.expect("pending gate must reject desktop calls");
assert!(!response.ok);
assert!(response
.error
.as_deref()
.is_some_and(|message| message.starts_with("permissions_pending:")));
assert_eq!(response.exit_code, Some(75));
}

#[test]
fn calls_resume_only_after_the_gate_completes() {
assert!(permission_gate_response_for_state(&call("check_permissions"), true).is_some());
assert!(permission_gate_response_for_state(&call("list_windows"), false).is_none());
}
}

#[cfg(test)]
mod telemetry_routing_tests {
use super::*;
Expand Down
Loading
Loading