Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/content/docs/reference/cua-driver/embedding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ cua-driver mcp --embedded --socket /tmp/yourapp-cua.sock \
--host-bundle-id com.yourco.yourapp
```

You can pass `--embedded --host-bundle-id com.yourco.yourapp` to `serve` instead of the environment variables. Only the exact value `CUA_DRIVER_EMBEDDED=1` enables environment-based embedded mode. The host bundle id is an advisory label echoed in `check_permissions`; trust still comes from macOS's responsibility chain.
You can pass `--embedded --host-bundle-id com.yourco.yourapp` to `serve` instead of the environment variables. Only the exact value `CUA_DRIVER_EMBEDDED=1` enables environment-based embedded mode. The host bundle id declares the expected host: `health_report` compares it with the daemon's parent application, while trust still comes from macOS's responsibility chain.

## Node and Electron hosts

Expand Down Expand Up @@ -150,6 +150,8 @@ If macOS grants are added after the daemon has started, restart the daemon so TC

`--embedded` does not transfer a GUI app's grants to the driver; it only keeps the daemon inside its spawner's macOS responsibility chain. If a separate gateway or Node process spawns the daemon, the daemon inherits the gateway's identity, not the app's. Spawn `cua-driver serve --embedded` from the app process.

Call `health_report` with `{"include":["bundle_identity"]}` after connecting. The check passes only when macOS can resolve the direct parent as an application and, when configured, its observed bundle identifier matches `CUA_DRIVER_HOST_BUNDLE_ID`.

Normal OpenClaw gateway and Hermes YAML MCP configurations remain standalone integrations; do not set embedded mode merely because one of those agents is the client. A signed Node or Electron desktop host may use `@trycua/cua-driver/embedded`, but only its permission-owning app process may start the daemon. The MCP client can then launch the proxy described by `connection.mcp`.

```text
Expand Down
33 changes: 26 additions & 7 deletions libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,12 @@ gateway / node daemon YourApp.app
--socket <private-path>
```

Note `check_permissions` cannot detect this: `source.attribution` reports
`host` whenever `CUA_DRIVER_EMBEDDED=1` is set, even if a gateway spawned
the driver. The symptoms are grant booleans that track the _gateway's_ TCC
state and prompts/Settings entries naming the gateway process; see
Troubleshooting below.
`health_report(include=["bundle_identity"])` detects this wiring error by
resolving the daemon's direct parent through macOS. It fails when the parent
is not an identifiable app or when its observed bundle identifier differs
from `CUA_DRIVER_HOST_BUNDLE_ID`. `check_permissions.source.attribution`
still describes the configured mode; use the two reports together when
diagnosing embedding.

## Reading `check_permissions` from the host

Expand Down Expand Up @@ -440,12 +441,29 @@ _ = readMessage()
send(["jsonrpc": "2.0", "method": "notifications/initialized"])
log("embedded cua-driver daemon + proxy started (\(driverPath)) — no driver prompt should have appeared")

// 4. check_permissions must report attribution "host" and never prompt.
// 4. health_report must observe this actual parent app, and
// check_permissions must report host attribution and matching TCC results.
let health = call("health_report", ["include": ["bundle_identity"]])
let healthStructured = health["structuredContent"] as? [String: Any] ?? [:]
let healthChecks = healthStructured["checks"] as? [[String: Any]] ?? []
let identity = healthChecks.first ?? [:]
let identityData = identity["data"] as? [String: Any] ?? [:]
let hostBundleId = Bundle.main.bundleIdentifier ?? ""
let identityOk = identity["status"] as? String == "pass" &&
identityData["bundle_identifier"] as? String == hostBundleId &&
identityData["identity_source"] as? String == "parent_application" &&
identityData["responsible_process_id"] as? Int == ProcessInfo.processInfo.processIdentifier
log("health_report — bundle_identity: \(identity["status"] ?? "?"), " +
"observed host: \(identityData["bundle_identifier"] ?? "?") (want: \(hostBundleId))")

let perms = call("check_permissions")
let structured = perms["structuredContent"] as? [String: Any] ?? [:]
let source = structured["source"] as? [String: Any] ?? [:]
let attribution = source["attribution"] as? String ?? "?"
let permissionsMatchHost = structured["accessibility"] as? Bool == ax &&
structured["screen_recording"] as? Bool == sr
log("check_permissions — attribution: \(attribution) (want: host), " +
"TCC matches host: \(permissionsMatchHost), " +
"capturable: \(structured["screen_recording_capturable"] ?? "?")")

// 5. Background AX read + window screenshot — proves both grants
Expand Down Expand Up @@ -476,7 +494,8 @@ let cursorOk = (cursor1["isError"] as? Bool) != true &&
(cursor2["isError"] as? Bool) != true
log("move_cursor — \(cursorOk ? "ok" : "FAILED")")

let pass = attribution == "host" && !images.isEmpty && hasTree && cursorOk
let pass = identityOk && attribution == "host" && permissionsMatchHost &&
!images.isEmpty && hasTree && cursorOk
log(pass ? "DEMO COMPLETE: PASS" : "DEMO COMPLETE: FAIL")
driver.terminate()
daemon.terminate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,14 @@ pub struct CheckData {
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub configured_bundle_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub executable_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub responsible_process_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub architecture: Option<String>,
Expand All @@ -112,7 +118,10 @@ impl CheckData {
/// serialized as `{}`.
pub fn is_empty(&self) -> bool {
self.bundle_identifier.is_none()
&& self.configured_bundle_identifier.is_none()
&& self.executable_path.is_none()
&& self.identity_source.is_none()
&& self.responsible_process_id.is_none()
&& self.os_version.is_none()
&& self.architecture.is_none()
&& self.display_count.is_none()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,28 @@ pub(crate) fn check_bundle_identity() -> CheckEntry {
.and_then(|p| p.to_str().map(str::to_owned))
.unwrap_or_default();

let is_correct = bid.as_deref() == Some(CANONICAL_BUNDLE_ID);
if cua_driver_core::embedded_mode() {
let responsible_process_id = unsafe { libc::getppid() } as u32;
let observed_host_bundle_id =
crate::apps::bundle_id_for_pid(responsible_process_id as libc::pid_t);
let configured_host_bundle_id = std::env::var(cua_driver_core::HOST_BUNDLE_ID_ENV)
.ok()
.filter(|id| !id.trim().is_empty());
return check_embedded_bundle_identity(
observed_host_bundle_id,
configured_host_bundle_id,
responsible_process_id,
exe,
);
}

let data = CheckData {
bundle_identifier: bid.clone(),
executable_path: if exe.is_empty() { None } else { Some(exe) },
identity_source: Some("current_process".to_owned()),
..Default::default()
};
if is_correct {
if bid.as_deref() == Some(CANONICAL_BUNDLE_ID) {
return CheckEntry::pass(
NAME_BUNDLE_IDENTITY,
format!("Bundle is {CANONICAL_BUNDLE_ID}."),
Expand All @@ -140,8 +155,56 @@ pub(crate) fn check_bundle_identity() -> CheckEntry {
),
),
};

CheckEntry::fail(NAME_BUNDLE_IDENTITY, message, hint).with_data(data)
}
fn check_embedded_bundle_identity(
observed_host_bundle_id: Option<String>,
configured_host_bundle_id: Option<String>,
responsible_process_id: u32,
executable_path: String,
) -> CheckEntry {
let data = CheckData {
bundle_identifier: observed_host_bundle_id.clone(),
configured_bundle_identifier: configured_host_bundle_id.clone(),
executable_path: if executable_path.is_empty() {
None
} else {
Some(executable_path)
},
identity_source: Some("parent_application".to_owned()),
responsible_process_id: Some(responsible_process_id),
..Default::default()
};

let Some(observed) = observed_host_bundle_id else {
return CheckEntry::fail(
NAME_BUNDLE_IDENTITY,
"Embedded mode is set, but the parent process is not an identifiable macOS application.",
"Spawn `cua-driver serve --embedded` directly from the signed host app process. Do not launch it through a shell, gateway, `open`, or NSWorkspace.",
)
.with_data(data);
};

if let Some(configured) = configured_host_bundle_id {
if configured != observed {
return CheckEntry::fail(
NAME_BUNDLE_IDENTITY,
format!(
"Observed host bundle is {observed}, but the configured host bundle is {configured}."
),
"Spawn the embedded daemon directly from the configured host application, or correct CUA_DRIVER_HOST_BUNDLE_ID.",
)
.with_data(data);
}
}

CheckEntry::pass(
NAME_BUNDLE_IDENTITY,
format!("Embedded daemon is directly hosted by {observed}."),
)
.with_data(data)
}

fn check_tcc_accessibility() -> CheckEntry {
// Reuse the shared probe — do not duplicate AXIsProcessTrusted.
Expand Down Expand Up @@ -282,6 +345,26 @@ mod tests {
use cua_driver_core::tool::Tool;
use std::sync::Arc;

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::permissions::test_env_lock()
}

fn swap_env(var: &str, value: Option<&str>) -> Option<std::ffi::OsString> {
let original = std::env::var_os(var);
match value {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
original
}

fn restore_env(var: &str, original: Option<std::ffi::OsString>) {
match original {
Some(value) => std::env::set_var(var, value),
None => std::env::remove_var(var),
}
}

#[test]
fn binary_version_always_passes() {
let entry = check_binary_version();
Expand Down Expand Up @@ -316,6 +399,9 @@ mod tests {

#[test]
fn bundle_identity_in_test_host_fails_with_full_shape() {
let _guard = env_lock();
let embedded = swap_env(cua_driver_core::EMBEDDED_ENV, None);

// The Rust test binary runs outside CuaDriver.app, so its
// bundle id is either absent or not com.trycua.driver. Either
// way the documented fail-mode shape applies: message + hint
Expand All @@ -342,6 +428,58 @@ mod tests {
data.executable_path.is_some(),
"executable_path must be set so consumers can identify the wrong binary"
);

restore_env(cua_driver_core::EMBEDDED_ENV, embedded);
}

#[test]
fn embedded_bundle_identity_passes_for_observed_host() {
let entry = check_embedded_bundle_identity(
Some("com.example.host".to_owned()),
Some("com.example.host".to_owned()),
1234,
"/usr/local/bin/cua-driver".to_owned(),
);
assert_eq!(entry.status, CheckStatus::Pass);
assert!(entry.message.contains("com.example.host"));
assert!(entry.hint.is_none());
let data = entry.data.expect("diagnostic data expected");
assert_eq!(data.bundle_identifier.as_deref(), Some("com.example.host"));
assert_eq!(
data.configured_bundle_identifier.as_deref(),
Some("com.example.host")
);
assert_eq!(data.identity_source.as_deref(), Some("parent_application"));
assert_eq!(data.responsible_process_id, Some(1234));
}

#[test]
fn embedded_bundle_identity_fails_without_observable_host() {
let entry = check_embedded_bundle_identity(
None,
Some("com.example.host".to_owned()),
1234,
"/usr/local/bin/cua-driver".to_owned(),
);
assert_eq!(entry.status, CheckStatus::Fail);
assert!(entry
.message
.contains("not an identifiable macOS application"));
assert!(entry.hint.is_some());
}

#[test]
fn embedded_bundle_identity_fails_on_configured_host_mismatch() {
let entry = check_embedded_bundle_identity(
Some("com.example.gateway".to_owned()),
Some("com.example.host".to_owned()),
1234,
"/usr/local/bin/cua-driver".to_owned(),
);
assert_eq!(entry.status, CheckStatus::Fail);
assert!(entry.message.contains("com.example.gateway"));
assert!(entry.message.contains("com.example.host"));
assert!(entry.hint.is_some());
}

// End-to-end through the dispatcher — checks every macOS canonical
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,29 @@ _ = readMessage()
send(["jsonrpc": "2.0", "method": "notifications/initialized"])
log("embedded cua-driver daemon + proxy started (\(driverPath)) — no driver prompt should have appeared")

// 4. check_permissions must report attribution "host" and never prompt.
// 4. health_report must observe this actual parent app, and
// check_permissions must report host attribution and matching TCC results.
let health = call("health_report", ["include": ["bundle_identity"]])
let healthStructured = health["structuredContent"] as? [String: Any] ?? [:]
let healthChecks = healthStructured["checks"] as? [[String: Any]] ?? []
let identity = healthChecks.first ?? [:]
let identityData = identity["data"] as? [String: Any] ?? [:]
let hostBundleId = Bundle.main.bundleIdentifier ?? ""
let identityOk = identity["status"] as? String == "pass" &&
identityData["bundle_identifier"] as? String == hostBundleId &&
identityData["identity_source"] as? String == "parent_application" &&
identityData["responsible_process_id"] as? Int == ProcessInfo.processInfo.processIdentifier
log("health_report — bundle_identity: \(identity["status"] ?? "?"), " +
"observed host: \(identityData["bundle_identifier"] ?? "?") (want: \(hostBundleId))")

let perms = call("check_permissions")
let structured = perms["structuredContent"] as? [String: Any] ?? [:]
let source = structured["source"] as? [String: Any] ?? [:]
let attribution = source["attribution"] as? String ?? "?"
let permissionsMatchHost = structured["accessibility"] as? Bool == ax &&
structured["screen_recording"] as? Bool == sr
log("check_permissions — attribution: \(attribution) (want: host), " +
"TCC matches host: \(permissionsMatchHost), " +
"capturable: \(structured["screen_recording_capturable"] ?? "?")")

// 5. Background AX read + window screenshot — proves both grants
Expand Down Expand Up @@ -158,7 +175,8 @@ let cursorOk = (cursor1["isError"] as? Bool) != true &&
(cursor2["isError"] as? Bool) != true
log("move_cursor — \(cursorOk ? "ok" : "FAILED")")

let pass = attribution == "host" && !images.isEmpty && hasTree && cursorOk
let pass = identityOk && attribution == "host" && permissionsMatchHost &&
!images.isEmpty && hasTree && cursorOk
log(pass ? "DEMO COMPLETE: PASS" : "DEMO COMPLETE: FAIL")
driver.terminate()
daemon.terminate()
Expand Down
Loading