From be6fe93afa3eb8d5a2c7ebb948e81e166f548549 Mon Sep 17 00:00:00 2001 From: Zane Chee Date: Sun, 12 Jul 2026 11:35:11 +0800 Subject: [PATCH 1/2] fix(cua-driver): accept host identity in embedded health check --- .../platform-macos/src/tools/health_report.rs | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs index 1bbdb378ae..ad87197b81 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs @@ -110,13 +110,19 @@ 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); let data = CheckData { bundle_identifier: bid.clone(), executable_path: if exe.is_empty() { None } else { Some(exe) }, ..Default::default() }; - if is_correct { + if cua_driver_core::embedded_mode() { + return CheckEntry::pass( + NAME_BUNDLE_IDENTITY, + "Embedded mode uses the host application's TCC identity.", + ) + .with_data(data); + } + if bid.as_deref() == Some(CANONICAL_BUNDLE_ID) { return CheckEntry::pass( NAME_BUNDLE_IDENTITY, format!("Bundle is {CANONICAL_BUNDLE_ID}."), @@ -282,6 +288,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 { + 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) { + 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(); @@ -316,6 +342,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 @@ -342,6 +371,23 @@ 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 bundle_identity_passes_in_embedded_mode() { + let _guard = env_lock(); + let embedded = swap_env(cua_driver_core::EMBEDDED_ENV, Some("1")); + + let entry = check_bundle_identity(); + assert_eq!(entry.status, CheckStatus::Pass); + assert!(entry.message.contains("host application's TCC identity")); + assert!(entry.hint.is_none()); + let data = entry.data.expect("diagnostic data expected"); + assert!(data.executable_path.is_some()); + + restore_env(cua_driver_core::EMBEDDED_ENV, embedded); } // End-to-end through the dispatcher — checks every macOS canonical From ed89e754358b48f4adf11d213bf63495b7a9a589 Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:27:30 +0000 Subject: [PATCH 2/2] fix(cua-driver): verify embedded host identity --- .../docs/reference/cua-driver/embedding.mdx | 4 +- .../rust/Skills/cua-driver/EMBEDDING.md | 33 ++++- .../cua-driver-core/src/health_report.rs | 9 ++ .../platform-macos/src/tools/health_report.rs | 122 +++++++++++++++--- .../ExampleAgentHarness.swift | 22 +++- 5 files changed, 165 insertions(+), 25 deletions(-) diff --git a/docs/content/docs/reference/cua-driver/embedding.mdx b/docs/content/docs/reference/cua-driver/embedding.mdx index b9db50b1a9..62cbbf605a 100644 --- a/docs/content/docs/reference/cua-driver/embedding.mdx +++ b/docs/content/docs/reference/cua-driver/embedding.mdx @@ -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 @@ -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 diff --git a/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md b/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md index 37d3bce1e1..536f5dc0aa 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md +++ b/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md @@ -249,11 +249,12 @@ gateway / node daemon YourApp.app --socket ``` -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 @@ -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 @@ -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() diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/health_report.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/health_report.rs index 13f486c502..424b63145e 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/health_report.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/health_report.rs @@ -95,8 +95,14 @@ pub struct CheckData { #[serde(skip_serializing_if = "Option::is_none")] pub bundle_identifier: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub configured_bundle_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub executable_path: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub identity_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub responsible_process_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub os_version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub architecture: Option, @@ -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() diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs index ad87197b81..eff0082ba3 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/health_report.rs @@ -110,18 +110,27 @@ pub(crate) fn check_bundle_identity() -> CheckEntry { .and_then(|p| p.to_str().map(str::to_owned)) .unwrap_or_default(); + 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 cua_driver_core::embedded_mode() { - return CheckEntry::pass( - NAME_BUNDLE_IDENTITY, - "Embedded mode uses the host application's TCC identity.", - ) - .with_data(data); - } if bid.as_deref() == Some(CANONICAL_BUNDLE_ID) { return CheckEntry::pass( NAME_BUNDLE_IDENTITY, @@ -146,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, + configured_host_bundle_id: Option, + 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. @@ -376,18 +433,53 @@ mod tests { } #[test] - fn bundle_identity_passes_in_embedded_mode() { - let _guard = env_lock(); - let embedded = swap_env(cua_driver_core::EMBEDDED_ENV, Some("1")); - - let entry = check_bundle_identity(); + 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("host application's TCC identity")); + assert!(entry.message.contains("com.example.host")); assert!(entry.hint.is_none()); let data = entry.data.expect("diagnostic data expected"); - assert!(data.executable_path.is_some()); + 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)); + } - restore_env(cua_driver_core::EMBEDDED_ENV, embedded); + #[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 diff --git a/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift b/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift index 9cb660a41b..5d2fa30995 100644 --- a/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift +++ b/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift @@ -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 @@ -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()