Skip to content

Commit 826fbef

Browse files
committed
feat(cua-driver): embedded mode — inherit host TCC grants, never prompt
Adds explicit embedded mode (CUA_DRIVER_EMBEDDED=1 / --embedded [--host-bundle-id <id>]) so a host app (e.g. OpenClaw) can embed the driver as a direct child and have macOS attribute Accessibility + Screen Recording to the host it already trusted — one grant, one Settings entry, no com.trycua.driver prompts. In embedded mode the driver: - skips the responsibility-disclaim re-exec (stays in the host's chain) - skips the daemon auto-relaunch via the CuaDriver.app bundle - opts out of the startup permissions gate and never calls request_accessibility / request_screen_recording (check_permissions ignores prompt=true) - reports check_permissions source.attribution = "host" with the advisory host bundle id, keeping the live SCShareableContent probe Fail-closed property preserved: the caller-controlled env var only ever downgrades attribution (host, never driver-daemon); daemon attribution stays keyed on the non-spoofable bundle-path signal. Standalone behavior is unchanged when the flag is off. Ships Skills/cua-driver/EMBEDDING.md (integration guide + troubleshooting) and examples/embedded-host-macos/ (signed AppKit reference host + one-grant demo script).
1 parent 73fe822 commit 826fbef

12 files changed

Lines changed: 839 additions & 38 deletions

File tree

docs/content/docs/reference/cua-driver/cli-reference.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,15 @@ On macOS, shell-spawned MCP processes can auto-launch and proxy through a CuaDri
7171
| Name | Type | Default | Description |
7272
| ---- | ---- | ------- | ----------- |
7373
| `--socket` | String || Override the daemon socket or named-pipe path used by the proxy fallback. |
74+
| `--host-bundle-id` | String || Advisory host bundle id label echoed in check_permissions output (embedded mode). |
7475

7576
**Flags:**
7677

7778
| Name | Description |
7879
| ---- | ----------- |
7980
| `--no-daemon-relaunch` | Stay in-process instead of proxying through a daemon. |
8081
| `--claude-code-computer-use-compat` | Expose the Claude Code computer-use compatibility screenshot surface. |
82+
| `--embedded` | Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1. |
8183

8284
### `cua-driver serve`
8385

@@ -91,12 +93,14 @@ The daemon owns per-process state such as element-index caches, recording state,
9193
| ---- | ---- | ------- | ----------- |
9294
| `--socket` | String || Override the daemon socket or named-pipe path. |
9395
| `--pid-file` | String || Override the pid-file path on Unix targets. |
96+
| `--host-bundle-id` | String || Advisory host bundle id label echoed in check_permissions output (embedded mode). |
9497

9598
**Flags:**
9699

97100
| Name | Description |
98101
| ---- | ----------- |
99102
| `--no-permissions-gate` | Skip the macOS first-launch permissions gate. |
103+
| `--embedded` | Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1. |
100104

101105
### `cua-driver stop`
102106

libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md

Lines changed: 375 additions & 0 deletions
Large diffs are not rendered by default.

libs/cua-driver/rust/Skills/cua-driver/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ platform: no focus steal, no cursor warp.
1919
- `LINUX.md` — Linux carve-out (X11 background input via AT-SPI +
2020
XSendEvent, recording, Wayland opt-in/preview). Read this when
2121
driving on Linux.
22+
- `EMBEDDING.md` — embedding cua-driver inside another macOS app
23+
(agent harness) so the driver inherits the host's Accessibility +
24+
Screen Recording grants with zero extra prompts. Read this when
25+
integrating the driver into your own app rather than running it
26+
standalone.
2227

2328
## What the skill covers
2429

libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@
1212
1313
pub const RESPONSIBILITY_DISCLAIMED_ENV: &str = "CUA_DRIVER_RS_RESPONSIBILITY_DISCLAIMED";
1414

15+
/// Embedded mode (`CUA_DRIVER_EMBEDDED=1` / `--embedded`): the driver runs
16+
/// as a direct child of a host app and stays in its TCC responsibility
17+
/// chain — no disclaim re-exec, no daemon relaunch, no permission prompts.
18+
/// See `Skills/cua-driver/EMBEDDING.md`.
19+
///
20+
/// Caller-controlled, which is safe only because embedded mode strictly
21+
/// REMOVES capability claims; it must never feed into the `driver-daemon`
22+
/// attribution decision (`permission_source` in platform-macos).
23+
pub const EMBEDDED_ENV: &str = "CUA_DRIVER_EMBEDDED";
24+
25+
/// Advisory label for the embedding host's bundle id, echoed in
26+
/// `check_permissions` output. NOT a trust signal — trust comes from the
27+
/// OS responsibility chain.
28+
pub const HOST_BUNDLE_ID_ENV: &str = "CUA_DRIVER_HOST_BUNDLE_ID";
29+
30+
/// Only the exact value `1` counts — fail-safe for anything else.
31+
pub fn embedded_mode() -> bool {
32+
std::env::var_os(EMBEDDED_ENV).is_some_and(|v| v == "1")
33+
}
34+
1535
pub mod capture_mode;
1636
pub mod cdp;
1737
pub mod element_cache;

libs/cua-driver/rust/crates/cua-driver/src/cli.rs

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ const VALUE_FLAGS: &[&str] = &[
140140
"--cursor-icon", "--cursor-id", "--cursor-palette", "--cursor-shape",
141141
"--glide-ms", "--dwell-ms", "--idle-hide-ms",
142142
"--screenshot-out-file", "--client", "--socket", "--pid-file", "--type",
143+
"--host-bundle-id",
143144
// Experimental PiP preview — value flag for the optional geometry
144145
// override (--experimental-pip itself is a bare flag and doesn't
145146
// need to be listed here).
@@ -201,6 +202,10 @@ pub fn parse_command() -> Command {
201202
println!("mcp options (macOS):");
202203
println!(" --no-daemon-relaunch Stay in-process; skip auto-launching the CuaDriver daemon.");
203204
println!(" Also: CUA_DRIVER_RS_MCP_NO_RELAUNCH=1");
205+
println!(" --embedded Run embedded inside a host app (also: CUA_DRIVER_EMBEDDED=1).");
206+
println!(" Inherits the host's TCC grants; never prompts or relaunches.");
207+
println!(" See Skills/cua-driver/EMBEDDING.md.");
208+
println!(" --host-bundle-id <id> Advisory host bundle id label for check_permissions output.");
204209
println!(" --socket <path> Override the daemon UDS path used by the proxy fallback.");
205210
println!(" --claude-code-computer-use-compat");
206211
println!(" Select the Claude Code computer-use compat surface.");
@@ -254,6 +259,16 @@ pub fn parse_command() -> Command {
254259
let mcp_client = flag_value(&args, "--client");
255260
let socket = flag_value(&args, "--socket");
256261

262+
// `--embedded` / `--host-bundle-id` export to the environment rather
263+
// than threading through `Command`: all consumers read
264+
// `cua_driver_core::embedded_mode()` and children inherit the mode.
265+
if args.iter().any(|a| a == "--embedded") {
266+
std::env::set_var(cua_driver_core::EMBEDDED_ENV, "1");
267+
}
268+
if let Some(id) = flag_value(&args, "--host-bundle-id") {
269+
std::env::set_var(cua_driver_core::HOST_BUNDLE_ID_ENV, id);
270+
}
271+
257272
// Strip cursor-overlay flags (and their values) to expose the subcommand.
258273
let mut positionals: Vec<&str> = Vec::new();
259274
let mut i = 0;
@@ -540,6 +555,12 @@ pub fn run_describe(registry: &ToolRegistry, name: &str) {
540555
#[cfg(target_os = "macos")]
541556
pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool {
542557
use crate::bundle::{is_env_truthy, is_executable_inside_cuadriver_app, parent_is_not_launchd};
558+
// Embedded mode stays in-process: relaunching via `open -a CuaDriver`
559+
// would leave the host's TCC responsibility chain and could prompt
560+
// for com.trycua.driver.
561+
if cua_driver_core::embedded_mode() {
562+
return false;
563+
}
543564
if no_daemon_relaunch {
544565
return false;
545566
}
@@ -590,6 +611,10 @@ pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool {
590611
#[cfg(not(target_os = "macos"))]
591612
pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool {
592613
use crate::bundle::is_env_truthy;
614+
// Same rule as macOS: an embedded driver answers in-process.
615+
if cua_driver_core::embedded_mode() {
616+
return false;
617+
}
593618
if no_daemon_relaunch {
594619
return false;
595620
}
@@ -846,14 +871,18 @@ pub fn build_manifest() -> serde_json::Value {
846871
"args": [
847872
{ "name": "--no-daemon-relaunch", "type": "flag", "description": "Skip the bundle-based TCC auto-relaunch and stay in-process." },
848873
{ "name": "--socket", "type": "string", "description": "Override the daemon proxy UDS path." },
849-
{ "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Select the Claude Code computer-use compat tool surface." }
874+
{ "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Select the Claude Code computer-use compat tool surface." },
875+
{ "name": "--embedded", "type": "flag", "description": "Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1." },
876+
{ "name": "--host-bundle-id", "type": "string", "description": "Advisory host bundle id label echoed in check_permissions output." }
850877
] },
851878
{ "name": "serve",
852879
"description": "Run the long-lived daemon — backs the proxy/auto-relaunch path on macOS and the autostart Session 1+ daemon on Windows.",
853880
"args": [
854881
{ "name": "--socket", "type": "string", "description": "Override the listen socket path." },
855882
{ "name": "--no-permissions-gate", "type": "flag", "description": "Skip the macOS TCC first-launch gate." },
856-
{ "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Forwarded by the MCP proxy when the client asked for the compat surface." }
883+
{ "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Forwarded by the MCP proxy when the client asked for the compat surface." },
884+
{ "name": "--embedded", "type": "flag", "description": "Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1." },
885+
{ "name": "--host-bundle-id", "type": "string", "description": "Advisory host bundle id label echoed in check_permissions output." }
857886
] },
858887
{ "name": "stop",
859888
"description": "Stop a running daemon by sending it a shutdown request.",
@@ -2019,11 +2048,13 @@ fn cli_docs_json() -> serde_json::Value {
20192048
"discussion": "On macOS, shell-spawned MCP processes can auto-launch and proxy through a CuaDriver.app daemon so TCC grants attach to the bundle. On Windows and Linux, MCP proxies through an already-running daemon when one is listening.",
20202049
"arguments": no_args,
20212050
"options": [
2022-
{"name":"socket","short_name":null,"help":"Override the daemon socket or named-pipe path used by the proxy fallback.","type":"String","default_value":null,"is_optional":true}
2051+
{"name":"socket","short_name":null,"help":"Override the daemon socket or named-pipe path used by the proxy fallback.","type":"String","default_value":null,"is_optional":true},
2052+
{"name":"host-bundle-id","short_name":null,"help":"Advisory host bundle id label echoed in check_permissions output (embedded mode).","type":"String","default_value":null,"is_optional":true}
20232053
],
20242054
"flags": [
20252055
{"name":"no-daemon-relaunch","short_name":null,"help":"Stay in-process instead of proxying through a daemon.","default_value":false},
2026-
{"name":"claude-code-computer-use-compat","short_name":null,"help":"Expose the Claude Code computer-use compatibility screenshot surface.","default_value":false}
2056+
{"name":"claude-code-computer-use-compat","short_name":null,"help":"Expose the Claude Code computer-use compatibility screenshot surface.","default_value":false},
2057+
{"name":"embedded","short_name":null,"help":"Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1.","default_value":false}
20272058
],
20282059
"subcommands": no_subcommands
20292060
},
@@ -2067,10 +2098,12 @@ fn cli_docs_json() -> serde_json::Value {
20672098
"arguments": no_args,
20682099
"options": [
20692100
{"name":"socket","short_name":null,"help":"Override the daemon socket or named-pipe path.","type":"String","default_value":null,"is_optional":true},
2070-
{"name":"pid-file","short_name":null,"help":"Override the pid-file path on Unix targets.","type":"String","default_value":null,"is_optional":true}
2101+
{"name":"pid-file","short_name":null,"help":"Override the pid-file path on Unix targets.","type":"String","default_value":null,"is_optional":true},
2102+
{"name":"host-bundle-id","short_name":null,"help":"Advisory host bundle id label echoed in check_permissions output (embedded mode).","type":"String","default_value":null,"is_optional":true}
20712103
],
20722104
"flags": [
2073-
{"name":"no-permissions-gate","short_name":null,"help":"Skip the macOS first-launch permissions gate.","default_value":false}
2105+
{"name":"no-permissions-gate","short_name":null,"help":"Skip the macOS first-launch permissions gate.","default_value":false},
2106+
{"name":"embedded","short_name":null,"help":"Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1.","default_value":false}
20742107
],
20752108
"subcommands": no_subcommands
20762109
},

libs/cua-driver/rust/crates/cua-driver/src/responsibility.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,22 @@ pub fn already_disclaimed() -> bool {
1717
std::env::var_os(cua_driver_core::RESPONSIBILITY_DISCLAIMED_ENV).is_some()
1818
}
1919

20+
/// Split out from [`reexec_disclaimed_if_needed`] so the decision is
21+
/// testable without spawning. Embedded mode must skip the disclaim:
22+
/// disclaiming would make the driver its own responsible process and
23+
/// break TCC inheritance from the host.
24+
#[cfg(target_os = "macos")]
25+
fn should_skip_disclaim(embedded: bool, already_disclaimed: bool, inside_bundle: bool) -> bool {
26+
embedded || already_disclaimed || inside_bundle
27+
}
28+
2029
#[cfg(target_os = "macos")]
2130
pub fn reexec_disclaimed_if_needed() {
22-
if already_disclaimed() {
23-
return;
24-
}
25-
if crate::bundle::is_executable_inside_cuadriver_app() {
31+
if should_skip_disclaim(
32+
cua_driver_core::embedded_mode(),
33+
already_disclaimed(),
34+
crate::bundle::is_executable_inside_cuadriver_app(),
35+
) {
2636
return;
2737
}
2838

@@ -164,6 +174,15 @@ pub fn reexec_disclaimed_if_needed() {}
164174
mod tests {
165175
use super::*;
166176

177+
#[test]
178+
fn embedded_mode_skips_disclaim_reexec() {
179+
assert!(should_skip_disclaim(true, false, false));
180+
// A bare standalone binary must still disclaim.
181+
assert!(!should_skip_disclaim(false, false, false));
182+
assert!(should_skip_disclaim(false, true, false));
183+
assert!(should_skip_disclaim(false, false, true));
184+
}
185+
167186
#[test]
168187
fn already_disclaimed_reflects_env_var() {
169188
let name = cua_driver_core::RESPONSIBILITY_DISCLAIMED_ENV;

libs/cua-driver/rust/crates/platform-macos/src/permissions/gate.rs

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,11 @@ impl Default for GateOpts {
165165
impl GateOpts {
166166
/// Construct from the standard env-var
167167
/// (`CUA_DRIVER_RS_PERMISSIONS_GATE` set to `0` / `false` / `no` /
168-
/// `off`, case-insensitive, disables the gate) and an explicit
169-
/// `--no-permissions-gate` flag. Either signal is sufficient to opt
170-
/// out.
168+
/// `off`, case-insensitive, disables the gate), an explicit
169+
/// `--no-permissions-gate` flag, and embedded mode
170+
/// (`CUA_DRIVER_EMBEDDED=1`). Any signal is sufficient to opt out.
171+
/// Embedded mode opts out because the host app owns the grant flow;
172+
/// the driver must never raise its own prompts.
171173
pub fn from_env_and_flag(no_gate_flag: bool) -> Self {
172174
// Match the standard list of "off" sentinels case-insensitively so
173175
// CI scripts can use any of `0`, `false`, `no`, `off`, `FALSE`,
@@ -181,7 +183,7 @@ impl GateOpts {
181183
})
182184
.unwrap_or(false);
183185
Self {
184-
opt_out: no_gate_flag || env_disabled,
186+
opt_out: no_gate_flag || env_disabled || cua_driver_core::embedded_mode(),
185187
..Self::default()
186188
}
187189
}
@@ -595,22 +597,11 @@ fn fmt_missing(missing: &[MissingPermission]) -> String {
595597
#[cfg(test)]
596598
mod tests {
597599
use super::*;
598-
use std::sync::{Mutex, OnceLock};
599-
600-
/// Serializes every test that mutates `CUA_DRIVER_RS_PERMISSIONS_GATE`.
601-
/// `cargo test` runs tests in parallel by default and `std::env::set_var`
602-
/// / `remove_var` touch a process-global table — without this lock the
603-
/// env-var tests race and produce flaky failures.
604-
static TEST_ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
605600

601+
/// Crate-wide env-var test lock — `from_env_and_flag` reads
602+
/// `CUA_DRIVER_EMBEDDED`, which the `check_permissions` tests mutate.
606603
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
607-
// `lock()` can only fail if a previous holder panicked. Recover the
608-
// guard and keep going — the env var will be re-set/cleared by this
609-
// test anyway, so a poisoned mutex carries no stale invariant.
610-
TEST_ENV_MUTEX
611-
.get_or_init(|| Mutex::new(()))
612-
.lock()
613-
.unwrap_or_else(|e| e.into_inner())
604+
crate::permissions::test_env_lock()
614605
}
615606

616607
#[test]
@@ -652,10 +643,23 @@ mod tests {
652643
fn neither_flag_nor_env_does_not_opt_out() {
653644
let _guard = env_lock();
654645
std::env::remove_var("CUA_DRIVER_RS_PERMISSIONS_GATE");
646+
std::env::remove_var(cua_driver_core::EMBEDDED_ENV);
655647
let opts = GateOpts::from_env_and_flag(false);
656648
assert!(!opts.opt_out);
657649
}
658650

651+
#[test]
652+
fn embedded_mode_opts_out_of_gate() {
653+
let _guard = env_lock();
654+
std::env::remove_var("CUA_DRIVER_RS_PERMISSIONS_GATE");
655+
std::env::set_var(cua_driver_core::EMBEDDED_ENV, "1");
656+
assert!(GateOpts::from_env_and_flag(false).opt_out);
657+
// Only the exact value "1" enables embedded mode.
658+
std::env::set_var(cua_driver_core::EMBEDDED_ENV, "true");
659+
assert!(!GateOpts::from_env_and_flag(false).opt_out);
660+
std::env::remove_var(cua_driver_core::EMBEDDED_ENV);
661+
}
662+
659663
#[test]
660664
fn env_var_truthy_values_do_not_opt_out() {
661665
let _guard = env_lock();

libs/cua-driver/rust/crates/platform-macos/src/permissions/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,15 @@ pub mod panel;
2424

2525
pub use status::{PermissionsStatus, current_status};
2626
pub use gate::{GateOpts, MissingPermission, run_if_needed};
27+
28+
/// Crate-wide lock serializing tests that mutate process-global env vars.
29+
/// Per-module locks are not enough: `gate` and `check_permissions` tests
30+
/// share `CUA_DRIVER_EMBEDDED`.
31+
#[cfg(test)]
32+
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
33+
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
34+
// Poison carries no stale invariant (tests restore the vars they touch).
35+
LOCK.get_or_init(|| std::sync::Mutex::new(()))
36+
.lock()
37+
.unwrap_or_else(|e| e.into_inner())
38+
}

0 commit comments

Comments
 (0)