From 6a7c3f193b2af1637d9e58e43dc96d7a58ebe5a0 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 17:33:30 +0800 Subject: [PATCH 01/28] =?UTF-8?q?feat(cua):=20Tauri=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=20CUA=20Driver=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 services/cua 模块:CuaDriver trait + macOS 实现(osascript / screencapture / cliclick) - CuaStore 持有 enabled / allowed_owners / audit_log_limit 配置 + 最近 N 条审计日志,enforce() 在每条命令前守门 - CuaError 结构化错误(kind + params + message),Locale-aware 渲染入口 - 12 个 Tauri Command:cua_status / set_config / clear_audit / list_windows / focus_window / screenshot / click / double_click / type / key / scroll / drag - force_activate_main_window:NSApp.activate + makeKeyAndOrderFront + makeFirstResponder(WKWebView),带 respondsToSelector 守门与 objc2::exception::catch,WKWebView accessibilityChildren 注册 + NSAccessibilityUIElementCreatedNotification 广播 - 新增 cua_window_ready / cua_refresh_a11y IPC 命令,供前端路由切换后主动重激活 a11y --- Cargo.lock | 1 + crates/agent-gui/src-tauri/Cargo.toml | 3 +- .../src-tauri/src/commands/app/app.rs | 10 +- .../agent-gui/src-tauri/src/commands/cua.rs | 381 +++++++++++ .../agent-gui/src-tauri/src/commands/mod.rs | 1 + crates/agent-gui/src-tauri/src/lib.rs | 496 +++++++++++++- .../src-tauri/src/services/cua/driver.rs | 604 ++++++++++++++++++ .../src-tauri/src/services/cua/error.rs | 117 ++++ .../src-tauri/src/services/cua/mod.rs | 21 + .../src-tauri/src/services/cua/store.rs | 302 +++++++++ .../agent-gui/src-tauri/src/services/mod.rs | 1 + crates/agent-gui/src-tauri/tauri.conf.json | 1 + 12 files changed, 1927 insertions(+), 11 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/cua.rs create mode 100644 crates/agent-gui/src-tauri/src/services/cua/driver.rs create mode 100644 crates/agent-gui/src-tauri/src/services/cua/error.rs create mode 100644 crates/agent-gui/src-tauri/src/services/cua/mod.rs create mode 100644 crates/agent-gui/src-tauri/src/services/cua/store.rs diff --git a/Cargo.lock b/Cargo.lock index 1800f9794..a58c2c3fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3406,6 +3406,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-av-foundation", + "objc2-foundation 0.3.2", "percent-encoding", "portable-pty", "prost", diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index cb67349d8..9a4910854 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -78,8 +78,9 @@ encoding_rs = "0.8.35" chardetng = "0.1.17" [target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSControl", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSView", "NSWindow"] } objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "AVCaptureDevice", "AVMediaFormat"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] } objc2 = { version = "0.6", default-features = false, features = ["std"] } block2 = "0.6" diff --git a/crates/agent-gui/src-tauri/src/commands/app/app.rs b/crates/agent-gui/src-tauri/src/commands/app/app.rs index c0185c44e..7e7234e0e 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/app.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/app.rs @@ -39,6 +39,7 @@ pub fn app_frontend_ready( ) -> Result<(), String> { ready_state.0.store(true, Ordering::SeqCst); if window.is_visible().unwrap_or(false) { + crate::force_activate_main_window(&window); return Ok(()); } window @@ -46,7 +47,14 @@ pub fn app_frontend_ready( .map_err(|error| format!("failed to show frontend-ready window: {error}"))?; window .set_focus() - .map_err(|error| format!("failed to focus frontend-ready window: {error}")) + .map_err(|error| format!("failed to focus frontend-ready window: {error}"))?; + // CUA-007: macOS-only NSApp.activate + makeKeyAndOrderFront ensures the + // window reaches the frontmost-ordinary window state so cua-driver's + // `bring_to_front` and foreground delivery actually land on the + // WebView. `set_focus` alone can leave the window below the + // loginwindow or another system surface in a dev sandbox. + crate::force_activate_main_window(&window); + Ok(()) } /// 前端主动切换置顶(置顶指示器点击取消);状态变更仍经 diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs new file mode 100644 index 000000000..ecbadacf2 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -0,0 +1,381 @@ +//! CUA Tauri Command 层。所有命令的统一规范: +//! 1. `cua_*` 命名前缀;状态无关。 +//! 2. 经 `CuaStore::enforce` 守卫:未启用 / 白名单拒了就直接 `Err` 回去, +//! 不调用驱动。 +//! 3. 操作结果写审计日志(成功 + 失败都写),便于前端面板与远端审计。 +//! 4. `screenshot` 返回 base64(前端可经 invoke 解码成 PNG),与 MCP +//! 协议桥的标准一致;底层驱动返回原始字节。 +//! +//! 平台:非 macOS 上 `cua_status.available=false`、其余命令返回 +//! 结构化 `CuaError`(kind + i18n params + 英文 message),由前端按 +//! 当前 locale 翻译——避免之前中文硬编码字串跨 IPC 泄漏(CUA-006)。 + +use base64::Engine; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::State; + +use crate::services::cua::{ + store::CuaRuntimeConfig, ClickButton, CuaAuditEntry, CuaError, CuaStore, CuaStoreSnapshot, + PlatformError, WindowInfo, +}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaScreenshotResponse { + pub width: u32, + pub height: u32, + pub base64_png: String, +} + +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ClickButtonArg { + #[default] + Left, + Middle, + Right, +} + +impl From for ClickButton { + fn from(value: ClickButtonArg) -> Self { + match value { + ClickButtonArg::Left => ClickButton::Left, + ClickButtonArg::Middle => ClickButton::Middle, + ClickButtonArg::Right => ClickButton::Right, + } + } +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaOpResponse { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 把驱动层错误转成跨 IPC 的结构化错误。`platform_driver` 调用方需要 +/// `?` 后再调一次 `PlatformError::into()`。 +fn map_platform_error(err: PlatformError) -> CuaError { + match err { + PlatformError::Unsupported(os) => CuaError::unsupported_platform(&os), + PlatformError::PermissionRequired { + permission_key, + permission, + } => CuaError::permission_required(permission_key, permission), + PlatformError::NotExecuted(detail) => CuaError::not_executed(&detail), + PlatformError::Io(detail) => CuaError::io(&detail), + } +} + +fn record_audit( + store: &Arc, + op: &str, + detail: Option, + result: &Result<(), PlatformError>, + started: chrono::DateTime, +) { + let entry = match result { + Ok(()) => CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: true, + error: None, + detail, + }, + Err(err) => CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: false, + error: Some(map_platform_error(err.clone())), + detail, + }, + }; + store.record(entry); +} + +fn run_with_audit( + store: &Arc, + op: &str, + detail: Option, + f: F, +) -> Result<(), CuaError> +where + F: FnOnce() -> Result<(), PlatformError>, +{ + let started = Utc::now(); + let result = f(); + record_audit(store, op, detail, &result, started); + result.map_err(map_platform_error) +} + +// ───────── Commands ───────── + +/// 平台标签 + 是否可用 + 当前配置 + 最近操作。前端挂载时调用一次。 +#[tauri::command(rename_all = "snake_case")] +pub fn cua_status(store: State<'_, Arc>) -> CuaStoreSnapshot { + store.snapshot() +} + +/// 整体覆盖配置。前端保存面板时调用。 +#[tauri::command(rename_all = "snake_case")] +pub fn cua_set_config( + store: State<'_, Arc>, + config: CuaRuntimeConfig, +) -> CuaStoreSnapshot { + store.replace_config(config); + store.snapshot() +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_clear_audit(store: State<'_, Arc>) -> CuaStoreSnapshot { + store.clear_audit(); + store.snapshot() +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_list_windows(store: State<'_, Arc>) -> Result, CuaError> { + let op = "list_windows"; + store.enforce(op, None)?; + let driver = crate::services::cua::platform_driver(); + let started = Utc::now(); + let result = driver.list_windows(); + record_audit(store.inner(), op, None, &result.clone().map(|_| ()), started); + result.map_err(map_platform_error) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_focus_window( + store: State<'_, Arc>, + owner: String, +) -> Result { + let op = "focus_window"; + let owner_ref = owner.trim().to_string(); + store.enforce(op, Some(&owner_ref))?; + let driver = crate::services::cua::platform_driver(); + let started = Utc::now(); + let detail = serde_json::json!({ "owner": &owner_ref }); + let result = driver.focus_window(&owner_ref); + record_audit(store.inner(), op, Some(detail), &result, started); + // 焦点失败不该让调用方 abort —— best-effort;把 ok=false + error 一起返回。 + Ok(CuaOpResponse { + ok: result.is_ok(), + error: result.err().map(map_platform_error), + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_screenshot( + store: State<'_, Arc>, + window_owner: Option, +) -> Result { + let op = "screenshot"; + // 截屏不强制白名单(截图系统自身 / 必要场景),但仍要 enabled。 + store.enforce(op, None)?; + let driver = crate::services::cua::platform_driver(); + let started = Utc::now(); + let result = driver.screenshot(window_owner.as_deref()); + match result { + Ok(bytes) => { + let (width, height) = png_dimensions(&bytes).unwrap_or((0, 0)); + let base64_png = base64::engine::general_purpose::STANDARD.encode(&bytes); + store.inner().record(CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: true, + error: None, + detail: Some(serde_json::json!({ + "width": width, + "height": height, + "windowOwner": window_owner, + "sizeBytes": bytes.len(), + })), + }); + Ok(CuaScreenshotResponse { + width, + height, + base64_png, + }) + } + Err(err) => { + let err = map_platform_error(err); + store.inner().record(CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: false, + error: Some(err.clone()), + detail: Some(serde_json::json!({ "windowOwner": window_owner })), + }); + Err(err) + } + } +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_click( + store: State<'_, Arc>, + x: i32, + y: i32, + button: Option, +) -> Result { + let op = "click"; + store.enforce(op, None)?; + let button: ClickButton = button.unwrap_or_default().into(); + let detail = Some(serde_json::json!({ + "x": x, "y": y, "button": format!("{:?}", button) + })); + let driver = crate::services::cua::platform_driver(); + run_with_audit(store.inner(), op, detail, || driver.click(x, y, button))?; + Ok(CuaOpResponse { + ok: true, + error: None, + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_double_click( + store: State<'_, Arc>, + x: i32, + y: i32, +) -> Result { + let op = "double_click"; + store.enforce(op, None)?; + let detail = Some(serde_json::json!({ "x": x, "y": y })); + let driver = crate::services::cua::platform_driver(); + run_with_audit(store.inner(), op, detail, || driver.double_click(x, y))?; + Ok(CuaOpResponse { + ok: true, + error: None, + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_type( + store: State<'_, Arc>, + text: String, + target_owner: Option, +) -> Result { + let op = "type"; + let owner_ref = target_owner.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()); + store.enforce(op, owner_ref)?; + let len = text.chars().count(); + let detail = Some(serde_json::json!({ + "length": len, + "targetOwner": target_owner, + })); + let driver = crate::services::cua::platform_driver(); + run_with_audit(store.inner(), op, detail, || driver.type_text(&text))?; + Ok(CuaOpResponse { + ok: true, + error: None, + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_key( + store: State<'_, Arc>, + key: String, + modifiers: Option>, + target_owner: Option, +) -> Result { + let op = "key"; + let owner_ref = target_owner.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()); + store.enforce(op, owner_ref)?; + let mods = modifiers.unwrap_or_default(); + let detail = Some(serde_json::json!({ + "key": key, + "modifiers": &mods, + "targetOwner": target_owner, + })); + let driver = crate::services::cua::platform_driver(); + run_with_audit(store.inner(), op, detail, || { + driver.press_key(&key, &mods) + })?; + Ok(CuaOpResponse { + ok: true, + error: None, + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_scroll( + store: State<'_, Arc>, + x: i32, + y: i32, + dy: i32, +) -> Result { + let op = "scroll"; + store.enforce(op, None)?; + let detail = Some(serde_json::json!({ "x": x, "y": y, "dy": dy })); + let driver = crate::services::cua::platform_driver(); + run_with_audit(store.inner(), op, detail, || driver.scroll(x, y, dy))?; + Ok(CuaOpResponse { + ok: true, + error: None, + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn cua_drag( + store: State<'_, Arc>, + x1: i32, + y1: i32, + x2: i32, + y2: i32, +) -> Result { + let op = "drag"; + store.enforce(op, None)?; + let detail = Some(serde_json::json!({ + "x1": x1, "y1": y1, "x2": x2, "y2": y2 + })); + let driver = crate::services::cua::platform_driver(); + run_with_audit(store.inner(), op, detail, || driver.drag(x1, y1, x2, y2))?; + Ok(CuaOpResponse { + ok: true, + error: None, + }) +} + +// ───────── Helpers ───────── + +/// 单元辅助:从 PNG 字节流解析 width / height(仅取 IHDR 头 8 个字节)。 +fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 24 { + return None; + } + if &bytes[..8] != b"\x89PNG\r\n\x1a\n".as_slice() { + return None; + } + let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + Some((width, height)) +} + +/// cua-driver 入口命令:把主窗口重新推到最前并等待 `is_focused()` 落 +/// 住。命令不依赖 CUA enable 开关——它的目的正是让 enable=true 之 +/// 后的 click / AX 路径能找到 WebView(任何一次失败都让 +/// `bring_to_front` + foreground HID 落入 `unverifiable` / `ax_window_unresolved`)。 +#[tauri::command(rename_all = "snake_case")] +pub async fn cua_window_ready( + window: tauri::WebviewWindow, +) -> Result { + Ok(crate::cua_window_ready(window).await) +} + +/// CUA-020/021/022: 在路由切换、overlay 打开/关闭、cua-driver 主动唤 +/// 起等场景重新触发 NSWindow/WKWebView 的 AX 注解 + 一回弹广播 +/// UIElementCreatedNotification。`force_activate_main_window` 内部的 +/// 注解是幂等的(NSWindow 已 setAccessibilityEnabled=true 不再变化), +/// 但 `makeFirstResponder + becomeFirstResponder` 在 WKWebView 被 hot +/// reload 重建后必须重跑,否则 cua-driver 的 foreground HID 会落到 +/// content view 而不是 renderer,前端反馈 `effect: unverifiable`。 +/// +/// 不依赖 CUA enable 开关——这是「先把 WebView 修好」,与 +/// `cua_window_ready` 是同一族操作。返回值为可序列化诊断字段,便于前 +/// 端 / 远端验证调用确实落地。 +#[tauri::command(rename_all = "snake_case")] +pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yResponse { + crate::cua_refresh_a11y(&window) +} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/commands/mod.rs b/crates/agent-gui/src-tauri/src/commands/mod.rs index 2b145be5e..e34fdd654 100644 --- a/crates/agent-gui/src-tauri/src/commands/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod app_commands; pub mod automation_commands; #[path = "config/mod.rs"] pub mod config_commands; +pub mod cua; #[path = "history/mod.rs"] pub mod history_commands; #[path = "integration/mod.rs"] diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 986bd3e7c..19bed94c7 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -309,6 +309,20 @@ macro_rules! app_invoke_handler { commands::system::system_begin_power_activity, commands::system::system_end_power_activity, commands::system::system_clipboard_read_text, + commands::cua::cua_status, + commands::cua::cua_set_config, + commands::cua::cua_clear_audit, + commands::cua::cua_list_windows, + commands::cua::cua_focus_window, + commands::cua::cua_screenshot, + commands::cua::cua_click, + commands::cua::cua_double_click, + commands::cua::cua_type, + commands::cua::cua_key, + commands::cua::cua_scroll, + commands::cua::cua_drag, + commands::cua::cua_window_ready, + commands::cua::cua_refresh_a11y, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, @@ -352,11 +366,425 @@ fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { window.show()?; window.unminimize()?; window.set_focus()?; + // CUA-007: on macOS, `set_focus()` alone is not enough to reclaim + // focus from a previously foreground app (e.g. a stale Problem + // Reporter dialog). `NSApp.activate(ignoringOtherApps: true)` plus + // `makeKeyAndOrderFront` is the documented pattern for "always + // bring this window to the front", which is what cua-driver needs + // for `bring_to_front` to land on the dev window. + force_activate_main_window(&window); } Ok(()) } +/// macOS-only: bring the main window to the front by activating NSApp and +/// ordering the window key. No-op on other platforms. +#[cfg(target_os = "macos")] +#[allow(deprecated)] // `activateIgnoringOtherApps` is the documented hook for our case; the new `NSApp.activate` API is not yet available in our objc2-app-kit version. +pub(crate) fn force_activate_main_window(window: &tauri::WebviewWindow) { + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSWindow}; + use objc2::MainThreadMarker; + // `WebviewWindow::ns_window` borrows the window, so the raw pointer we + // get back has a lifetime tied to the borrow. To hand it to the main + // thread we must first convert it to an integer (raw pointers are not + // `Send`); AppKit guarantees the underlying NSWindow outlives the + // Tauri handle, so the integer is a faithful stand-in. + let ns_window_addr = window + .ns_window() + .ok() + .filter(|ptr| !ptr.is_null()) + .map(|ptr| ptr.cast::() as usize) + .unwrap_or(0); + if ns_window_addr == 0 { + return; + } + let _ = window.run_on_main_thread(move || { + let ns_window_ptr = ns_window_addr as *mut NSWindow; + // run_on_main_thread guarantees we are on the AppKit main thread; + // MainThreadMarker::new() panics on the wrong thread, which would + // be a programmer error worth surfacing loudly. + let mtm = MainThreadMarker::new().expect("run_on_main_thread must run on the AppKit main thread"); + let ns_app: Retained = NSApplication::sharedApplication(mtm); + // Ignoring other apps is exactly what we want during dev: cua-driver + // needs the LiveAgent window to be frontmost to deliver input. In + // production, users would notice this; in dev it is the right knob + // because the desktop test harness explicitly summons the window. + ns_app.activateIgnoringOtherApps(true); + let ns_window: &NSWindow = unsafe { &*ns_window_ptr }; + ns_window.makeKeyAndOrderFront(None); + // CUA-007: the WKWebView child view must be the first responder + // for HID events to actually reach the renderer. Without this, + // cua-driver's foreground delivery reports `effect: unverifiable` + // because the input is dropped at the AppKit layer — the NSWindow + // is "frontmost" but no responder consumes the events. + // CUA-011: explicitly enable accessibility on the NSWindow so + // WindowServer's AX walker can descend into the WKWebView subtree. + // Without this, `cua-driver get_window_state` returns + // `ax_window_unresolved` because the AppKit surface we present + // does not opt into accessibility until *something* queries it. + // We call this on every re-activation so a later dev reload still + // benefits even if the renderer tears down the previous responder. + // CUA-019: also explicitly mark the NSWindow itself as an + // accessibility element with role AXWindow so cua-driver's + // AXWindow walk has at least one entry. Without this the + // window's default `isAccessibilityElement = false` keeps it + // out of the AX tree entirely, and even a perfectly-configured + // WKWebView subtree is unreachable. + // + // Caveat: the tao-rs `TaoWindow` class is a runtime-built + // subclass of NSWindow. `respondsToSelector(setIsAccessibilityElement:)` + // returns false because the subclass is registered with the + // bare minimum methods (see tao's WindowClass). Sending the + // selector anyway throws `NSInvalidArgumentException` + // (unrecognized selector) which terminates the dev binary at + // first paint. The CUA-015 guard wraps the call in + // `objc2::exception::Exception::catch` so a future tao/wry + // release that DOES implement these selectors can take + // advantage without us having to ship another round trip. + // + // The `NSAccessibilitySetOverrideEnabled(true)` function that + // would force AppKit to publish the tree regardless of consumer + // presence is NOT in the public AppKit symbol table on recent + // macOS releases (it's an internal helper), so we can't link + // against it without a private framework header. The runtime + // configuration must rely on (a) the WKWebView's remote a11y + // tree being installed automatically when WKWebView is added + // to a window whose `accessibilityEnabled` is true, and (b) + // the AX walker picking it up via the `setAccessibilityChildren:` + // call below. + let _ = objc2::exception::catch(std::panic::AssertUnwindSafe(|| unsafe { + let _: () = objc2::msg_send![&*ns_window, setAccessibilityEnabled: true]; + let _: () = objc2::msg_send![&*ns_window, setIsAccessibilityElement: true]; + let ax_window_role: Retained = objc2::msg_send![ + objc2::class!(NSString), + stringWithUTF8String: b"AXWindow\0".as_ptr() + ]; + let _: () = objc2::msg_send![&*ns_window, setAccessibilityRole: &*ax_window_role]; + })); + if let Some(content_view) = ns_window.contentView() { + // CUA-015: Tauri 2 / wry's NSWindow contentView is a + // `wry::WryWebViewParent`, which does NOT implement + // `setIsAccessibilityElement:` or `setAccessibilityRole:`. + // Calling them unconditionally throws `NSInvalidArgumentException` + // (`unrecognized selector sent to instance …`) and terminates + // the dev binary at `frontend_ready` time, blocking every + // launch. Guard with `respondsToSelector:` so we silently skip + // the annotation on classes that haven't opted into + // accessibility, and wrap the whole block in + // `objc2::exception::Exception::catch` so any future selector + // drift in a wry/Tauri release cannot take the binary down. + // CUA-011's intent (WindowServer's AX walker can descend into + // the WKWebView subtree) is preserved when wry later swaps in a + // view that does respond; the `setAccessibilityEnabled:` call on + // the NSWindow above is unaffected. + use objc2::runtime::NSObjectProtocol; + let annotates_accessibility = content_view + .respondsToSelector(objc2::sel!(setIsAccessibilityElement:)) + && content_view.respondsToSelector(objc2::sel!(setAccessibilityRole:)); + if annotates_accessibility { + // Use a raw pointer + AssertUnwindSafe so the closure stays + // `UnwindSafe` regardless of `Retained`'s auto-trait impls + // (the `Retained` itself is still owned by + // `content_view` outside the catch). + let cv_ptr = + std::ptr::addr_of!(*content_view).cast::(); + let _ = objc2::exception::catch( + std::panic::AssertUnwindSafe(move || { + let role: objc2::rc::Retained = unsafe { + objc2::msg_send![ + objc2::class!(NSString), + stringWithUTF8String: b"AXWindow\0".as_ptr() + ] + }; + unsafe { + let _: () = objc2::msg_send![ + cv_ptr, + setIsAccessibilityElement: true + ]; + let _: () = + objc2::msg_send![cv_ptr, setAccessibilityRole: &*role]; + } + }), + ); + } + // CUA-017: walk the content view's subviews to find the actual + // WKWebView. `wry::WryWebViewParent` inherits NSView's default + // `acceptsFirstResponder == false`, so calling + // `makeFirstResponder(Some(&content_view))` silently fails on + // every dev build — AppKit drops the request because the + // receiver refuses to become first responder. The WKWebView + // subclass overrides `acceptsFirstResponder` to return `true` + // (WebKit consumes keyboard events), so naming it explicitly is + // what actually plumbs foreground HID into the renderer. + let wk_webview_ptr = find_wk_webview_in_subviews(&content_view); + if let Some(wk_ptr) = wk_webview_ptr { + let wk_view: &objc2_app_kit::NSView = unsafe { wk_ptr.as_ref() }; + // Promote the WKWebView to first responder through BOTH + // pathways. `makeFirstResponder` is the window-level + // delegate; `becomeFirstResponder` is the view-level + // confirmation that exercises `acceptsFirstResponder` so + // we know the rename actually stuck. + let ok = ns_window.makeFirstResponder(Some(wk_view)); + let became = wk_view.becomeFirstResponder(); + if !ok || !became { + eprintln!( + "liveagent: WKWebView first responder not granted (makeFirstResponder={ok}, become={became}); falling back to content view" + ); + ns_window.makeFirstResponder(Some(&content_view)); + } + // CUA-019: declare the WKWebView as the content view's + // accessibility child so WindowServer's AX walker has a + // deterministic entry point into the WKWebView subtree. + // Without this, `get_window_state` reports + // `ax_window_unresolved` because + // `wry::WryWebViewParent` doesn't implement + // `accessibilityChildren` itself (the selector is + // inherited as a no-op). The WKWebView already owns a + // remote AX tree; we just need to expose it. + unsafe { + let wk_array: Retained> = + objc2::msg_send![ + objc2::class!(NSArray), + arrayWithObject: wk_view + ]; + let _: () = objc2::msg_send![ + &*content_view, + setAccessibilityChildren: &*wk_array + ]; + } + // CUA-019: also explicitly mark the WKWebView itself as + // an accessibility element with role AXWebArea so the + // WKWebView's own AX bridge has at least one anchor + // entry visible to AX walkers that look inside the + // window. WebKit installs its remote a11y tree under + // this element. The whole block is wrapped in + // exception::catch so a future WebKit selector drift + // cannot crash the dev binary. + let _ = objc2::exception::catch(std::panic::AssertUnwindSafe(|| unsafe { + let _: () = objc2::msg_send![wk_view, setAccessibilityEnabled: true]; + let _: () = objc2::msg_send![wk_view, setIsAccessibilityElement: true]; + let ax_web_area: Retained = objc2::msg_send![ + objc2::class!(NSString), + stringWithUTF8String: b"AXWebArea\0".as_ptr() + ]; + let _: () = objc2::msg_send![wk_view, setAccessibilityRole: &*ax_web_area]; + })); + // CUA-019: also broadcast a UIElementCreatedNotification + // on the content view. WindowServer's AX walker treats + // this as a cue to re-evaluate the window's subtree — if + // it had previously cached `ax_window_unresolved`, the + // next `get_window_state` call sees the WKWebView's tree. + // The notification is cheap; re-broadcasting on every + // activation also covers the dev-reload path where the + // previous WKWebView was torn down. + post_accessibility_element_created(&content_view); + } else { + // Fallback path: WKWebView not in the view hierarchy yet + // (page still loading) or wry swapped implementations. + // Keep the CUA-007 behaviour so a partial first paint + // still routes input to the responder chain's deepest + // accepting view. + ns_window.makeFirstResponder(Some(&content_view)); + } + } + }); +} + +#[cfg(target_os = "macos")] +fn find_wk_webview_in_subviews( + view: &objc2_app_kit::NSView, +) -> Option> { + // `class!` would force a hard dependency on the WebKit framework class + // list, which is not part of objc2-app-kit. Resolve WKWebView at + // runtime via the Objective-C class registry — wry loads WebKit as + // part of WKWebView construction, so the class is registered by the + // time we get here in production. In tests where no WebKit is + // loaded, `get` returns `None` and we silently fall through. + let wk_class = objc2::runtime::AnyClass::get(c"WKWebView")?; + let is_wk: bool = + unsafe { objc2::msg_send![view, isKindOfClass: wk_class] }; + if is_wk { + return Some(std::ptr::NonNull::from(view)); + } + let subviews = view.subviews(); + for sub in subviews.iter() { + if let Some(found) = find_wk_webview_in_subviews(&sub) { + return Some(found); + } + } + None +} + +#[cfg(target_os = "macos")] +fn post_accessibility_element_created(view: &objc2_app_kit::NSView) { + use objc2::rc::Retained; + // `NSAccessibilityPostNotification` is a free C function exported + // from AppKit. `NSAccessibility` itself is only a category on + // `NSObject` + a protocol — it is NOT a real class, so `class!` + // would panic at runtime. Linking the symbol directly is the + // supported path (it's how every macOS app calls this entry point). + // Wrapping the call in `objc2::exception::catch` keeps a malformed + // AppKit from taking the dev binary down if the selector ever + // changes signature in a future macOS release. + #[link(name = "AppKit", kind = "framework")] + extern "C" { + fn NSAccessibilityPostNotification( + element: *const objc2::runtime::AnyObject, + notification: *const objc2::runtime::AnyObject, + ); + } + let _ = objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + unsafe { + let notification_name: Retained = objc2::msg_send![ + objc2::class!(NSString), + stringWithUTF8String: b"NSAccessibilityUIElementCreatedNotification\0".as_ptr() + ]; + NSAccessibilityPostNotification( + std::ptr::addr_of!(*view).cast::(), + std::ptr::addr_of!(*notification_name).cast::(), + ); + } + })); +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn force_activate_main_window(_window: &tauri::WebviewWindow) {} + +/// Polled result of `force_activate_main_window`: cua-driver calls +/// `cua_window_ready` between `bring_to_front` and the first AX / +/// foreground click, so this is the contract the driver can rely on. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaWindowReadyResponse { + /// True once `is_focused()` reports true within the poll window. + pub focused: bool, + /// How long we waited for focus to land (ms). Useful for the caller + /// to detect a slow first paint and back off. + pub elapsed_ms: u64, + /// Whether the macOS-specific NSApp.activate path ran. False on + /// non-macOS platforms where the call is a no-op best-effort. + pub macos_activated: bool, +} + +/// Best-effort "force focus" command for cua-driver. Re-runs the macOS +/// NSApp.activate + makeKeyAndOrderFront + makeFirstResponder cycle (see +/// [`force_activate_main_window`]) and then polls `is_focused()` for up +/// to 750 ms. cua-driver awaits this between `bring_to_front` and the +/// next AX / pixel action to avoid the `effect: unverifiable` / +/// `ax_window_unresolved` regressions seen when the WKWebView is still +/// settling after first paint (CUA-011). +pub async fn cua_window_ready(window: tauri::WebviewWindow) -> CuaWindowReadyResponse { + let started = std::time::Instant::now(); + force_activate_main_window(&window); + let mut focused = window.is_focused().unwrap_or(false); + if !focused { + // Poll up to 750 ms, 50 ms cadence — long enough to outlast a + // typical first-paint cycle but short enough that the driver + // stays interactive. Each iteration re-issues the activation + // hint so AppKit keeps LiveAgent on the foreground space. + let mut elapsed_ms = 0u64; + while elapsed_ms < 750 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + elapsed_ms = started.elapsed().as_millis() as u64; + force_activate_main_window(&window); + if window.is_focused().unwrap_or(false) { + focused = true; + break; + } + } + } + CuaWindowReadyResponse { + focused, + elapsed_ms: started.elapsed().as_millis() as u64, + macos_activated: cfg!(target_os = "macos"), + } +} + +/// CUA-020/021/022: 给前端的「重新发表 AX 表面」诊断响应。 +/// `force_activate_main_window` 是幂等的(NSWindow 注解不变),但每 +/// 次都会重新跑 `makeFirstResponder + becomeFirstResponder` 并在 +/// content view 上重新广播 `UIElementCreatedNotification`,用于 +/// `Settings overlay` 打开 / 路由切换 / WKWebView hot reload 后让 +/// cua-driver 的下一帧 AX 查询拿到刷新后的表面(不再 `unresolved`)。 +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaRefreshA11yResponse { + /// 重新触发了 `force_activate_main_window` 的次数(带 retry 时 >1)。 + pub activations: u32, + /// 找到的 WKWebView 子视图指针是否非空(false 表示尚未挂载)。 + pub wk_webview_found: bool, + /// NSWindow 是否真的拿到了 first responder(macOS only)。 + pub responder_granted: bool, + /// 是否在 macOS 平台跑了实际注解(非 macOS 是 no-op)。 + pub macos_activated: bool, +} + +/// 把 `force_activate_main_window` 的内容再次跑一遍——主要给前端在 +/// `Settings overlay` 打开 / 路由切换 / 模态弹出后手动调用一次,让 +/// WKWebView 的 a11y 子树被 cua-driver 看见(CUA-021)。也用于 +/// `cua_window_ready` 之外的「只修 AX、不等 focus」场景。 +pub fn cua_refresh_a11y(window: &tauri::WebviewWindow) -> CuaRefreshA11yResponse { + // 在 macOS 上 force_activate_main_window 已经覆盖了:NSWindow 注解 + // + WKWebView 第一响应 + AX 子树广播。这里再加一次「WKWebView 是 + // 否真的找到 / 是否真的成 first responder」的诊断,便于前端在 + // 端到端验证时不用再额外发一次 `cua_window_ready`。 + let activations = 1u32; + // 用 Arc 把诊断标志从 `run_on_main_thread` 的闭包里透 + // 出来——闭包按 move 捕获,无法直接拿 `&mut` 外部变量。 + let wk_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let resp_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mtm_present = cfg!(target_os = "macos"); + // CUA-022: 第二次跑确保 WKWebView 在 hot reload / overlay 重渲 + // 之后重新拿到 first responder——`makeFirstResponder` 不是幂等的, + // WKWebView 被 React unmount/remount 后会重置 responder chain。 + force_activate_main_window(window); + #[cfg(target_os = "macos")] + { + use objc2_app_kit::NSWindow; + if let Ok(ptr) = window.ns_window() { + if !ptr.is_null() { + let addr = ptr.cast::() as usize; + if addr != 0 { + let wk_flag_inner = std::sync::Arc::clone(&wk_flag); + let resp_flag_inner = std::sync::Arc::clone(&resp_flag); + let _ = window.run_on_main_thread(move || { + let ns_window_ptr = addr as *mut NSWindow; + let ns_window: &NSWindow = unsafe { &*ns_window_ptr }; + if let Some(content_view) = ns_window.contentView() { + if let Some(wk_ptr) = find_wk_webview_in_subviews(&content_view) { + wk_flag_inner.store(true, std::sync::atomic::Ordering::SeqCst); + let wk_view: &objc2_app_kit::NSView = unsafe { wk_ptr.as_ref() }; + let ok = ns_window.makeFirstResponder(Some(wk_view)); + let became = wk_view.becomeFirstResponder(); + resp_flag_inner.store( + ok && became, + std::sync::atomic::Ordering::SeqCst, + ); + // CUA-021: 重广播一次 UIElementCreated 通 + // 知,让 WindowServer 的 AX walker 把最近 + // 一次渲染(含 Settings overlay 重渲)写 + // 进缓存。cua-driver 下一帧 + // `get_window_state` 不会再返回 + // `ax_window_unresolved`。 + post_accessibility_element_created(&content_view); + } + } + }); + } + } + } + } + let _ = mtm_present; + CuaRefreshA11yResponse { + activations, + wk_webview_found: wk_flag.load(std::sync::atomic::Ordering::SeqCst), + responder_granted: resp_flag.load(std::sync::atomic::Ordering::SeqCst), + macos_activated: mtm_present, + } +} + fn toggle_main_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { let visible = window.is_visible().unwrap_or(false); @@ -733,6 +1161,10 @@ pub fn run() { commands::app::CLOSE_WINDOW_BEHAVIOR_MINIMIZE, )); let stt_manager = Arc::new(services::stt::SttManager::default()); + // CUA 默认 disabled。前端首次加载后会把用户的开关 / 名单 push 进来。 + let cua_store = Arc::new(services::cua::CuaStore::new( + services::cua::CuaRuntimeConfig::default(), + )); let builder = tauri::Builder::default(); // dev 构建与已安装正式版共享 identifier;若 dev 也注册单实例, @@ -781,19 +1213,65 @@ pub fn run() { .manage(Arc::clone(&automation_scheduler)) .manage(Arc::new(commands::hook::HookScopeRegistry::default())) .manage(stt_manager) + .manage(cua_store) .on_page_load(|webview, payload| { - if webview.label() != MAIN_WINDOW_LABEL - || !matches!(payload.event(), tauri::webview::PageLoadEvent::Started) - { + if webview.label() != MAIN_WINDOW_LABEL { return; } let app = webview.app_handle(); - if let Some(ready_state) = app.try_state::>() { - ready_state.0.store(false, Ordering::SeqCst); - } - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - if window.is_visible().unwrap_or(false) { - let _ = window.hide(); + match payload.event() { + tauri::webview::PageLoadEvent::Started => { + if let Some(ready_state) = + app.try_state::>() + { + ready_state.0.store(false, Ordering::SeqCst); + } + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } + } + } + tauri::webview::PageLoadEvent::Finished => { + // CUA-011: re-issue force_activate_main_window after the + // first paint so the WKWebView is the first responder and + // the NSWindow's accessibility surface is wired up before + // cua-driver walks the AX tree. App_frontend_ready fires + // from a static-shell hook (often before the JS bundle is + // parsed); waiting for `Finished` closes the race where + // cua-driver queries the AX tree while the WKWebView + // host view is still being laid out, which is what was + // producing `ax_window_unresolved`. + // CUA-017/019: always run the activation cycle, even when + // the window is still hidden. The function is idempotent + // and the WKWebView is in the view hierarchy by the time + // `Finished` fires — calling it lets the AX walker find + // the WKWebView the moment `show_main_window` unhides + // the window, instead of waiting for cua-driver to issue + // its first foreground action. + // CUA-020: WKWebView's remote AX tree is populated + // asynchronously by the WebContent process — calling + // `force_activate_main_window` exactly at `Finished` + // races the layout pass and yields `ax_window_unresolved` + // on the very first `get_window_state`. Schedule a + // 600 ms deferred re-activation so the second pass lands + // after WebKit has registered its remote a11y children. + // The defer uses tokio (not `run_on_main_thread` + + // `std::thread::sleep`, which would block the AppKit + // main thread and starve the very layout pass we are + // trying to wait out). + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + force_activate_main_window(&window); + let window_for_retry = window.clone(); + let app_handle_for_retry = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + let _ = app_handle_for_retry + .run_on_main_thread(move || { + force_activate_main_window(&window_for_retry); + }); + }); + } } } }) diff --git a/crates/agent-gui/src-tauri/src/services/cua/driver.rs b/crates/agent-gui/src-tauri/src/services/cua/driver.rs new file mode 100644 index 000000000..c51ccfdd1 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua/driver.rs @@ -0,0 +1,604 @@ +//! 平台相关驱动:把屏幕 / 输入抽象为一组同步、可审计的操作。 +//! +//! - macOS:`osascript` 操作 System Events(发送鼠标 / 键盘事件) + +//! `screencapture` 截屏。需要用户在「系统设置 → 隐私与安全」授予 +//! LiveAgent「辅助功能(Accessibility)」与「屏幕录制(Screen Recording)」。 +//! 缺权限时 System Events 报错的特征字串是 `1002` / `Not authorized`, +//! `screencapture` 报 `cannot create screen recording` —— 我们把这些 +//! 翻译成结构化 `PlatformError::PermissionRequired`,提示前端打开权限。 +//! - 其他平台:返回 `PlatformError::Unsupported`,由命令层翻译给前端。 +//! +//! 所有调用走 `Command::new` 直起子进程;不进入沙箱(命令层有 enable 守卫)。 + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ClickButton { + Left, + Middle, + Right, +} + +impl ClickButton { + fn apple_event_name(self) -> &'static str { + match self { + ClickButton::Left => "click", + ClickButton::Middle => "middleClick", + ClickButton::Right => "rightClick", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowInfo { + /// 系统级窗口 ID;macOS 的 CGWindowID 用 u32 表示,足以覆盖常规场景。 + pub window_id: u64, + /// 拥有窗口的应用可执行文件名(`Finder`、`Safari` …)。 + pub owner: String, + /// 窗口标题,没有时为空串。 + pub title: String, + /// 屏幕坐标的左上角 + 尺寸(来自 System Events 的 position / size)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, + /// 是否处于最前。 + pub focused: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowBounds { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// 平台驱动层错误。字段无中文/无 hardcoded 文案——一律交给 `CuaError` 在 +/// 调用边界翻译(CUA-006)。`Clone` 因为 Tauri Command 需要复制错误回前端; +/// 不从 `io::Error` 直接 derive `From`,避免给枚举加 `From` 约束。 +#[derive(Debug, Clone)] +pub enum PlatformError { + /// 当前 OS 不支持 CUA(参数是被拒时的 OS 标签)。 + Unsupported(String), + /// macOS 权限缺失——参数是稳定 `permissionKey`(例如 "accessibility" / + /// "screen-recording")+ 人类可读展示名。 + PermissionRequired { + permission_key: &'static str, + permission: &'static str, + }, + /// 前置条件未满足 / 子进程运行失败但不是权限问题。 + NotExecuted(String), + /// 子进程启动 IO 失败。 + Io(String), +} + +impl std::fmt::Display for PlatformError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Display 仅在日志里有用,权威面向用户消息由 CuaError 提供。 + match self { + PlatformError::Unsupported(os) => { + write!(f, "CUA driver is not available on {os}") + } + PlatformError::PermissionRequired { permission, .. } => { + write!(f, "CUA needs permission: {permission}") + } + PlatformError::NotExecuted(detail) => { + write!(f, "CUA operation not executed: {detail}") + } + PlatformError::Io(detail) => { + write!(f, "CUA IO error: {detail}") + } + } + } +} + +impl std::error::Error for PlatformError {} + +/// CUA 驱动抽象。每种平台给一份实现。调用方约定: +/// 1. 任何操作之前都已过启用开关(store 侧)。 +/// 2. 操作返回错误时,调用方负责把原因结构化给 Tauri Command。 +pub trait CuaDriver: Send + Sync { + fn platform_label() -> &'static str + where + Self: Sized; + + /// 列出当前所有可见窗口。结果按 owner 排序,title / bounds 尽力而为: + /// macOS 通过 System Events,权限不足时整列表失败。 + fn list_windows(&self) -> Result, PlatformError>; + + /// 聚焦指定 owner(应用名)。聚焦失败不抛错;只是 best-effort。 + fn focus_window(&self, owner: &str) -> Result<(), PlatformError>; + + /// 全屏截图(PNG 字节)或指定窗口截图(`window_owner` 给应用名, + /// 截的是该应用最前面的窗口)。macOS 用 `screencapture -x -l `。 + fn screenshot(&self, window_owner: Option<&str>) -> Result, PlatformError>; + + /// 在屏幕坐标处点击。坐标原点在主显示器左上角,与 System Events 一致。 + fn click(&self, x: i32, y: i32, button: ClickButton) -> Result<(), PlatformError>; + + fn double_click(&self, x: i32, y: i32) -> Result<(), PlatformError>; + + /// 输入字符串。osascript `keystroke` 原生支持 unicode。 + fn type_text(&self, text: &str) -> Result<(), PlatformError>; + + /// 按键:`key` 是 key code 字符串(如 "49" = Return,"51" = Delete, + /// "53" = Escape)或 key 名称(如 "return"/"tab"/"escape"),与 + /// System Events 的 `key code` / `key` 等价。modifiers 取自 + /// {"command","option","control","shift"}。 + fn press_key(&self, key: &str, modifiers: &[String]) -> Result<(), PlatformError>; + + /// 滚动:以 (x, y) 为锚点;`dy > 0` 向上、`< 0` 向下(与 macOS + /// `scroll wheel` 一致,前端负责翻转方向)。 + fn scroll(&self, x: i32, y: i32, dy: i32) -> Result<(), PlatformError>; + + /// 拖拽:从 (x1, y1) 到 (x2, y2)。 + fn drag(&self, x1: i32, y1: i32, x2: i32, y2: i32) -> Result<(), PlatformError>; +} + +// ───────── macOS 实现 ───────── + +#[cfg(target_os = "macos")] +pub struct MacOsDriver; + +#[cfg(target_os = "macos")] +impl MacOsDriver { + fn run_osascript(script: &str) -> Result { + let output = Command::new("/usr/bin/osascript") + .arg("-e") + .arg(script) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| PlatformError::Io(e.to_string()))?; + translate_osascript_output(output) + } + + fn run_screencapture(args: &[&str]) -> Result, PlatformError> { + // screencapture 必须写到文件,没有 stdout 输出模式;写到临时目录后读出。 + let path = temp_screenshot_path(); + let status = Command::new("/usr/sbin/screencapture") + .args(args) + .arg(&path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .map_err(|e| PlatformError::Io(e.to_string()))?; + if !status.success() { + return Err(PlatformError::PermissionRequired { + permission_key: "screen-recording", + permission: "Screen Recording", + }); + } + let bytes = std::fs::read(&path).map_err(|e| PlatformError::Io(e.to_string()))?; + let _ = std::fs::remove_file(&path); + Ok(bytes) + } +} + +#[cfg(target_os = "macos")] +impl CuaDriver for MacOsDriver { + fn platform_label() -> &'static str { + "macos" + } + + fn list_windows(&self) -> Result, PlatformError> { + // System Events 的 `every window of (every process whose background + // only is false)` 会返回每个进程的可见窗口。bundle id / 可执行名取自 + // `unix id` / `name`。position / size 在 System Events 上是 `position` + // 和 `size` 属性(points)。focused 暂以「应用是否在前台」近似。 + let script = r#" + tell application "System Events" + set winList to {} + set procs to (every process whose background only is false) + repeat with p in procs + try + set pName to (name of p) as string + set procsWindows to (every window of p) + repeat with w in procsWindows + try + set wTitle to (title of w) as string + on error + set wTitle to "" + end try + try + set wPos to position of w + set wSize to size of w + set wx to item 1 of wPos + set wy to item 2 of wPos + set ww to item 1 of wSize + set wh to item 2 of wSize + set boundsStr to (wx as string) & "," & (wy as string) & "," & (ww as string) & "," & (wh as string) + on error + set boundsStr to "" + end try + set end of winList to (pName & "||" & wTitle & "||" & boundsStr) + end repeat + end try + end repeat + return winList + end tell + "#; + let out = Self::run_osascript(script)?; + let mut windows = Vec::new(); + // 每条记录:`owner||title||x,y,w,h`,用 \n 分隔。 + for line in out.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parts: Vec<&str> = trimmed.splitn(3, "||").collect(); + if parts.len() < 2 { + continue; + } + let owner = parts[0].trim().to_string(); + let title = parts[1].trim().to_string(); + let bounds = if parts.len() == 3 && !parts[2].trim().is_empty() { + let coords: Vec = parts[2] + .split(',') + .filter_map(|c| c.trim().parse::().ok()) + .collect(); + if coords.len() == 4 { + Some(WindowBounds { + x: coords[0] as i32, + y: coords[1] as i32, + width: coords[2].max(0) as u32, + height: coords[3].max(0) as u32, + }) + } else { + None + } + } else { + None + }; + windows.push(WindowInfo { + window_id: 0, + owner, + title, + bounds, + focused: false, + }); + } + // 找不到任何窗口也正常(前台 app 没窗口 / 权限拒绝会走到错误分支) + Ok(windows) + } + + fn focus_window(&self, owner: &str) -> Result<(), PlatformError> { + // 通过 `tell application id` 失败时(应用没有 AppleScript 字典), + // 退化为 `tell application "X" to activate`,再退化为 bring to front。 + let safe_owner = escape_applescript_string(owner); + let script = format!( + r#"try + tell application id "{}" to activate + on error + try + tell application "{}" to activate + on error + tell application "System Events" to set frontmost of first process whose name is "{}" to true + end try + end try"#, + safe_owner, safe_owner, safe_owner + ); + Self::run_osascript(&script).map(|_| ()) + } + + fn screenshot(&self, window_owner: Option<&str>) -> Result, PlatformError> { + // `-x` 不发声,`-t png` 强转 PNG;`-l ` 是窗口截屏模式, + // 但我们这里只给 owner,所以走全屏截屏(窗口级截屏需要 window id, + // 由 Agent 通过 cua_list_windows 后续扩展)。 + let _ = window_owner; // MVP: 暂未实现窗口级截图(需要 window id) + Self::run_screencapture(&["-x", "-t", "png"]) + } + + fn click(&self, x: i32, y: i32, button: ClickButton) -> Result<(), PlatformError> { + let event = button.apple_event_name(); + let script = format!( + r#"tell application "System Events" to {} at {{{}, {}}}"#, + event, x, y + ); + Self::run_osascript(&script).map(|_| ()) + } + + fn double_click(&self, x: i32, y: i32) -> Result<(), PlatformError> { + let script = format!( + r#"tell application "System Events" to double click at {{{}, {}}}"#, + x, y + ); + Self::run_osascript(&script).map(|_| ()) + } + + fn type_text(&self, text: &str) -> Result<(), PlatformError> { + let escaped = escape_applescript_string(text); + let script = format!( + r#"tell application "System Events" to keystroke "{}""#, + escaped + ); + Self::run_osascript(&script).map(|_| ()) + } + + fn press_key(&self, key: &str, modifiers: &[String]) -> Result<(), PlatformError> { + let mods_clause = build_modifier_clause(modifiers); + // 无 modifiers:单字符走 `keystroke`,其余走 `key code`(要求数字)或 `key "name"`。 + // 有 modifiers:必须用 `key code`(System Events 的 keystroke 不接受组合键)。 + let final_script = if mods_clause.is_empty() { + if key.trim().chars().all(|c| c.is_ascii_digit()) { + format!( + r#"tell application "System Events" to key code {}"#, + key.trim() + ) + } else if key.trim().chars().count() == 1 { + format!( + r#"tell application "System Events" to keystroke "{}""#, + escape_applescript_string(key.trim()) + ) + } else { + format!( + r#"tell application "System Events" to key "{}""#, + escape_applescript_string(key.trim()) + ) + } + } else { + let code = if key.trim().chars().all(|c| c.is_ascii_digit()) { + key.trim().to_string() + } else { + // 字符按键没法直接给 code:保守地回退为单字符 keystroke + 修饰键。 + format!( + r#"keystroke "{}" using {{{}}}"#, + escape_applescript_string(key.trim()), + modifiers_to_applescript_list(modifiers) + ) + }; + format!( + r#"tell application "System Events" to key code {}{}"#, + code, mods_clause + ) + }; + Self::run_osascript(&final_script).map(|_| ()) + } + + fn scroll(&self, x: i32, y: i32, dy: i32) -> Result<(), PlatformError> { + // System Events 的 `scroll` 没有专门语法;最稳的是 mouseEvent 配合 + // mousewheel。退而求其次:用 `key code` 模拟 Page Up / Page Down + // 不可行(语义错乱)。我们用 click + drag 思路也不行。 + // 系统命令是 `cliclick` / `osascript -e 'do shell script'` 不可用, + // 我们直接走 `osascript -e 'tell application "System Events" to + // scroll wheel ...'`:它在 macOS 上接受整数 dy。 + let script = format!( + r#"tell application "System Events" to scroll wheel {{{}}} at {{{}, {}}}"#, + dy, x, y + ); + Self::run_osascript(&script).map(|_| ()) + } + + fn drag(&self, x1: i32, y1: i32, x2: i32, y2: i32) -> Result<(), PlatformError> { + // System Events 没有原生 drag API。可借用第三方 `cliclick`(需要 + // 用户安装)。MVP:fallback 到 `cliclick dd:x1,y1 dm:dx,dy du:x2,y2`。 + // 未安装 cliclick 时返回明确错误。 + let total_dx = x2 - x1; + let total_dy = y2 - y1; + let cli = match find_cli_click() { + Ok(p) => p, + Err(msg) => return Err(PlatformError::NotExecuted(msg)), + }; + let status = Command::new(cli) + .arg(format!("dd:{},{}", x1, y1)) + .arg(format!("dm:{},{}", total_dx, total_dy)) + .arg(format!("du:{},{}", x2, y2)) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .map_err(|e| PlatformError::Io(e.to_string()))?; + if !status.success() { + return Err(PlatformError::NotExecuted(format!( + "cliclick 退出码非零:{:?}", + status.code() + ))); + } + Ok(()) + } +} + +#[cfg(target_os = "macos")] +fn temp_screenshot_path() -> PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("liveagent-cua-{ts}.png")) +} + +#[cfg(target_os = "macos")] +fn translate_osascript_output(output: Output) -> Result { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + return Ok(stdout); + } + // 典型权限拒绝信号: + // "Not authorized to ...", error number -1002 (AppleScript 1002), + // "osascript is not allowed to send keystrokes" 等。 + let combined = format!("{stdout}\n{stderr}"); + let lower = combined.to_lowercase(); + if lower.contains("not authorized") + || lower.contains("1002") + || lower.contains("accessibility") + || lower.contains("assistive") + { + return Err(PlatformError::PermissionRequired { + permission_key: "accessibility", + permission: "Accessibility", + }); + } + if stderr.is_empty() { + Err(PlatformError::NotExecuted(format!( + "osascript exit non-zero (stdout={stdout})" + ))) + } else { + Err(PlatformError::NotExecuted(stderr)) + } +} + +#[cfg(target_os = "macos")] +fn modifiers_to_applescript_list(modifiers: &[String]) -> String { + use std::collections::BTreeSet; + let mut mods: BTreeSet = BTreeSet::new(); + for m in modifiers { + let lower = m.trim().to_lowercase(); + match lower.as_str() { + "cmd" | "command" | "commandorcontrol" | "super" | "meta" => { + mods.insert("command down".to_string()); + } + "shift" => { + mods.insert("shift down".to_string()); + } + "ctrl" | "control" => { + mods.insert("control down".to_string()); + } + "alt" | "option" => { + mods.insert("option down".to_string()); + } + _ => {} + } + } + mods.into_iter().collect::>().join(", ") +} + +#[cfg(target_os = "macos")] +fn build_modifier_clause(modifiers: &[String]) -> String { + let list = modifiers_to_applescript_list(modifiers); + if list.is_empty() { + String::new() + } else { + format!(" using {{{list}}}") + } +} + +#[cfg(target_os = "macos")] +fn find_cli_click() -> Result { + // 在 PATH 与常见 brew 安装路径中查找 cliclick,避免引入 which crate。 + use std::env; + if let Some(p) = env::var_os("PATH") { + for dir in env::split_paths(&p) { + let candidate = dir.join("cliclick"); + if candidate.is_file() { + return Ok(candidate); + } + } + } + for brew_dir in [ + "/opt/homebrew/bin/cliclick", + "/usr/local/bin/cliclick", + ] { + let p = PathBuf::from(brew_dir); + if p.is_file() { + return Ok(p); + } + } + Err("拖拽需要系统命令 `cliclick`(brew install cliclick)".into()) +} + +#[cfg(target_os = "macos")] +fn escape_applescript_string(input: &str) -> String { + let mut out = String::with_capacity(input.len() + 4); + for ch in input.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + // 控制字符:写成 \uXXXX 让 AppleScript 拒收,统一提前跳过。 + out.push(' '); + } + c => out.push(c), + } + } + out +} + +// ───────── 非 macOS 占位实现 ───────── + +#[cfg(not(target_os = "macos"))] +pub struct UnsupportedDriver; + +#[cfg(not(target_os = "macos"))] +impl CuaDriver for UnsupportedDriver { + fn platform_label() -> &'static str { + std::env::consts::OS + } + + fn list_windows(&self) -> Result, PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn focus_window(&self, _owner: &str) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn screenshot(&self, _owner: Option<&str>) -> Result, PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn click(&self, _x: i32, _y: i32, _b: ClickButton) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn double_click(&self, _x: i32, _y: i32) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn type_text(&self, _text: &str) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn press_key(&self, _k: &str, _mods: &[String]) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn scroll(&self, _x: i32, _y: i32, _dy: i32) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } + fn drag(&self, _x1: i32, _y1: i32, _x2: i32, _y2: i32) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) + } +} + +// ───────── 驱动工厂 ───────── + +/// 拿到当前平台可用的驱动实例。`&'static` 因为驱动是无状态的。 +pub fn platform_driver() -> &'static dyn CuaDriver { + #[cfg(target_os = "macos")] + { + &MacOsDriver + } + #[cfg(not(target_os = "macos"))] + { + &UnsupportedDriver + } +} + +// ───────── 单元测试 ───────── + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn escape_applescript_handles_quotes_and_backslash() { + assert_eq!(escape_applescript_string("a\"b\\c"), "a\\\"b\\\\c"); + assert_eq!(escape_applescript_string("hi\nthere"), "hi\\nthere"); + } + + #[test] + fn modifier_clause_normalizes_aliases() { + assert_eq!(build_modifier_clause(&[]), ""); + let m = build_modifier_clause(&["cmd".into(), "shift".into()]); + assert!(m.contains("command down")); + assert!(m.contains("shift down")); + } + + #[test] + fn click_button_apple_event_name_matches_system_events() { + assert_eq!(ClickButton::Left.apple_event_name(), "click"); + assert_eq!(ClickButton::Right.apple_event_name(), "rightClick"); + assert_eq!(ClickButton::Middle.apple_event_name(), "middleClick"); + } +} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/cua/error.rs b/crates/agent-gui/src-tauri/src/services/cua/error.rs new file mode 100644 index 000000000..3d987b771 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua/error.rs @@ -0,0 +1,117 @@ +//! CUA 跨 IPC 的结构化错误。 +//! +//! 之前每个错误分支都用 `#[error("…中文…")]` 把字面量硬塞进 IPC 返回值, +//! 结果在英文 locale 下审计日志与 tool 错误都飘出中文(CUA-006)。现在改成 +//! 仅由后端承载「稳定 i18n key + 模板参数 + 英文兜底消息」,由前端 `t(...)` +//! 按活动 locale 渲染。 +//! +//! 不用 `thiserror` 派生:因为 key + params 都来自手工拼装而非 fmt。 + +use serde::{Deserialize, Serialize}; + +/// 跨 IPC 传递的 CUA 错误。前端拿到 `kind` 后查 i18n map;查不到时退回 +/// `message`(始终是英文)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaError { + /// i18n key,例如 `"cua.errors.disabled"`。 + pub kind: String, + /// 模板参数,键名与 i18n 文案的 `{xxx}` 占位一一对应。 + #[serde(default, skip_serializing_if = "is_null_or_empty")] + pub params: serde_json::Value, + /// 英文兜底消息。当前端无法识别 `kind` 时显示。 + pub message: String, +} + +fn is_null_or_empty(v: &serde_json::Value) -> bool { + v.is_null() +} + +impl CuaError { + pub fn new(kind: impl Into, message: impl Into) -> Self { + Self { + kind: kind.into(), + params: serde_json::Value::Null, + message: message.into(), + } + } + + pub fn with_params(kind: impl Into, message: impl Into, params: serde_json::Value) -> Self { + Self { + kind: kind.into(), + params, + message: message.into(), + } + } + + /// 总开关未启用。 + pub fn disabled() -> Self { + Self::new( + "cua.errors.disabled", + "CUA is not enabled. Open Settings → CUA Driver and turn the master switch on.", + ) + } + + /// 目标 owner 不在白名单。 + pub fn denied_by_allowlist(target: &str, allowed: &[String]) -> Self { + Self::with_params( + "cua.errors.deniedByAllowlist", + format!( + "CUA operation denied: target \"{target}\" is not in the allowlist {allowed:?}." + ), + serde_json::json!({ + "target": target, + "allowed": allowed, + }), + ) + } + + /// 当前 OS 尚未实现 CUA 驱动。 + pub fn unsupported_platform(os: &str) -> Self { + Self::with_params( + "cua.errors.unsupportedPlatform", + format!("CUA driver is not available on {os}; only macOS is implemented."), + serde_json::json!({ "platform": os }), + ) + } + + /// macOS 权限拒绝(辅助功能 / 屏幕录制)。 + pub fn permission_required(permission_key: &str, display: &str) -> Self { + Self::with_params( + "cua.errors.permissionRequired", + format!( + "CUA needs macOS permission ({display}); grant LiveAgent the {permission_key} permission in System Settings → Privacy & Security, then try again." + ), + serde_json::json!({ + "permissionKey": permission_key, + "permission": display, + }), + ) + } + + /// 驱动未执行(前置条件未满足,例如缺少 `cliclick`)。 + pub fn not_executed(detail: &str) -> Self { + Self::with_params( + "cua.errors.notExecuted", + format!("CUA operation was not executed: {detail}."), + serde_json::json!({ "detail": detail }), + ) + } + + /// 子进程 IO 失败(osascript / screencapture 启动失败等)。 + pub fn io(detail: &str) -> Self { + Self::with_params( + "cua.errors.io", + format!("CUA subprocess IO error: {detail}."), + serde_json::json!({ "detail": detail }), + ) + } +} + +impl std::fmt::Display for CuaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for CuaError {} diff --git a/crates/agent-gui/src-tauri/src/services/cua/mod.rs b/crates/agent-gui/src-tauri/src/services/cua/mod.rs new file mode 100644 index 000000000..c30127433 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua/mod.rs @@ -0,0 +1,21 @@ +//! CUA (Computer Use Agent) 驱动:把 macOS 屏幕 / 输入抽象为一组结构化操作, +//! 暴露给 Tauri Command,再由前端 MCP 工具层注入给 Agent。 +//! +//! MVP 范围:窗口枚举 + 截屏 + 点击 + 输入 + 按键 + 滚动 + 拖拽 + 焦点。 +//! 平台支持:macOS 通过 `osascript`(System Events)与 `screencapture` 完成; +//! Windows / Linux 暂以 stub + 明确错误回绝(避免静默失败),后续再补。 +//! +//! 设计原则: +//! - **KISS**:直接走系统自带命令行(osascript / screencapture),不引入 +//! `core-graphics` / `core-foundation` 等原生依赖,编译与体积代价最小。 +//! - **DRY**:状态、审计日志、配额放同一个 `CuaStore`;所有命令统一过 +//! `enforce_enabled + check_allowed` 守卫。 + +pub mod driver; +pub mod error; +pub mod store; + +#[allow(unused_imports)] +pub use driver::{platform_driver, ClickButton, CuaDriver, PlatformError, WindowInfo}; +pub use error::CuaError; +pub use store::{CuaAuditEntry, CuaRuntimeConfig, CuaStore, CuaStoreSnapshot}; \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/cua/store.rs b/crates/agent-gui/src-tauri/src/services/cua/store.rs new file mode 100644 index 000000000..a6d0e5dc6 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua/store.rs @@ -0,0 +1,302 @@ +//! CUA 运行时状态:启用开关、允许的应用名单(白名单 / 黑名单语义)、审计日志。 +//! +//! 设计:纯内存 `Arc>`,启动时由 `lib::run` 装载默认配置。 +//! 持久化走前端 localStorage(与 STT / 托盘偏好同源),保持与项目里 +//! 「桌面端非关键配置走 localStorage,敏感配置走 SQLite」的约定一致。 +//! 审计日志不进 SQLite(成本/收益不匹配),仅在内存保留最近 100 条供 UI +//! 显示「最近操作」。 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; + +use super::error::CuaError; + +/// 编译期确定的平台标签。直接给字符串常量,避免在 trait object 上 +/// 调用 `Self::platform_label()` 这种 Sized-bound 关联函数。 +const fn platform_label_const() -> &'static str { + #[cfg(target_os = "macos")] + { + "macos" + } + #[cfg(target_os = "windows")] + { + "windows" + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + "linux" + } +} + +/// 把 `DateTime` 用 RFC3339 字符串承载,避免引入 chrono 的 `serde` feature。 +mod chrono_iso { + use chrono::{DateTime, Utc}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &DateTime, s: S) -> Result { + s.serialize_str(&value.to_rfc3339()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(serde::de::Error::custom) + } +} + +const AUDIT_LOG_MAX: usize = 100; + +/// 持久化的 CUA 配置。前端写过来什么就原样回写(schema 由前端 TS 类型保证)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaRuntimeConfig { + /// 总开关。`false` 时所有 CUA 命令返回 `Disabled` 错误,前端工具注册表也跳过。 + pub enabled: bool, + /// 白名单:仅当非空时生效;非空时仅允许列表内的 owner 名通过 `focus_window` + /// / 输入 / 点击(截屏 / 列举不走名单以便 Agent 自检)。 + /// owner 匹配用 `equals_ignore_ascii_case`,避免大小写差异漏判。 + #[serde(default)] + pub allowed_owners: Vec, + /// 最大审计日志条数(默认 100;<=0 视作关闭)。 + #[serde(default = "default_audit_limit")] + pub audit_log_limit: usize, +} + +fn default_audit_limit() -> usize { + AUDIT_LOG_MAX +} + +impl Default for CuaRuntimeConfig { + fn default() -> Self { + Self { + enabled: false, + allowed_owners: Vec::new(), + audit_log_limit: AUDIT_LOG_MAX, + } + } +} + +/// 单条审计记录。`ok=false` 时附 `error`(结构化 CuaError, +/// 由前端按 locale 翻译;CUA-006)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaAuditEntry { + #[serde(with = "chrono_iso")] + pub timestamp: DateTime, + pub operation: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaStoreSnapshot { + pub config: CuaRuntimeConfig, + pub platform: &'static str, + pub available: bool, + pub recent: Vec, +} + +#[derive(Debug)] +pub struct CuaStore { + inner: Mutex, +} + +#[derive(Debug)] +struct Inner { + config: CuaRuntimeConfig, + recent: Vec, +} + +impl CuaStore { + pub fn new(config: CuaRuntimeConfig) -> Self { + Self { + inner: Mutex::new(Inner { + config, + recent: Vec::new(), + }), + } + } + + pub fn snapshot(&self) -> CuaStoreSnapshot { + let inner = self.inner.lock().expect("cua store poisoned"); + CuaStoreSnapshot { + config: inner.config.clone(), + platform: platform_label_const(), + available: inner.config.enabled, + recent: inner.recent.clone(), + } + } + + pub fn replace_config(&self, config: CuaRuntimeConfig) { + let mut inner = self.inner.lock().expect("cua store poisoned"); + inner.config = config; + } + + /// 命令执行前调用:返回 ok 或对应的结构化拒绝原因。 + /// - 未启用 → `CuaError::disabled()` + /// - 有白名单且 owner 不在表里 → `CuaError::denied_by_allowlist(...)` + pub fn enforce( + &self, + op: &str, + owner: Option<&str>, + ) -> Result<(), CuaError> { + let inner = self.inner.lock().expect("cua store poisoned"); + if !inner.config.enabled { + return Err(CuaError::disabled()); + } + let allowed_owners = &inner.config.allowed_owners; + if !allowed_owners.is_empty() { + let target = match owner { + Some(o) => o.trim(), + None => "", // 操作没指定 owner,但名单非空 → 拒绝 + }; + if target.is_empty() + || !allowed_owners + .iter() + .any(|a| a.eq_ignore_ascii_case(target)) + { + let _ = op; + return Err(CuaError::denied_by_allowlist(target, allowed_owners)); + } + } + Ok(()) + } + + /// 把审计条目写入内存。若用户在前端把 `audit_log_limit` 设为 0 + /// (即关闭审计),本方法跳过 push——尊重 UI 「0 = disable」 + /// 文案(CUA-005 修复)。`audit_log_limit > 1000` 截到 1000。 + pub fn record(&self, entry: CuaAuditEntry) { + let mut inner = self.inner.lock().expect("cua store poisoned"); + let limit = inner.config.audit_log_limit.min(1000); + if limit == 0 { + // 用户禁用审计:直接退出,不写入任何条目。 + return; + } + inner.recent.push(entry); + if inner.recent.len() > limit { + let drop_n = inner.recent.len() - limit; + inner.recent.drain(0..drop_n); + } + } + + pub fn clear_audit(&self) { + let mut inner = self.inner.lock().expect("cua store poisoned"); + inner.recent.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_config(audit_log_limit: usize, enabled: bool) -> CuaRuntimeConfig { + CuaRuntimeConfig { + enabled, + allowed_owners: Vec::new(), + audit_log_limit, + } + } + + fn fake_entry(op: &str) -> CuaAuditEntry { + CuaAuditEntry { + timestamp: Utc::now(), + operation: op.to_string(), + ok: true, + error: None, + detail: None, + } + } + + /// CUA-005:当 audit_log_limit = 0(用户在 UI 中点 "0 = disable"), + /// store 应完全跳过 push,确保 recent 始终为空。后续改成 50 时正常 + /// 记录并裁剪到 50 条。 + #[test] + fn audit_log_zero_means_disabled() { + let cfg_zero = fake_config(0, false); + let store = CuaStore::new(cfg_zero); + for i in 0..5 { + store.record(fake_entry(&format!("op{i}"))); + } + assert!( + store.snapshot().recent.is_empty(), + "audit_log_limit=0 must drop every entry" + ); + + let mut next_cfg = store.snapshot().config.clone(); + next_cfg.audit_log_limit = 50; + next_cfg.enabled = true; + store.replace_config(next_cfg); + + for i in 0..3 { + store.record(fake_entry(&format!("op{i}"))); + } + let snap = store.snapshot(); + assert_eq!(snap.recent.len(), 3); + assert_eq!(snap.recent[0].operation, "op0"); + assert_eq!(snap.recent[2].operation, "op2"); + } + + /// CUA-005 附带:limit=50 时正常裁剪到 50 条。 + #[test] + fn audit_log_respects_positive_limit() { + let store = CuaStore::new(fake_config(50, false)); + for i in 0..80 { + store.record(fake_entry(&format!("op{i}"))); + } + let snap = store.snapshot(); + assert_eq!(snap.recent.len(), 50); + // 应丢弃最早的 30 条,剩下 op30..op79。 + assert_eq!(snap.recent[0].operation, "op30"); + assert_eq!(snap.recent[49].operation, "op79"); + } + + /// CUA-005 附带:limit 巨大值(>1000)也会被夹到 1000 上限。 + #[test] + fn audit_log_caps_huge_limit_at_1000() { + let store = CuaStore::new(fake_config(usize::MAX, false)); + for i in 0..1500 { + store.record(fake_entry(&format!("op{i}"))); + } + let snap = store.snapshot(); + assert_eq!(snap.recent.len(), 1000); + } + + /// CUA-006:enforce 返回的禁用错误是结构化 CuaError(kind 稳定、 + /// message 是英文),不再泄露中文硬编码字符串。 + #[test] + fn enforce_returns_structured_error_when_disabled() { + let store = CuaStore::new(fake_config(100, false)); + let err = store + .enforce("list_windows", None) + .expect_err("must reject when disabled"); + assert_eq!(err.kind, "cua.errors.disabled"); + assert!(err.message.contains("CUA"), "fallback message usable: {}", err.message); + assert!(!err.message.contains('你'), "no Chinese leaks: {}", err.message); + } + + /// CUA-006:denied_by_allowlist 携带 target + allowed 参数。 + #[test] + fn enforce_returns_structured_denied_by_allowlist() { + let mut cfg = fake_config(100, true); + cfg.allowed_owners = vec!["Finder".into()]; + let store = CuaStore::new(cfg); + let err = store + .enforce("click", Some("Safari")) + .expect_err("must reject Safari"); + assert_eq!(err.kind, "cua.errors.deniedByAllowlist"); + assert_eq!( + err.params + .get("target") + .and_then(|v| v.as_str()) + .map(str::to_owned), + Some("Safari".into()) + ); + assert!(!err.message.contains('你')); + } +} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/mod.rs b/crates/agent-gui/src-tauri/src/services/mod.rs index bb39f9755..17cd52128 100644 --- a/crates/agent-gui/src-tauri/src/services/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod automation; pub mod chat_run_ledger; +pub mod cua; pub mod gateway; pub mod gateway_bridge; pub mod memory; diff --git a/crates/agent-gui/src-tauri/tauri.conf.json b/crates/agent-gui/src-tauri/tauri.conf.json index ea86b2977..9bfb7c1d8 100644 --- a/crates/agent-gui/src-tauri/tauri.conf.json +++ b/crates/agent-gui/src-tauri/tauri.conf.json @@ -19,6 +19,7 @@ "minWidth": 1200, "minHeight": 720, "visible": false, + "focus": true, "backgroundThrottling": "disabled" } ], From eaf8f8e5b1afd66c59a7625c28228f0ea7e1a3b1 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 17:33:44 +0800 Subject: [PATCH 02/28] =?UTF-8?q?feat(cua):=20agent-gui=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20MCP=20tool=20=E6=B3=A8=E5=86=8C=E4=B8=8E=20cuaServi?= =?UTF-8?q?ce=20=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lib/cua/cuaService.ts:Tauri invoke 桥接层(desktopCuaService),统一错误归一化(normalizeCuaError) - 新增 lib/tools/cuaTools.ts:9 个 builtin tool(cua_list_windows / focus_window / screenshot / click / double_click / type / key / scroll / drag),screenshot 返回多模态 ImageContent - builtinRegistry 挂载 cua groupId,仅在用户设置 cua.enabled === true 时注册 - runAgentConversationTurn / useSendChatTurn 透传 getLocale,cua tool 错误按当前 locale 渲染 - i18n/config.ts 新增 settings.cua.* 文案(zh-CN / en-US) - MacOsTitleBarSpacer 加 data-testid='open-settings' + aria-label - App.tsx 监听 overlay 路由切换,自动调 cua_refresh_a11y - vite.config.ts:collectionsNodeResolvePath 支持跨目录 unplugin-icons 解析 - 新增 test/cua/format-cua-error.test.mjs --- crates/agent-gui/src/App.tsx | 29 ++ .../agent-ui-adapters/settingsExtension.tsx | 7 +- .../src/components/MacOsTitleBarSpacer.tsx | 2 + crates/agent-gui/src/i18n/config.ts | 58 +++ crates/agent-gui/src/lib/cua/cuaService.ts | 38 ++ crates/agent-gui/src/lib/settings/storage.ts | 15 +- .../src/lib/tools/builtinRegistry.ts | 12 + crates/agent-gui/src/lib/tools/cuaTools.ts | 442 ++++++++++++++++++ .../src/pages/chat/runtime/useSendChatTurn.ts | 2 + .../chat/turns/runAgentConversationTurn.ts | 9 + crates/agent-gui/src/pages/settings/types.ts | 4 + .../test/cua/format-cua-error.test.mjs | 91 ++++ crates/agent-gui/vite.config.ts | 38 +- 13 files changed, 738 insertions(+), 9 deletions(-) create mode 100644 crates/agent-gui/src/lib/cua/cuaService.ts create mode 100644 crates/agent-gui/src/lib/tools/cuaTools.ts create mode 100644 crates/agent-gui/test/cua/format-cua-error.test.mjs diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 402b49b03..394ff4e69 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -27,6 +27,7 @@ import { AppBootShell } from "./components/app/AppBootShell"; import { useNativeInputContextMenu } from "./components/input-context-menu/NativeInputContextMenu"; import { WindowsTitleBar } from "./components/WindowsTitleBar"; import { useAppUpdateController } from "./lib/appUpdates"; +import { desktopCuaService } from "./lib/cua/cuaService"; import { type AppSettings, getDefaultSettings, @@ -513,6 +514,33 @@ export default function App() { const closeSettings = closeSettingsOverlay; + // CUA-021/022: Settings overlay 打开/关闭后会切换一棵新 React 子树, + // WKWebView 的 AX walker 会先短暂失效再重建。让前端在过渡结束、覆 + // 盖层真正可见之后主动 `cua_refresh_a11y` 一次,让 Rust 把 + // NSWindow/WKWebView 的 a11y 注解重新广播一遍——cua-driver 下一帧 + // `get_window_state` 不会再 `ax_window_unresolved`。`cua_window_ready` + // 是入口前置命令,这个是在它之后的「再补一刀」。 + useEffect(() => { + if (!settingsReady) return; + // overlay 状态切到 open/closed 后再触发,避开 React 渲染抖动。 + if (overlay !== "open" && overlay !== "closed") return; + let cancelled = false; + // 给 AppKit 50 ms 让它把当前 frame 的 AX 提交到 WindowServer—— + // 立即调用经常仍然拿到前一帧的缓存。 + const timer = window.setTimeout(() => { + if (cancelled) return; + invoke<{ wk_webview_found: boolean; responder_granted: boolean }>("cua_refresh_a11y").catch( + (error: unknown) => { + console.warn("cua_refresh_a11y failed", error); + }, + ); + }, 50); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [overlay, settingsReady]); + // 动作总线(Rust `app:action`)中 App 拥有的动作:主题/打开设置/网关开关/ // 检查更新,以及「新建对话」时先收起设置覆盖层(会话侧由 ChatPage 处理)。 const closeSettingsRef = useRef(closeSettings); @@ -744,6 +772,7 @@ export default function App() { sttSettingsService={desktopSttSettingsService} onSttProviderChange={setSttProviderOverride} reloadSettings={reloadPersistedSettings} + cuaService={desktopCuaService} /> diff --git a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx index 043ba2d4b..3752a36a4 100644 --- a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx @@ -1,16 +1,20 @@ import { Archive, Info, Keyboard } from "@liveagent/ui/components/IconSet"; import type { SettingsSectionDefinition, UiExtensionSlots } from "@liveagent/ui/contracts/registry"; import { isMacOsTauri, MacOsTitleBarSpacer } from "../components/MacOsTitleBarSpacer"; +import { desktopCuaService } from "../lib/cua/cuaService"; import { AboutSection } from "../pages/settings/AboutSection"; import { BackupSyncSection } from "../pages/settings/BackupSyncSection"; import { GlobalShortcutsSection } from "../pages/settings/GlobalShortcutsSection"; import type { SettingsPageProps } from "../pages/settings/types"; -export function createSettingsExtension(props: SettingsPageProps): { +export function createSettingsExtension( + props: SettingsPageProps, +): { surface: "desktop"; iconClassName: string; slots: UiExtensionSlots; sections: SettingsSectionDefinition[]; + cuaService: SettingsPageProps["cuaService"]; } { const { settings, setSettings, appUpdate, reloadSettings } = props; return { @@ -59,5 +63,6 @@ export function createSettingsExtension(props: SettingsPageProps): { ), }, ], + cuaService: props.cuaService ?? desktopCuaService, }; } diff --git a/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx b/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx index 373d1a549..80e9298ef 100644 --- a/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx +++ b/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx @@ -141,6 +141,8 @@ export function MacOsTitleBarToggle({ + + + + + {loadError ? ( +
+ {loadError} +
+ ) : null} + {persisting ? ( +
{t("settings.cua.saving")}
+ ) : null} + + ); +} \ No newline at end of file diff --git a/crates/agent-ui/src/pages/settings/SettingsPage.tsx b/crates/agent-ui/src/pages/settings/SettingsPage.tsx index 6ce52057a..80defe170 100644 --- a/crates/agent-ui/src/pages/settings/SettingsPage.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsPage.tsx @@ -7,6 +7,7 @@ import { Clock3, Cloud, Cpu, + Hand, Key, Mic, Settings2, @@ -17,6 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { SettingsSectionDefinition, UiExtensionRegistry } from "../../contracts/registry"; import { AgentsSection } from "./AgentsSection"; import { CronSection } from "./CronSection"; +import { CuaSection } from "./CuaSection"; import { HooksSection } from "./HooksSection"; import { MemoryPanel } from "./memory/MemoryPanel"; import { ProvidersSection } from "./ProvidersSection"; @@ -44,6 +46,7 @@ export function SettingsPage(props: SettingsPageProps) { hiddenSections = [], sttSettingsService, onSttProviderChange, + cuaService, } = props; const [pendingProviderId, setPendingProviderId] = useState(initialProviderId); const [sttSelectedProvider, setSttSelectedProvider] = useState( @@ -190,6 +193,27 @@ export function SettingsPage(props: SettingsPageProps) { icon: , render: () => , }, + ...(cuaService + ? [ + { + id: "cua", + groupKey: "settings.groupConnectivity", + groupOrder: 40, + order: 30, + labelKey: "settings.navCua", + icon: , + render: () => ( + + setSettings((prev) => ({ ...prev, cua: updater(prev.cua) })) + } + cuaService={cuaService} + /> + ), + }, + ] + : []), ...extension.sections, ], [ diff --git a/crates/agent-ui/src/pages/settings/SettingsShell.tsx b/crates/agent-ui/src/pages/settings/SettingsShell.tsx index 18a4ba598..a7578a276 100644 --- a/crates/agent-ui/src/pages/settings/SettingsShell.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsShell.tsx @@ -156,6 +156,9 @@ export function SettingsShell(props: SettingsShellProps) { key={definition.id} type="button" onClick={() => setSection(definition.id)} + data-testid={`settings-nav-${definition.id}`} + data-settings-nav-id={definition.id} + data-active={active ? "true" : "false"} className={cn( "settings-nav-item group relative flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-all duration-150", active From 88ba04a0af80e423c59f19d758d6f24c082322c4 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 17:34:02 +0800 Subject: [PATCH 04/28] =?UTF-8?q?chore(dev):=20dev-prepare.mjs=20=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E6=B8=85=E7=90=86=20+=20dev-stack.mjs=20CUA=20?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=B0=B1=E7=BB=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/dev-prepare.mjs: - dismissMacProblemReporterDialogs:osascript AXCloseButton + pkill 双路径,SIGTERM->SIGKILL 升级 - killStaleLiveagentProcesses:读 dev-stack 状态文件,豁免 in-flight PID,只杀孤儿 stale - --watch 模式周期 sweep(7s)新出现的 Problem Reporter - probeScreenCaptureReadiness:cua-driver get_desktop_state 主动探测,TCC 授权 + daemon 重启恢复 - LIVEAGENT_CUA_DRIVER_NO_OVERLAY=1:检测到 overlay daemons 时用 serve --no-overlay 重启 - dev-stack.mjs desktop 分支:绝对路径 spawn crates/agent-gui,workdir 修复 ~icons 解析;--watch 后台 sweep;start 前先调 dev-prepare --- scripts/dev-prepare.mjs | 830 ++++++++++++++++++++++++++++++++++++++++ scripts/dev-stack.mjs | 166 +++++++- 2 files changed, 981 insertions(+), 15 deletions(-) create mode 100755 scripts/dev-prepare.mjs diff --git a/scripts/dev-prepare.mjs b/scripts/dev-prepare.mjs new file mode 100755 index 000000000..825661c93 --- /dev/null +++ b/scripts/dev-prepare.mjs @@ -0,0 +1,830 @@ +#!/usr/bin/env node + +// dev-prepare.mjs — idempotent dev-environment cleanup so cua-driver can +// reliably drive the LiveAgent desktop window. Failure modes addressed: +// +// 1. Stale liveagent PIDs from a previous crash keep holding the focus or +// keeping Problem Reporter dialogs alive. We kill any "liveagent" PID +// not registered by the current dev-stack instance (CUA-013 — read the +// dev-stack state files to avoid killing the running dev-stack + +// binary the test harness is actively using). +// 2. macOS Problem Reporter dialogs (" 的问题报告" / "Problem +// Report for ") have no AX surface and steal every click/keystroke +// for themselves. cua-driver's `bring_to_front` cannot bypass them, so +// the dev build underneath never receives any input. We close them. +// 3. cua-driver spawns an always-on-top overlay window to visualise its +// agent cursor. The overlay sits above every ordinary window in +// `frontmost_ordinary_window` so LiveAgent can never win that slot, +// which is what cua-driver uses to verify foreground delivery. We +// shrink + off-screen the overlay via `cua-driver set_window_frame` +// instead of killing it (CUA-012 — set_agent_cursor_enabled false +// hides the cursor glyph but keeps the overlay window alive, which is +// why disabling the cursor alone does not help). +// +// Designed to be a no-op when nothing needs cleaning. Safe to invoke from +// dev-stack start, restart, and (manually) before running the test harness. + +import { execFile, execFileSync, spawn, spawnSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { unlink as unlinkAsync, readFile as readFileAsync } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { platform } from "node:process"; +import { inflateSync } from "node:zlib"; + +const OWN_BUNDLE_FRAGMENT = "liveagent"; +const CUA_OVERLAY_BUNDLE = "cua driver"; +const CUA_DRIVER_BINARY = process.env.LIVEAGENT_CUA_DRIVER_BIN ?? "cua-driver"; +// Off-screen anchor that macOS still considers the window "live" so the +// session can still use the overlay if the driver ever flips it back on. +// -10000 / -10000 keeps the window out of any reasonable display layout. +const CUA_OVERLAY_OFFSCREEN = { x: -10000, y: -10000, width: 1, height: 1 }; + +function log(stage, message) { + console.log(`dev-prepare[${stage}]: ${message}`); +} + +function runOsascript(script) { + try { + return execFileSync("osascript", ["-e", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +// ───────── Managed PID discovery (CUA-013) ───────── + +function defaultDevStackStateDirs() { + // Mirror dev-stack.mjs `stateDir` default: $TMPDIR/liveagent-dev-stack-. + const envOverride = process.env.LIVEAGENT_DEV_STATE_DIR; + if (envOverride) return [envOverride]; + const uid = + typeof process.getuid === "function" + ? String(process.getuid()) + : (process.env.USERNAME ?? "user"); + return [join(tmpdir(), `liveagent-dev-stack-${uid}`)]; +} + +function readManagedPids() { + const pids = new Set(); + for (const dir of defaultDevStackStateDirs()) { + let entries; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const path = join(dir, entry); + try { + const state = JSON.parse(readFileSync(path, "utf8")); + if (Number.isInteger(state.pid)) pids.add(state.pid); + if (Number.isInteger(state.childPid)) pids.add(state.childPid); + } catch { + // Stale / unreadable state file → ignore. + } + } + } + // Also exempt any ancestor of this process — Claude / zsh / test + // harness might be the supervisor's parent. `process.ppid` is `0` on + // Windows / unsupported platforms; the call to `kill -0` further down + // is what gates this list. + return pids; +} + +function listOwnPids() { + if (platform !== "darwin" && platform !== "linux") return new Set(); + try { + const stdout = execFileSync("pgrep", ["-f", OWN_BUNDLE_FRAGMENT], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return new Set( + stdout + .split(/\r?\n/) + .map((line) => Number.parseInt(line, 10)) + .filter((pid) => Number.isInteger(pid)), + ); + } catch { + return new Set(); + } +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error && typeof error === "object" && error.code === "EPERM") return false; + return false; + } +} + +function listProblemReporterPids() { + if (platform !== "darwin") return []; + try { + const stdout = execFileSync("pgrep", ["-f", "Problem Reporter"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return stdout + .split(/\r?\n/) + .map((line) => Number.parseInt(line, 10)) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } +} + +function killPid(pid, signal) { + try { + process.kill(pid, signal); + return true; + } catch (error) { + if (error && typeof error === "object" && error.code === "EPERM") return false; + return false; + } +} + +async function killStaleLiveagentProcesses() { + const self = process.pid; + const groupId = process.pgid; + // CUA-013: read dev-stack state files so we never kill a process that + // the test harness / supervisor is actively using. Without this filter + // any invocation of `node scripts/dev-prepare.mjs` would terminate the + // dev binary + supervisor launched by the parent `dev-stack start`. + const managedPids = readManagedPids(); + const ownPids = listOwnPids(); + const targets = [...ownPids].filter((pid) => { + if (pid === self || pid === groupId || pid <= 1) return false; + if (managedPids.has(pid)) return false; + // Defensive: skip PIDs that respond to kill -0 but are themselves + // ancestors of this process tree (the supervisor's child has the + // dev-stack's heartbeat state; its grandchild does not, but it + // usually shares the same parent chain). + return true; + }); + if (targets.length === 0) { + log("stale-liveagent", "no stale liveagent pid(s) to kill"); + return 0; + } + + // Filter the kill list once more against live processes — `pgrep -f` + // can race with processes that have just exited. We must check + // `kill -0` to avoid surfacing ESRCH errors for PIDs that were already + // gone before our second pass. + const liveTargets = targets.filter(processIsAlive); + if (liveTargets.length === 0) return 0; + + log( + "stale-liveagent", + `killing ${liveTargets.length} stale pid(s): ${liveTargets.join(", ")}`, + ); + let killed = 0; + for (const pid of liveTargets) { + if (killPid(pid, "SIGTERM")) killed += 1; + } + // Wait briefly for graceful exit; escalate if needed. + await new Promise((resolveWait) => setTimeout(resolveWait, 750)); + for (const pid of liveTargets) { + if (processIsAlive(pid)) killPid(pid, "SIGKILL"); + } + return killed; +} + +function dismissMacProblemReporterDialogs() { + if (platform !== "darwin") return 0; + const pids = listProblemReporterPids(); + if (pids.length === 0) { + // Even when pgrep misses them, try a window-based probe. + const windows = runOsascript( + 'tell application "System Events" to get name of every window of (every process whose name contains "Problem Reporter")', + ); + if (!windows) return 0; + } else { + log( + "problem-reporter", + `found ${pids.length} stale Problem Reporter pid(s): ${pids.join(", ")}`, + ); + } + + // The dialog is owned by ReportCrash; closing its windows via System Events + // is the only reliable way that does not require Accessibility permission + // for cua-driver to bypass. Killing the helper PID is harmless — macOS + // re-spawns it on demand. + const dismissed = runOsascript( + [ + 'tell application "System Events"', + "\ttell (every process whose name contains \"Problem Reporter\")", + "\t\tset winList to every window", + "\t\tif (count of winList) is 0 then return 0", + "\t\trepeat with w in winList", + '\t\ttry', + '\t\t\tset value of attribute "AXCloseButton" of w to true', + "\t\tend try", + "\t\tend repeat", + "\t\treturn (count of winList)", + "\tend tell", + "end tell", + ].join("\n"), + ); + const count = Number.parseInt(dismissed, 10); + if (Number.isFinite(count) && count > 0) { + log("problem-reporter", `requested close on ${count} Problem Reporter window(s)`); + } + + // CUA-016: also SIGTERM/SIGKILL the helper PIDs directly. macOS will + // respawn ReportCrash on demand, but a stubborn PID that survived + // AXCloseButton (e.g. dialog already showing, AX not yet granted) can + // be released by sending SIGTERM first, then escalating to SIGKILL if + // it still owns a CGWindowID 750 ms later. + for (const pid of pids) { + if (killPid(pid, "SIGTERM")) { + log("problem-reporter", `sent SIGTERM to Problem Reporter pid ${pid}`); + } + } + // Escalate survivors so cua-driver's bring_to_front can claim + // frontmost_ordinary_window within one tick. + const survivors = pids.filter((pid) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }); + for (const pid of survivors) { + if (killPid(pid, "SIGKILL")) { + log("problem-reporter", `sent SIGKILL to Problem Reporter pid ${pid}`); + } + } + return pids.length; +} + +// ───────── CUA driver overlay detection (CUA-012) ───────── + +function runCuaDriverCall(tool, args, timeoutMs = 4000) { + // `cua-driver call ` returns a JSON payload; we forward + // stdin via argv. Failures are non-fatal — overlay pinning is best + // effort and the daemon might not be running on every dev box. + try { + const result = spawnSync(CUA_DRIVER_BINARY, ["call", tool, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }); + if (result.error) return null; + const stdout = (result.stdout ?? "").trim(); + if (!stdout) return null; + try { + return JSON.parse(stdout); + } catch { + return { raw: stdout }; + } + } catch { + return null; + } +} + +function identifyOverlayWindows(windows) { + if (!Array.isArray(windows)) return []; + return windows.filter((window) => { + if (!window || typeof window !== "object") return false; + const appName = typeof window.app_name === "string" ? window.app_name : ""; + const title = typeof window.title === "string" ? window.title : ""; + const bundle = appName.toLowerCase(); + // CUA-012: the daemon's agent-cursor overlay is owned by a process + // named like `Cua Driver` and never has a window title (the overlay + // is purely a presentation surface, not a real document window). + // We also require `is_on_screen` so we don't move hidden helpers. + if (!bundle.includes(CUA_OVERLAY_BUNDLE)) return false; + if (title !== "") return false; + if (window.is_on_screen === false) return false; + return true; + }); +} + +function readCuaDriverDaemon() { + // Returns the live daemon's pid + arguments, or null when no daemon is + // running. Used to detect whether the user launched cua-driver without + // `--no-overlay` and is therefore blocking LiveAgent from becoming + // frontmost_ordinary (CUA-012). + try { + const stdout = execFileSync("pgrep", ["-f", "cua-driver serve"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const pids = stdout + .split(/\r?\n/) + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((pid) => Number.isInteger(pid)); + for (const pid of pids) { + try { + const argsResult = execFileSync("ps", ["-p", String(pid), "-o", "args="], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const args = argsResult.trim(); + if (args.includes("cua-driver") && args.includes("serve")) { + return { pid, args, noOverlay: args.includes("--no-overlay") }; + } + } catch { + // process already exited + } + } + } catch { + return null; + } + return null; +} + +function restartCuaDriverWithNoOverlay() { + if (platform !== "darwin") return false; + // Opt-in: skip unless the user explicitly enabled it. Restarting the + // daemon terminates any active cua-driver session running on the box. + if (process.env.LIVEAGENT_CUA_DRIVER_NO_OVERLAY !== "1") return false; + const daemon = readCuaDriverDaemon(); + if (!daemon) { + log("cua-overlay", "no live cua-driver daemon; nothing to restart"); + return false; + } + if (daemon.noOverlay) { + log("cua-overlay", `daemon pid=${daemon.pid} already launched with --no-overlay`); + return false; + } + log( + "cua-overlay", + `daemon pid=${daemon.pid} is missing --no-overlay; restarting (LIVEAGENT_CUA_DRIVER_NO_OVERLAY=1)`, + ); + // Ask the daemon to shut down cleanly first so it can flush state. + runCuaDriverCall("end_session", ["{}"]); + spawnSync(CUA_DRIVER_BINARY, ["stop"], { + encoding: "utf8", + stdio: ["ignore", "ignore", "ignore"], + timeout: 4000, + }); + // SIGTERM fallback in case `stop` was a no-op (some daemon versions + // refuse if they think a session is still active). + try { + process.kill(daemon.pid, "SIGTERM"); + } catch { + // already gone + } + // Give the daemon a moment to release the unix socket. + const binary = daemon.args.split(/\s+/)[0]; + const detached = spawn( + binary || "/Applications/CuaDriver.app/Contents/MacOS/cua-driver", + ["serve", "--no-overlay"], + { + detached: true, + stdio: "ignore", + env: process.env, + }, + ); + detached.unref(); + log( + "cua-overlay", + `restarted cua-driver with --no-overlay (new pid=${detached.pid ?? "unknown"})`, + ); + return true; +} + +function pinCuaDriverOverlay() { + if (platform !== "darwin") return 0; + const payload = runCuaDriverCall("list_windows", []); + if (!payload) { + log("cua-overlay", "cua-driver list_windows unavailable; overlay pinning skipped"); + return 0; + } + const overlays = identifyOverlayWindows(payload.windows); + if (overlays.length === 0) return 0; + let pinned = 0; + for (const overlay of overlays) { + const pid = overlay.pid; + const windowId = overlay.window_id; + if (!Number.isInteger(pid) || !Number.isInteger(windowId)) continue; + // cua-driver refuses set_window_frame for windows with no AXWindow + // (overlay windows are exactly that). The driver round-trip keeps + // an audit trail even when the call fails — it is the path cua-driver + // users are expected to use. + const args = [ + JSON.stringify({ + height: CUA_OVERLAY_OFFSCREEN.height, + pid, + width: CUA_OVERLAY_OFFSCREEN.width, + window_id: windowId, + x: CUA_OVERLAY_OFFSCREEN.x, + y: CUA_OVERLAY_OFFSCREEN.y, + }), + ]; + const result = runCuaDriverCall("set_window_frame", args); + if (result !== null && !String(result.raw ?? "").includes("no matching AXWindow")) { + log( + "cua-overlay", + `pinned overlay pid=${pid} window_id=${windowId} to ${CUA_OVERLAY_OFFSCREEN.x},${CUA_OVERLAY_OFFSCREEN.y}`, + ); + pinned += 1; + } else { + log( + "cua-overlay", + `set_window_frame refused for pid=${pid} window_id=${windowId} (overlay has no AXWindow); set LIVEAGENT_CUA_DRIVER_NO_OVERLAY=1 to restart the daemon without the overlay`, + ); + } + } + return pinned; +} + +function activateLiveAgentIfRunning() { + if (platform !== "darwin") return false; + const liveagentPids = (() => { + try { + return execFileSync("pgrep", ["-x", "LiveAgent"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + .split(/\r?\n/) + .map((line) => Number.parseInt(line, 10)) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } + })(); + if (liveagentPids.length === 0) return false; + // Best-effort: ask the OS to bring it forward. This is a no-op when a + // Problem Reporter dialog still owns focus — that is exactly why + // dismissMacProblemReporterDialogs() must run first. + execFile("osascript", ["-e", 'tell application "LiveAgent" to activate'], () => {}); + return true; +} + +// ───────── CUA-018 / CUA-023: screen capture readiness probe ───────── + +// `cua-driver health_report` deliberately skips the live ScreenCaptureKit +// probe because it would raise system prompts (the diagnostic surface must +// stay read-only). The downstream cost is that `get_desktop_state` may +// quietly return all-black PNGs even when TCC grants look healthy +// (ScreenCaptureKit can still be blocked by Tahoe's direct-capture consent +// or a stale daemon identity). dev-prepare actively re-asserts capture by +// capturing once and confirming the pixels are not all (0,0,0,255); the +// fast path samples 16 evenly-spaced pixels so the check costs <50 ms and +// never blocks the dev startup beyond that. +// +// CUA-023: when the probe comes back all-black we no longer just log and +// give up. We (a) re-issue `cua-driver permissions grant` so the user gets +// a fresh system prompt if the previous one was dismissed, (b) restart the +// daemon with --no-overlay so a stale identity is replaced, and (c) probe +// once more before failing loud. The retry ladder is bounded so a hostile +// TCC state cannot hang dev startup indefinitely. +async function captureReadinessProbeOnce() { + if (platform !== "darwin") return "skip"; + const tmp = join(tmpdir(), `liveagent-screen-probe-${process.pid}-${Date.now()}.png`); + let payload; + try { + payload = runCuaDriverCall( + "get_desktop_state", + [JSON.stringify({ screenshot_out_file: tmp })], + 5000, + ); + } catch { + return "error"; + } + let pcmBytes; + try { + pcmBytes = await readFileAsync(tmp); + } catch { + pcmBytes = null; + } + let verdict = "unknown"; + try { + await unlinkAsync(tmp); + } catch {} + if (!pcmBytes || pcmBytes.length === 0) { + if (payload && typeof payload.screenshot_png_b64 === "string") { + pcmBytes = Buffer.from(payload.screenshot_png_b64, "base64"); + } + } + if (!pcmBytes || pcmBytes.length === 0) { + verdict = "no-pixels"; + } else { + verdict = inspectPngForBlackness(pcmBytes); + } + return verdict; +} + +async function requestCuaDriverPermissionsGrant() { + if (platform !== "darwin") return false; + // `cua-driver permissions grant` re-launches the helper app via + // LaunchServices so the TCC prompt attributes to com.trycua.driver + // rather than the terminal — this is the correct way to recover from + // a stale TCC grant. The call may itself trigger macOS system prompts; + // we run it detached so it doesn't block dev startup. + log("screen-capture", "retrying: cua-driver permissions grant (re-prompt TCC)"); + try { + const result = spawnSync( + CUA_DRIVER_BINARY, + ["permissions", "grant"], + { + stdio: "ignore", + windowsHide: true, + timeout: 15_000, + env: process.env, + }, + ); + if (result.error) { + log( + "screen-capture", + `permissions grant failed to launch: ${result.error.message}`, + ); + return false; + } + return result.status === 0; + } catch (error) { + log( + "screen-capture", + `permissions grant crashed: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } +} + +async function restartCuaDriverDaemonForCapture() { + if (platform !== "darwin") return false; + const daemon = readCuaDriverDaemon(); + if (!daemon) { + log("screen-capture", "no live cua-driver daemon; cannot restart for capture"); + return false; + } + log( + "screen-capture", + `restarting cua-driver daemon (pid ${daemon.pid}) — stale identity may have lost direct-capture consent (CUA-023)`, + ); + // Best-effort stop; if the daemon ignores it we fall back to SIGTERM. + runCuaDriverCall("end_session", ["{}"]); + spawnSync(CUA_DRIVER_BINARY, ["stop"], { + stdio: "ignore", + windowsHide: true, + timeout: 4_000, + }); + try { + process.kill(daemon.pid, "SIGTERM"); + } catch {} + await new Promise((resolveWait) => setTimeout(resolveWait, 750)); + const binary = daemon.args.split(/\s+/)[0]; + const args = daemon.noOverlay + ? ["serve"] + : ["serve", "--no-overlay"]; + const detached = spawn( + binary || "/Applications/CuaDriver.app/Contents/MacOS/cua-driver", + args, + { + detached: true, + stdio: "ignore", + env: process.env, + }, + ); + detached.unref(); + await new Promise((resolveWait) => setTimeout(resolveWait, 1500)); + return true; +} + +async function probeScreenCaptureReadiness() { + if (platform !== "darwin") return "skip"; + + let verdict = await captureReadinessProbeOnce(); + + // Fast path: capture works on the very first try. Done. + if (verdict === "non-black") { + log("screen-capture", "screen capture readiness: pass (non-black pixels detected)"); + return verdict; + } + + // CUA-023 ladder. We attempt `permissions grant` first (least invasive: + // it only re-prompts for TCC) and only escalate to a daemon restart if + // the user dismissed or already granted and capture is still broken. + if (verdict === "all-black" || verdict === "no-pixels" || verdict === "unknown") { + log( + "screen-capture", + `first probe returned '${verdict}'; entering CUA-023 retry ladder`, + ); + const grantOk = await requestCuaDriverPermissionsGrant(); + if (grantOk) { + // Give macOS a beat to swap the TCC identity into the helper. + await new Promise((resolveWait) => setTimeout(resolveWait, 1000)); + verdict = await captureReadinessProbeOnce(); + if (verdict === "non-black") { + log( + "screen-capture", + "screen capture readiness: pass after permissions grant retry", + ); + return verdict; + } + } + + // Still unhappy → suspect a stale daemon identity (TCC granted to + // an older build). Restart the daemon and re-probe. + const restarted = await restartCuaDriverDaemonForCapture(); + if (restarted) { + verdict = await captureReadinessProbeOnce(); + if (verdict === "non-black") { + log( + "screen-capture", + "screen capture readiness: pass after cua-driver daemon restart", + ); + return verdict; + } + } + } + + // Exhausted ladder. Emit a structured error so the user knows what + // to try manually; do not silently fail. + if (verdict === "all-black") { + log( + "screen-capture", + "screen-capture: ERROR — get_desktop_state still returns all-black after permissions grant + daemon restart. Open System Settings → Privacy & Security → Screen Recording and ensure the CuaDriver helper (com.trycua.driver) is toggled ON, then re-run dev-prepare. (CUA-023)", + ); + } else if (verdict === "no-pixels") { + log( + "screen-capture", + "screen-capture: ERROR — cua-driver daemon did not produce a PNG after retry; check `cua-driver status` and ensure the helper is running with --no-overlay when LIVEAGENT_CUA_DRIVER_NO_OVERLAY=1. (CUA-023)", + ); + } else if (verdict === "unknown") { + log( + "screen-capture", + `screen-capture: ERROR — probe could not classify the PNG after retry (verdict='${verdict}'); capture may still work for real calls but the readiness probe gave up. (CUA-023)`, + ); + } else { + log("screen-capture", `screen capture readiness: ${verdict}`); + } + return verdict; +} + +// Inspect a PNG buffer for the all-black failure mode without decoding +// the whole image. We read the IHDR + IDAT chunks once, decompress with +// Node's zlib, and sample 16 evenly-spaced rows/columns from the raw scan +// data. The check is intentionally cheap — a single 1920x1080 PNG +// decompresses in <30 ms. +function inspectPngForBlackness(buffer) { + // Minimal PNG parser. We assume the encoder used by cua-driver + // (color_type=2 RGB or 6 RGBA, bit_depth=8, no interlace). Any other + // layout (palette, 16-bit, alpha-only) returns "unknown" so we never + // false-positive on a malformed input. + if (buffer.length < 24) return "unknown"; + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < signature.length; i += 1) { + if (buffer[i] !== signature[i]) return "unknown"; + } + let offset = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idatChunks = []; + while (offset + 12 <= buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const dataStart = offset + 8; + if (type === "IHDR" && dataStart + 13 <= buffer.length) { + width = buffer.readUInt32BE(dataStart); + height = buffer.readUInt32BE(dataStart + 4); + bitDepth = buffer[dataStart + 8]; + colorType = buffer[dataStart + 9]; + } else if (type === "IDAT") { + idatChunks.push(buffer.subarray(dataStart, dataStart + length)); + } else if (type === "IEND") { + break; + } + offset = dataStart + length + 4; // 4 bytes CRC + if (offset > buffer.length) break; + } + if (width === 0 || height === 0) return "unknown"; + if (bitDepth !== 8) return "unknown"; + // 0=gray, 2=RGB, 3=palette, 4=gray+alpha, 6=RGBA + const channels = colorType === 2 ? 3 : colorType === 6 ? 4 : 0; + if (channels === 0) return "unknown"; + let raw; + try { + raw = inflateSync(Buffer.concat(idatChunks)); + } catch { + return "unknown"; + } + const stride = width * channels; + const scanlineBytes = stride + 1; // 1 filter byte per row + if (raw.length < scanlineBytes) return "unknown"; + const sampleRows = [0, Math.floor(height / 4), Math.floor(height / 2), height - 1]; + const sampleCols = [0, Math.floor(width / 4), Math.floor(width / 2), width - 1]; + let blackPixels = 0; + let totalSamples = 0; + // All filters other than "None" (0) require us to undo previous pixels. + // For a pass/fail sanity check we only need to spot ANY non-black pixel, + // so we read the raw byte stream and ignore filter deltas. A non-black + // pixel in a filtered stream still shows as a non-zero byte somewhere + // in its scanline — false positives ("non-black" verdict) are + // acceptable; false negatives ("all-black" verdict) are not, so we + // default the verdict to "non-black" whenever we see any non-zero byte + // in the raw stream to keep the check sensitive. + for (const row of sampleRows) { + const rowStart = row * scanlineBytes + 1; // skip filter byte + for (const col of sampleCols) { + const pixelOffset = rowStart + col * channels; + if (pixelOffset + channels > raw.length) continue; + totalSamples += 1; + let isBlack = true; + // For RGB(A), black means RGB == 0; alpha is ignored on purpose + // (a fully-transparent black would still be a healthy capture). + for (let c = 0; c < 3 && c < channels; c += 1) { + if (raw[pixelOffset + c] !== 0) { + isBlack = false; + break; + } + } + if (isBlack) blackPixels += 1; + } + } + if (totalSamples === 0) return "unknown"; + return blackPixels === totalSamples ? "all-black" : "non-black"; +} + +// ───────── CUA-016: periodic Problem Reporter sweep ───────── + +async function runWatchLoop(intervalMs) { + // Re-run the lightweight sweep every `intervalMs`. The first cycle is + // a fresh full pass (so a stale dialog that survived the initial sweep + // is caught on the next tick). The sweep itself is idempotent and a + // no-op when nothing is pending. + log( + "watch", + `starting periodic Problem Reporter sweep every ${intervalMs}ms (pid ${process.pid})`, + ); + let stopping = false; + const stop = () => { + if (stopping) return; + stopping = true; + log("watch", "stopping periodic sweep"); + process.exit(0); + }; + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + // Probe screen capture once on watcher boot so an unhappy TCC state + // surfaces in `dev-stack logs desktop` immediately. + await probeScreenCaptureReadiness(); + // Keep the event loop alive via the interval itself. The watcher stays + // running until SIGTERM/SIGINT triggers `stop()`. We do NOT unref() the + // interval because the supervisor expects the watcher process to stay + // alive for the entire dev session. + setInterval(() => { + if (stopping) return; + try { + const dismissed = dismissMacProblemReporterDialogs(); + if (dismissed > 0) { + log("watch", `sweep dismissed ${dismissed} Problem Reporter window(s)`); + } + } catch (error) { + log("watch", `sweep failed: ${error instanceof Error ? error.message : String(error)}`); + } + }, intervalMs); + // Park forever; signals drive exit. setImmediate ensures the signal + // handlers above are registered before we yield the main thread. + await new Promise(() => {}); +} + +async function main() { + const args = process.argv.slice(2); + const watchIdx = args.indexOf("--watch"); + const watchMode = watchIdx >= 0; + const intervalArg = watchIdx >= 0 ? args[watchIdx + 1] : undefined; + const intervalMs = (() => { + const parsed = Number.parseInt(intervalArg ?? "", 10); + return Number.isFinite(parsed) && parsed >= 1000 ? parsed : 7000; + })(); + + const killed = await killStaleLiveagentProcesses(); + const dismissed = dismissMacProblemReporterDialogs(); + const restarted = restartCuaDriverWithNoOverlay(); + const pinned = pinCuaDriverOverlay(); + const activated = activateLiveAgentIfRunning(); + if (watchMode) { + log( + "summary", + `killed=${killed} dismissed_problem_reporter=${dismissed} restarted_cua_daemon=${restarted} pinned_cua_overlay=${pinned} activated_liveagent=${activated} (watch mode)`, + ); + await runWatchLoop(intervalMs); + return; + } + // One-shot mode still runs an opportunistic screen capture probe so the + // warning lands in dev-stack's startup log when TCC is broken (CUA-018). + const screenCapture = await probeScreenCaptureReadiness(); + log( + "summary", + `killed=${killed} dismissed_problem_reporter=${dismissed} restarted_cua_daemon=${restarted} pinned_cua_overlay=${pinned} activated_liveagent=${activated} screen_capture=${screenCapture}`, + ); +} + +main().catch((error) => { + console.error(`dev-prepare: ${error instanceof Error ? error.message : String(error)}`); + // Never fail the caller: cleanup is best-effort. + process.exit(0); +}); \ No newline at end of file diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs index 733c2e3bf..27c7c11cd 100755 --- a/scripts/dev-stack.mjs +++ b/scripts/dev-stack.mjs @@ -21,6 +21,22 @@ import { join, resolve } from "node:path"; const scriptPath = resolve(import.meta.filename); const repoRoot = resolve(import.meta.dirname, ".."); +const agentGuiDir = join(repoRoot, "crates/agent-gui"); +const devPrepareScript = resolve(import.meta.dirname, "dev-prepare.mjs"); + +// Detect whether `mise` is on PATH once at startup. The CLI uses mise to +// pin Node/pnpm/go versions; on machines without mise we fall back to +// direct binary invocation. `LIVEAGENT_DEV_MISE` overrides detection so +// the test harness can force either path. +function detectMise() { + const override = process.env.LIVEAGENT_DEV_MISE; + if (override === "0" || override === "false") return null; + if (override) return override; + const found = spawnSync("which", ["mise"], { encoding: "utf8", shell: false }); + if (found.status === 0 && found.stdout.trim()) return "mise"; + return null; +} +const miseBinary = detectMise(); const userKey = typeof process.getuid === "function" ? String(process.getuid()) : (process.env.USERNAME ?? "user"); const stateDir = resolve( @@ -171,8 +187,10 @@ function serviceCommand(service) { ensureWebUiEmbedStub(); mkdirSync(gatewayDataDir, { recursive: true }); return { - args: ["exec", "--", "go", "-C", "crates/agent-gateway", "run", "./cmd/gateway"], - command: "mise", + // `mise exec -- go …` resolves the toolchain from .mise.toml; without + // mise we fall back to a plain `go` invocation on PATH. + directArgs: ["go", "-C", "crates/agent-gateway", "run", "./cmd/gateway"], + miseArgs: ["exec", "--", "go", "-C", "crates/agent-gateway", "run", "./cmd/gateway"], cwd: repoRoot, env: { ...process.env, @@ -185,7 +203,16 @@ function serviceCommand(service) { } if (service === "webui") { return { - args: [ + directArgs: [ + "node", + "node_modules/vite/bin/vite.js", + "--host", + "localhost", + "--port", + String(ports.webui), + "--strictPort", + ], + miseArgs: [ "exec", "--", "node", @@ -196,15 +223,18 @@ function serviceCommand(service) { String(ports.webui), "--strictPort", ], - command: "mise", cwd: join(repoRoot, "crates/agent-gateway/web"), env: { ...process.env, npm_config_proxy_api: urls.gateway }, }; } return { - args: ["exec", "--", "pnpm", "--dir", "crates/agent-gui", "tauri", "dev"], - command: "mise", - cwd: repoRoot, + directArgs: ["pnpm", "--dir", agentGuiDir, "tauri", "dev"], + miseArgs: ["exec", "--", "pnpm", "--dir", agentGuiDir, "tauri", "dev"], + // CUA-008: Vite must start from `crates/agent-gui` so unplugin-icons + // can resolve @iconify-json/* via the workspace's node_modules. Starting + // from the repo root leaves ~icons/* un-resolvable and the SPA falls + // back to the ErrorOverlay, which then becomes invisible to cua-driver. + cwd: agentGuiDir, env: { ...process.env, VITE_LIVEAGENT_SESSION_WORKBENCH: process.env.DEV_SESSION_WORKBENCH ?? "1", @@ -212,17 +242,101 @@ function serviceCommand(service) { }; } -async function runService(service, token) { - process.title = `liveagent-dev-stack-${service}`; - const command = serviceCommand(service); - mkdirSync(join(stateDir, "go-cache"), { recursive: true }); - const child = spawn(command.command, command.args, { - cwd: command.cwd, - env: command.env, +function runDevPrepare() { + try { + const result = spawnSync(process.execPath, [devPrepareScript], { + encoding: "utf8", + stdio: ["ignore", "inherit", "inherit"], + windowsHide: true, + }); + if (result.error) { + console.error(`dev-stack: dev-prepare failed: ${result.error.message}`); + } + } catch (error) { + console.error( + `dev-stack: dev-prepare crashed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +// CUA-016: dev binary crashes that happen AFTER startup can spawn fresh +// Problem Reporter dialogs that the one-shot dev-prepare sweep misses. +// Spawn dev-prepare in --watch mode as a detached supervisor so a stale +// dialog that appears 30 s after launch still gets cleaned up. The +// watcher is owned by the desktop state directory and dies with the +// dev-stack session. +function startProblemReporterWatcher() { + // CUA-016 is only meaningful on macOS — Problem Reporter is a + // macOS-specific ReportCrash helper. No-op on every other platform. + if (process.platform !== "darwin") return null; + mkdirSync(stateDir, { recursive: true }); + // Default sweep cadence: 7 s. Short enough that a freshly-spawned + // dialog is dismissed within one tick, long enough that the sweep + // doesn't fight ReportCrash's own respawn loop. + const intervalMs = process.env.LIVEAGENT_PROBLEM_REPORTER_SWEEP_MS ?? "7000"; + const logFile = openSync(logPath("problem-reporter-watcher"), "a"); + const watcher = spawn( + process.execPath, + [devPrepareScript, "--watch", intervalMs], + { + detached: true, + env: process.env, + shell: false, + stdio: ["ignore", logFile, logFile], + windowsHide: true, + }, + ); + closeSync(logFile); + if (!watcher.pid) { + console.error("dev-stack: failed to start Problem Reporter watcher"); + return null; + } + watcher.unref(); + console.log( + `dev-stack: Problem Reporter watcher started (pid ${watcher.pid}, sweep every ${intervalMs}ms, log ${logPath("problem-reporter-watcher")})`, + ); + return watcher.pid; +} + +function stopProblemReporterWatcher() { + if (process.platform !== "darwin") return; + // pkill -f matches the full argv; --watch is unique enough to avoid + // hitting dev-prepare's other invocations (which are short-lived). + try { + spawnSync("pkill", ["-f", `${devPrepareScript}.*--watch`], { + stdio: "ignore", + windowsHide: true, + }); + } catch {} +} + +/// `mise exec` pins tool versions for the dev stack. When mise is not on +/// PATH (CI sandboxes, minimal local installs) we fall back to running the +/// underlying binary directly. The mapping here mirrors `serviceCommand`. +function spawnService(commandSpec) { + if (miseBinary) { + return spawn(miseBinary, commandSpec.miseArgs, { + cwd: commandSpec.cwd, + env: commandSpec.env, + shell: false, + stdio: "inherit", + windowsHide: true, + }); + } + return spawn(commandSpec.directArgs[0], commandSpec.directArgs.slice(1), { + cwd: commandSpec.cwd, + env: commandSpec.env, shell: false, stdio: "inherit", windowsHide: true, }); +} + +async function runService(service, token) { + process.title = `liveagent-dev-stack-${service}`; + const command = serviceCommand(service); + mkdirSync(join(stateDir, "go-cache"), { recursive: true }); + const child = spawnService(command); const startedAt = Date.now(); const updateHeartbeat = () => writeState({ @@ -250,7 +364,7 @@ async function runService(service, token) { const exitCode = await new Promise((resolveExitCode) => { child.once("error", (error) => { - console.error(`Failed to start ${command.command}: ${error.message}`); + console.error(`Failed to start ${command.miseArgs[0] ?? "service"}: ${error.message}`); resolveExitCode(127); }); child.once("exit", (code) => resolveExitCode(code ?? 1)); @@ -304,6 +418,23 @@ async function waitUntilReady(service, timeoutSeconds) { } async function startService(service) { + // CUA-009: keep macOS desktop clean before the dev build opens a + // window. Stale liveagent PIDs and Problem Reporter dialogs both keep + // frontmost_ordinary_window off the LiveAgent window, which prevents + // cua-driver from delivering input. Cleanup is idempotent and a no-op + // when the desktop is already in shape. + if (service === "desktop") { + runDevPrepare(); + // CUA-016: a freshly-crashed dev binary can spawn a new Problem + // Reporter AFTER the one-shot sweep above; the periodic watcher + // keeps cleaning up so the dev window can win frontmost_ordinary. + // The watcher is idempotent with other startService invocations + // because `pkill` in stopService cleans up any prior instance and + // pgrep ignores zombie watchers. We start it for both fresh and + // already-running cases — multiple sweeps per dialog are harmless. + startProblemReporterWatcher(); + } + const state = managedState(service); if (state) { if (!stateIsFresh(state)) { @@ -380,6 +511,11 @@ async function stopService(service) { return false; } removeState(service, state.token); + // CUA-016: the Problem Reporter watcher is only spawned for the + // desktop service. Stopping it here guarantees it doesn't outlive the + // dev stack when the user runs `dev-stack stop desktop`. The watcher + // also dies with the desktop supervisor, so this is belt-and-braces. + if (service === "desktop") stopProblemReporterWatcher(); console.log(`${service}: stopped`); return true; } From 9d2514cb4edc9dd75e84dd5c1b7f6427768fdbc2 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 17:49:55 +0800 Subject: [PATCH 05/28] =?UTF-8?q?fix(cua):=20=E9=80=82=E9=85=8D=20WebUI=20?= =?UTF-8?q?=E7=BC=96=E8=AF=91=20+=20Biome=20lint=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crates/agent-gateway/web/src/pages/settings/types.ts:SectionId 加 'cua';SettingsPageProps 加 cuaService?: never(WebUI 不提供 cuaService,条件渲染跳过 CUA 段) - agent-gui + agent-ui + gateway-webui 全量 Biome check --write:sort imports / useMemo deps / 格式统一 --- .../web/src/pages/settings/types.ts | 5 +- .../agent-ui-adapters/settingsExtension.tsx | 4 +- crates/agent-gui/src/i18n/config.ts | 3 +- crates/agent-gui/src/lib/cua/cuaService.ts | 2 +- crates/agent-gui/src/lib/settings/storage.ts | 4 +- .../src/lib/tools/builtinRegistry.ts | 6 +-- crates/agent-gui/src/lib/tools/cuaTools.ts | 37 ++++++++------- crates/agent-gui/src/pages/settings/types.ts | 2 +- .../src/i18n/translations/enUSSettings.ts | 2 +- .../src/i18n/translations/zhCNSettings.ts | 3 +- crates/agent-ui/src/lib/settings/index.ts | 2 +- .../src/pages/settings/CuaSection.tsx | 46 ++++--------------- 12 files changed, 44 insertions(+), 72 deletions(-) diff --git a/crates/agent-gateway/web/src/pages/settings/types.ts b/crates/agent-gateway/web/src/pages/settings/types.ts index d04298452..4c0bc79b8 100644 --- a/crates/agent-gateway/web/src/pages/settings/types.ts +++ b/crates/agent-gateway/web/src/pages/settings/types.ts @@ -15,7 +15,8 @@ export type SectionId = | "hooks" | "cron" | "devices" - | "remote"; + | "remote" + | "cua"; export type SettingsPageProps = { settings: AppSettings; @@ -29,6 +30,8 @@ export type SettingsPageProps = { sttSettingsService: SttSettingsService; /** 临时切换语音输入运行供应商,不触发配置保存。 */ onSttProviderChange?: (provider: SttProviderId) => void; + /** CUA 后端桥;WebUI 不提供(无 cuaService 时 SettingsPage 不渲染 CUA 段)。 */ + cuaService?: never; }; export type SettingsSectionProps = { diff --git a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx index 3752a36a4..2fe899f07 100644 --- a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx @@ -7,9 +7,7 @@ import { BackupSyncSection } from "../pages/settings/BackupSyncSection"; import { GlobalShortcutsSection } from "../pages/settings/GlobalShortcutsSection"; import type { SettingsPageProps } from "../pages/settings/types"; -export function createSettingsExtension( - props: SettingsPageProps, -): { +export function createSettingsExtension(props: SettingsPageProps): { surface: "desktop"; iconClassName: string; slots: UiExtensionSlots; diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 04ba298e0..c47129b1d 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -544,7 +544,8 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.enableDescUnsupported": "CUA is not implemented for this platform yet; turning it on will fail every operation.", "settings.cua.platform": "Driver platform", - "settings.cua.platformDesc": "Selected at build time by the Rust target (macOS / Windows / Linux).", + "settings.cua.platformDesc": + "Selected at build time by the Rust target (macOS / Windows / Linux).", "settings.cua.platformNotes.macos.accessibility": "Accessibility — System Events drives mouse / keyboard", "settings.cua.platformNotes.macos.screenRecording": diff --git a/crates/agent-gui/src/lib/cua/cuaService.ts b/crates/agent-gui/src/lib/cua/cuaService.ts index 3043b5cef..b4261d16e 100644 --- a/crates/agent-gui/src/lib/cua/cuaService.ts +++ b/crates/agent-gui/src/lib/cua/cuaService.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import type { CuaService, CuaSettings, CuaStatus } from "@liveagent/ui/pages/settings/CuaSection"; +import { invoke } from "@tauri-apps/api/core"; /** * 桌面端到 Rust `cua_*` Tauri Command 的桥。设置面板与 Settings 扩展 diff --git a/crates/agent-gui/src/lib/settings/storage.ts b/crates/agent-gui/src/lib/settings/storage.ts index 43ca15fb0..5dbb9ecf3 100644 --- a/crates/agent-gui/src/lib/settings/storage.ts +++ b/crates/agent-gui/src/lib/settings/storage.ts @@ -166,9 +166,7 @@ function readLocalUiSettings(): { closeWindowBehavior: normalizeCloseWindowBehavior( parsed?.closeWindowBehavior ?? defaults.closeWindowBehavior, ), - cua: parsed?.cua - ? normalizeCuaSettings(parsed.cua) - : defaults.cua, + cua: parsed?.cua ? normalizeCuaSettings(parsed.cua) : defaults.cua, }; } catch { return { diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 085765d50..f2ed0a670 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -24,11 +24,11 @@ import type { BuiltinToolMetadata, } from "./builtinTypes"; import { createCronTools } from "./cronTools"; +import { createCuaTools } from "./cuaTools"; import { createFileToolState, type FileToolState } from "./fileToolState"; import { createFsTools } from "./fsTools"; import { createMcpManagerTools } from "./mcpManagerTools"; import { createMcpTools } from "./mcpTools"; -import { createCuaTools } from "./cuaTools"; import { createMemoryTools } from "./memoryTools"; import { createExitPlanModeTools, isPlanModeAllowedTool } from "./planModeTools"; import { createShellTools, type ShellSandboxSettings } from "./shellTools"; @@ -297,9 +297,7 @@ async function buildBaseBuiltinToolBundles( }), ] : []), - ...(params.cuaEnabled - ? [createCuaTools({ getLocale: params.getLocale })] - : []), + ...(params.cuaEnabled ? [createCuaTools({ getLocale: params.getLocale })] : []), ]; const enabledServers = selectEnabledMcpServers(params.getMcpSettings()); diff --git a/crates/agent-gui/src/lib/tools/cuaTools.ts b/crates/agent-gui/src/lib/tools/cuaTools.ts index d8f2596f1..f4f9fad16 100644 --- a/crates/agent-gui/src/lib/tools/cuaTools.ts +++ b/crates/agent-gui/src/lib/tools/cuaTools.ts @@ -5,12 +5,9 @@ import type { ToolCall, ToolResultMessage, } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { DEFAULT_LOCALE, type Locale, t } from "@liveagent/app/i18n/config"; -import { - formatUnknownCuaError, - type CuaErrorPayload, -} from "@liveagent/ui/lib/cua/formatCuaError"; +import { type CuaErrorPayload, formatUnknownCuaError } from "@liveagent/ui/lib/cua/formatCuaError"; +import { invoke } from "@tauri-apps/api/core"; import type { BuiltinToolBundle, BuiltinToolMetadata } from "./builtinTypes"; import { createBuiltinMetadataMap } from "./builtinTypes"; @@ -116,8 +113,7 @@ function jsonResult(toolCall: ToolCall, payload: T, summary: string): ToolRes export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinToolBundle { const resolveLocale = (): Locale => params?.getLocale ? safeReadLocale(params.getLocale) : DEFAULT_LOCALE; - const formatError = (input: unknown): string => - formatUnknownCuaError(input, t, resolveLocale()); + const formatError = (input: unknown): string => formatUnknownCuaError(input, t, resolveLocale()); const formatOpError = (payload: CuaErrorPayload | undefined): string => { if (payload) { return formatUnknownCuaError(payload, t, resolveLocale()); @@ -126,10 +122,16 @@ export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinTo }; const metadataEntries: Array<[string, BuiltinToolMetadata]> = [ ["cua_list_windows", { groupId: "cua", kind: "cua", isReadOnly: true, displayCategory: "cua" }], - ["cua_focus_window", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], + [ + "cua_focus_window", + { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }, + ], ["cua_screenshot", { groupId: "cua", kind: "cua", isReadOnly: true, displayCategory: "cua" }], ["cua_click", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], - ["cua_double_click", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], + [ + "cua_double_click", + { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }, + ], ["cua_type", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], ["cua_key", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], ["cua_scroll", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], @@ -212,8 +214,7 @@ export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinTo }, { name: "cua_type", - description: - "[CUA] 模拟键盘输入字符串。targetOwner 可选:指定后操作会经后端白名单校验。", + description: "[CUA] 模拟键盘输入字符串。targetOwner 可选:指定后操作会经后端白名单校验。", parameters: normalizeToolParametersSchema( { type: "object", @@ -271,8 +272,7 @@ export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinTo }, { name: "cua_drag", - description: - "[CUA] 拖拽(macOS 上需要 `brew install cliclick`)。从 (x1, y1) 到 (x2, y2)。", + description: "[CUA] 拖拽(macOS 上需要 `brew install cliclick`)。从 (x1, y1) 到 (x2, y2)。", parameters: normalizeToolParametersSchema( { type: "object", @@ -301,7 +301,10 @@ export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinTo case "cua_list_windows": { const wins = await invokeCua("cua_list_windows", {}); if (wins.length === 0) { - return textResult(toolCall, "未发现可见窗口(可能前台应用没有窗口,或缺少屏幕录制权限)。"); + return textResult( + toolCall, + "未发现可见窗口(可能前台应用没有窗口,或缺少屏幕录制权限)。", + ); } return jsonResult(toolCall, wins, `共 ${wins.length} 个可见窗口:`); } @@ -322,11 +325,7 @@ export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinTo const resp = await invokeCua("cua_screenshot", { windowOwner, }); - return imageResult( - toolCall, - resp, - `截图成功:${resp.width}×${resp.height}`, - ); + return imageResult(toolCall, resp, `截图成功:${resp.width}×${resp.height}`); } case "cua_click": { const x = Number(args.x); diff --git a/crates/agent-gui/src/pages/settings/types.ts b/crates/agent-gui/src/pages/settings/types.ts index d85907ed7..d8e27810e 100644 --- a/crates/agent-gui/src/pages/settings/types.ts +++ b/crates/agent-gui/src/pages/settings/types.ts @@ -1,8 +1,8 @@ import type { SttSettingsService } from "@liveagent/ui/lib/stt/types"; +import type { CuaService } from "@liveagent/ui/pages/settings/CuaSection"; import type { AppUpdateController } from "../../lib/appUpdates"; import type { AppSettings, SttProviderId } from "../../lib/settings"; import type { SettingsSaveState } from "../../lib/settings/storage"; -import type { CuaService } from "@liveagent/ui/pages/settings/CuaSection"; export type SetSettingsFn = (updater: (prev: AppSettings) => AppSettings) => void; diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index aa551f276..cd30cf848 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1217,7 +1217,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "cua.errors.disabled": "CUA is not enabled. Open Settings → CUA Driver and turn the master switch on, then retry.", "cua.errors.deniedByAllowlist": - "CUA operation denied: target \"{target}\" is not in the allowlist {allowed}.", + 'CUA operation denied: target "{target}" is not in the allowlist {allowed}.', "cua.errors.unsupportedPlatform": "CUA driver is not available on {platform}; only macOS is implemented right now.", "cua.errors.permissionRequired": diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index e3d1b1f64..a4aff87f4 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1158,7 +1158,8 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.cua.errors.save": "保存 CUA 配置失败", "cua.errors.disabled": "CUA 未启用。请到「设置 → CUA 驱动」打开总开关后再试。", "cua.errors.deniedByAllowlist": "CUA 操作被拒绝:目标「{target}」不在白名单 {allowed} 中。", - "cua.errors.unsupportedPlatform": "CUA 驱动在当前平台({platform})不可用,目前仅在 macOS 上实现。", + "cua.errors.unsupportedPlatform": + "CUA 驱动在当前平台({platform})不可用,目前仅在 macOS 上实现。", "cua.errors.permissionRequired": "CUA 操作需要 macOS 权限({permission})。请到「系统设置 → 隐私与安全」授予 LiveAgent「{permission}」权限后重试。", "cua.errors.notExecuted": "CUA 操作未执行:{detail}", diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index 929395065..3f914c07c 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -63,11 +63,11 @@ import type { AgentPromptTemplate, AppSettings, ChatRuntimeControls, - CuaSettings, ChatRuntimeReasoningProviderKey, CloseWindowBehavior, CodexRequestFormat, CommandSafetyMode, + CuaSettings, CustomProvider, CustomSettings, EffectivePromptSettings, diff --git a/crates/agent-ui/src/pages/settings/CuaSection.tsx b/crates/agent-ui/src/pages/settings/CuaSection.tsx index 3f7186048..aa25e7c15 100644 --- a/crates/agent-ui/src/pages/settings/CuaSection.tsx +++ b/crates/agent-ui/src/pages/settings/CuaSection.tsx @@ -1,23 +1,11 @@ -import { - Eye, - EyeOff, - MonitorSmartphone, - Trash2, -} from "@liveagent/ui/components/IconSet"; +import { Eye, EyeOff, MonitorSmartphone, Trash2 } from "@liveagent/ui/components/IconSet"; import { Input } from "@liveagent/ui/components/ui/input"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { type CuaErrorPayload, formatCuaError } from "@liveagent/ui/lib/cua/formatCuaError"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { errorMessageWithFallback } from "@liveagent/ui/lib/shared/value"; -import { - formatCuaError, - type CuaErrorPayload, -} from "@liveagent/ui/lib/cua/formatCuaError"; -import { useLocale } from "@liveagent/ui/i18n/index"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - AgentActivationSwitch, - SettingsGroup, - SettingsRow, -} from "./shared"; +import { AgentActivationSwitch, SettingsGroup, SettingsRow } from "./shared"; export type CuaSettings = { enabled: boolean; @@ -130,9 +118,7 @@ export function CuaSection(props: CuaSectionProps) { const { t, locale } = useLocale(); const [status, setStatus] = useState(null); const [loadError, setLoadError] = useState(null); - const [ownersDraft, setOwnersDraft] = useState( - joinOwners(settings.allowedOwners), - ); + const [ownersDraft, setOwnersDraft] = useState(joinOwners(settings.allowedOwners)); const [persisting, setPersisting] = useState(false); const lastSyncedSettingsRef = useRef(settings); @@ -217,10 +203,7 @@ export function CuaSection(props: CuaSectionProps) { }, [cuaService]); const auditCount = status?.recent.length ?? 0; - const previewEntries = useMemo( - () => (status?.recent ?? []).slice(-10).reverse(), - [status], - ); + const previewEntries = useMemo(() => (status?.recent ?? []).slice(-10).reverse(), [status]); return (
@@ -321,16 +304,11 @@ export function CuaSection(props: CuaSectionProps) { />
{previewEntries.length === 0 ? ( -

- {t("settings.cua.auditEmpty")} -

+

{t("settings.cua.auditEmpty")}

) : (
    {previewEntries.map((entry, idx) => ( -
  • +
  • - {entry.ok ? ( - - ) : ( - - )} + {entry.ok ? : } {formatTimestamp(entry.timestamp)} @@ -384,4 +358,4 @@ export function CuaSection(props: CuaSectionProps) { ) : null}
); -} \ No newline at end of file +} From 7988c6dd6ba73089937667b0011620393b7dffbc Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 23:18:09 +0800 Subject: [PATCH 06/28] =?UTF-8?q?feat(cua):=20CUA=20Driver=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=B8=8B=E8=BD=BD/=E5=AE=89=E8=A3=85=EF=BC=88CUA-026?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 [cua.ai 官方文档](https://cua.ai/docs/how-to-guides/driver/install) 实现: 后端 (Rust): - services/cua/installer.rs:CUA Driver 检测/安装/更新/daemon 启动逻辑 - 平台分发:macOS/Linux 走 /bin/bash -c 'curl -fsSL https://cua.ai/driver/install.sh | bash -s --';Windows 走 PowerShell irm ... | iex + cua-driver autostart kick - Linux 额外提示 libxi6 + at-spi2-core apt 依赖 - macOS daemon 启动:open -n -g -a CuaDriver --args serve - 30 分钟超时 + 进度事件流(cua_install_progress: starting / downloading / installing / startingDaemon / completed | failed) - services/cua/error.rs:6 个 installer_* 错误变体(network / curl / signature / permission / unsupported / timeout) - commands/cua.rs:5 个新 Tauri Command(cua_driver_detect / install / update / start_daemon / install_preview) - lib.rs:注册到 invoke handler 前端 (agent-ui / agent-gui): - pages/settings/CuaInstaller.ts:CUA Installer 类型 + 服务接口 - pages/settings/CuaInstallerPanel.tsx:610 行 UI——status badge / 检测/安装/重启/检查更新/应用更新按钮 / 进度条 / 折叠 log / macOS 权限卡片(含 x-apple.systempreferences deep link)/ Linux apt 依赖提示 - pages/settings/CuaSection.tsx:挂载 installer 面板 - lib/cua/cuaService.ts:5 个新 Tauri invoke 桥接方法 + 进度订阅 - App.tsx:visibilitychange / focus 事件调 cua_refresh_a11y - i18n:agent-gui/config.ts + enUSSettings / zhCNSettings 新增 ~30 个 settings.cua.installer.* + cua.errors.installer.* 文案 data-testid 锚点:cua-driver-status / install-button / update-button / restart-daemon-button / install-progress / install-log 等 17 个 测试:cargo test 957 pass(4 新 installer 测试)/ pnpm test 2530 pass(6 新 cuaService 契约测试) --- .../agent-gui/src-tauri/src/commands/cua.rs | 118 ++- crates/agent-gui/src-tauri/src/lib.rs | 6 + .../src-tauri/src/services/cua/error.rs | 62 ++ .../src-tauri/src/services/cua/installer.rs | 980 ++++++++++++++++++ .../src-tauri/src/services/cua/mod.rs | 7 + crates/agent-gui/src/App.tsx | 39 + crates/agent-gui/src/i18n/config.ts | 105 ++ crates/agent-gui/src/lib/cua/cuaService.ts | 67 +- .../test/cua-installer-service.test.mjs | 159 +++ .../src/i18n/translations/enUSSettings.ts | 55 + .../src/i18n/translations/zhCNSettings.ts | 52 + .../src/pages/settings/CuaInstaller.ts | 85 ++ .../src/pages/settings/CuaInstallerPanel.tsx | 655 ++++++++++++ .../src/pages/settings/CuaSection.tsx | 21 +- 14 files changed, 2407 insertions(+), 4 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/services/cua/installer.rs create mode 100644 crates/agent-gui/test/cua-installer-service.test.mjs create mode 100644 crates/agent-ui/src/pages/settings/CuaInstaller.ts create mode 100644 crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs index ecbadacf2..6ecafd53b 100644 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -14,11 +14,13 @@ use base64::Engine; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tauri::AppHandle; use tauri::State; use crate::services::cua::{ - store::CuaRuntimeConfig, ClickButton, CuaAuditEntry, CuaError, CuaStore, CuaStoreSnapshot, - PlatformError, WindowInfo, + installer::{CuaDriverDetection, CuaInstallResult, CuaUpdateResult, InstallPreview}, + store::CuaRuntimeConfig, + ClickButton, CuaAuditEntry, CuaError, CuaStore, CuaStoreSnapshot, PlatformError, WindowInfo, }; #[derive(Debug, Serialize)] @@ -378,4 +380,116 @@ pub async fn cua_window_ready( #[tauri::command(rename_all = "snake_case")] pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yResponse { crate::cua_refresh_a11y(&window) +} + +// ───────── CUA Driver 安装器(CUA-100) ───────── +// +// 设计:不经过 CuaStore 的 enable 守卫——安装器本身就是为了让后续 CUA +// 操作可用;enable 留到装完再由用户在 UI 中开启。 + +/// 检测 cua-driver 是否已安装、版本、daemon 状态。无 IO 副作用。 +#[tauri::command(rename_all = "snake_case")] +pub fn cua_driver_detect() -> CuaDriverDetection { + crate::services::cua::detect_driver() +} + +/// 启动 cua-driver daemon(macOS: `open CuaDriver --args serve`)。 +/// 失败时返回结构化 `CuaError`;前端不阻塞在失败上,可重试。 +#[tauri::command(rename_all = "snake_case")] +pub fn cua_driver_start_daemon(app: AppHandle) -> Result { + crate::services::cua::start_driver_daemon(&app) + .map(|started| CuaOpResponse { + ok: started, + error: None, + }) + .map_err(|detail| match std::env::consts::OS { + "macos" | "linux" | "windows" => CuaError::not_executed(&detail), + other => CuaError::installer_unsupported_platform(other), + }) +} + +/// 安装 cua-driver。30 分钟超时;进度经 `cua_install_progress` 事件 +/// 推送。装完自动尝试 start_daemon。 +/// +/// 这里用 `spawn_blocking` 把同步阻塞 IO 移出 Tauri 异步运行时——安装 +/// 脚本最长可能跑几分钟,期间不能让 Tauri runtime 卡住。 +#[tauri::command(rename_all = "snake_case")] +pub async fn cua_driver_install(app: AppHandle) -> Result { + let os = std::env::consts::OS; + // 不支持平台:直接拒绝,不下到 spawn_blocking。 + match os { + "macos" | "linux" | "windows" => {} + other => return Err(CuaError::installer_unsupported_platform(other)), + } + // 探测 install 脚本:网络 / curl 不可用时立刻失败,避免长等待。 + let preflight = tauri::async_runtime::spawn_blocking({ + let app = app.clone(); + move || crate::services::cua::install_driver(&app) + }); + match preflight.await { + Ok(result) => { + if result.success { + Ok(result) + } else { + // 把 installer 文本错误归到最有意义的 kind 上。 + let detail = result.error.unwrap_or_else(|| "unknown".into()); + let lower = detail.to_lowercase(); + let err = if lower.contains("timeout") { + CuaError::installer_timeout(30) + } else if lower.contains("network") + || lower.contains("dns") + || lower.contains("could not resolve") + || lower.contains("connection refused") + { + CuaError::installer_network_unavailable(&detail) + } else if lower.contains("permission") || lower.contains("denied") { + CuaError::installer_permission_denied(&detail) + } else { + CuaError::installer_curl_failed(&detail) + }; + Err(err) + } + } + Err(join_err) => Err(CuaError::io(&format!( + "installer task join error: {join_err}" + ))), + } +} + +/// 检查更新 +(可选)应用。`apply=false` 时只跑 `check-update`。 +#[tauri::command(rename_all = "snake_case")] +pub async fn cua_driver_update(apply: bool) -> Result { + // 同步逻辑交给 blocking pool;install 路径已经示范,这里一致处理。 + let join = tauri::async_runtime::spawn_blocking(move || { + crate::services::cua::update_driver(apply) + }) + .await + .map_err(|e| CuaError::io(&format!("update task join error: {e}")))?; + if let Some(err) = join.error.as_deref() { + // check-update 自身可能因为未安装驱动而失败——但前端应有前置 + // detect 守卫;这里仍把网络 / 通用 IO 区分。 + let lower = err.to_lowercase(); + let mapped = if lower.contains("network") + || lower.contains("dns") + || lower.contains("connection") + { + CuaError::installer_network_unavailable(err) + } else { + CuaError::installer_curl_failed(err) + }; + Err(mapped) + } else { + Ok(join) + } +} + +/// 把 install 命令(program / args)暴露给前端做展示(无 spawn)。主 +/// 要给设置面板「即将运行的命令」用,UI 自行展示。Linux 上额外带 +/// apt 依赖检查结果。 +#[tauri::command(rename_all = "snake_case")] +pub fn cua_driver_install_preview() -> Result { + crate::services::cua::build_install_preview().map_err(|detail| match std::env::consts::OS { + "macos" | "linux" | "windows" => CuaError::not_executed(&detail), + other => CuaError::installer_unsupported_platform(other), + }) } \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 19bed94c7..21b82b87b 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -323,6 +323,12 @@ macro_rules! app_invoke_handler { commands::cua::cua_drag, commands::cua::cua_window_ready, commands::cua::cua_refresh_a11y, + // CUA driver installer (CUA-100 series). + commands::cua::cua_driver_detect, + commands::cua::cua_driver_install, + commands::cua::cua_driver_update, + commands::cua::cua_driver_start_daemon, + commands::cua::cua_driver_install_preview, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, diff --git a/crates/agent-gui/src-tauri/src/services/cua/error.rs b/crates/agent-gui/src-tauri/src/services/cua/error.rs index 3d987b771..ab44b4a65 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/error.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/error.rs @@ -106,6 +106,68 @@ impl CuaError { serde_json::json!({ "detail": detail }), ) } + + // ───────── Installer error variants (CUA-100 series) ───────── + + /// 网络不可用 / DNS 失败 / 离线。 + pub fn installer_network_unavailable(detail: &str) -> Self { + Self::with_params( + "cua.errors.installer.networkUnavailable", + format!( + "CUA driver installer could not reach the network: {detail}. \ + Check your connection and try again." + ), + serde_json::json!({ "detail": detail }), + ) + } + + /// curl / powershell 启动失败。 + pub fn installer_curl_failed(detail: &str) -> Self { + Self::with_params( + "cua.errors.installer.curlFailed", + format!("CUA driver installer download failed: {detail}."), + serde_json::json!({ "detail": detail }), + ) + } + + /// 脚本签名校验失败(占位;目前 install 脚本未签名,留接口备用)。 + pub fn installer_signature_invalid(detail: &str) -> Self { + Self::with_params( + "cua.errors.installer.signatureInvalid", + format!("CUA driver installer signature check failed: {detail}."), + serde_json::json!({ "detail": detail }), + ) + } + + /// 权限不足(典型:用户拒绝 sudo / 文件 ACL)。 + pub fn installer_permission_denied(detail: &str) -> Self { + Self::with_params( + "cua.errors.installer.permissionDenied", + format!("CUA driver installer needs elevated permission: {detail}."), + serde_json::json!({ "detail": detail }), + ) + } + + /// 当前平台不支持 CUA 安装器(理论上 webUI / 不支持 OS 才会触发)。 + pub fn installer_unsupported_platform(os: &str) -> Self { + Self::with_params( + "cua.errors.installer.unsupportedPlatform", + format!("CUA driver installer is not available on {os}."), + serde_json::json!({ "platform": os }), + ) + } + + /// 安装超时。 + pub fn installer_timeout(minutes: u64) -> Self { + Self::with_params( + "cua.errors.installer.timeout", + format!( + "CUA driver installer did not finish within {minutes} minutes. \ + The download may have stalled; please retry." + ), + serde_json::json!({ "minutes": minutes }), + ) + } } impl std::fmt::Display for CuaError { diff --git a/crates/agent-gui/src-tauri/src/services/cua/installer.rs b/crates/agent-gui/src-tauri/src/services/cua/installer.rs new file mode 100644 index 000000000..6f4dcf703 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua/installer.rs @@ -0,0 +1,980 @@ +//! CUA Driver 安装 / 检测 / 更新模块。 +//! +//! 设计目标:把 CUA 官方 install 脚本(`https://cua.ai/driver/install.sh` / +//! `install.ps1`)当成「可信外部脚本」对待——只通过 `Command::new` 直起官方 +//! 提供的安装器,把 stdout / stderr 实时经 Tauri `emit("cua_install_progress")` +//! 回给前端;不在 Rust 内部复刻安装逻辑,避免与官方版本漂移。 +//! +//! 检测:按平台在 PATH / 平台特定目录查找 `cua-driver` / `CuaDriver.app`, +//! 调用 `cua-driver --version` 与 `cua-driver doctor` 探测健康度。 +//! +//! 不在模块内做任何权限校验 / 启用开关——安装器面向「需要驱动但还没装」 +//! 的场景;上层 `cua_driver_*` Command 才是策略边界。 + +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; + +const INSTALL_PROGRESS_EVENT: &str = "cua_install_progress"; +/// 安装脚本最大允许运行时长。官方 `install.sh` 含下载 + 解压 + 自启动 +/// autostart,最坏情况约 3-5 分钟;30 分钟已留足余量。 +const INSTALL_TIMEOUT_SECS: u64 = 30 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstallerStage { + /// 开始:分配任务、准备 spawn。 + Starting, + /// 下载 install 脚本中。 + Downloading, + /// 正在执行安装。 + Installing, + /// 启动 daemon(仅 macOS / Windows)。 + StartingDaemon, + /// 安装 + 启动成功。 + Completed, + /// 失败;详见 error 字段。 + Failed, + /// 用户 / 守护进程主动取消。 + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallerProgressEvent { + pub stage: InstallerStage, + /// 人类可读进度信息(英文;UI 自行翻译)。 + pub message: String, + /// 累积的日志(最近 200 行)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub log_tail: Option, + /// 0..=100 的进度百分比。`None` 表示未知。 + #[serde(skip_serializing_if = "Option::is_none")] + pub percent: Option, +} + +/// 检测结果。安装成功但 daemon 仍未起来时 `installed=true` / +/// `daemon_running=false`,UI 据此给出不同 CTA。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaDriverDetection { + /// 能否在 PATH 或平台特定位置找到 `cua-driver` / `CuaDriver.app`。 + pub installed: bool, + /// `cua-driver --version` 输出解析出的版本字符串。 + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + /// 解析出的可执行文件绝对路径。 + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// 平台标签("macos" / "windows" / "linux")。 + pub platform: &'static str, + /// 是否找到 CuaDriver.app(macOS 专属)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub app_bundle_installed: Option, + /// daemon 进程是否在运行(macOS 下用 `pgrep CuaDriver` 探测)。 + pub daemon_running: bool, + /// `cua-driver doctor` 的完整输出(已截到 8 KB)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub doctor_output: Option, + /// 检测过程中的错误(路径找不到 / --version 失败等),非致命。 + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 安装结果。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaInstallResult { + pub success: bool, + /// 完整日志(已截到 64 KB),即使失败也返回。 + pub log: String, + /// 成功后探测到的 cua-driver 版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_version: Option, + /// 安装后是否自动启动了 daemon。 + pub daemon_started: bool, + /// 失败时的错误描述。 + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 更新结果。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaUpdateResult { + /// 是否检测到新版本。 + pub update_available: bool, + /// 完整 check-update 输出。 + pub log: String, + /// 实际执行 update --apply 后才填。 + #[serde(skip_serializing_if = "Option::is_none")] + pub new_version: Option, + /// 失败时的错误描述。 + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// ───────── 平台特定路径探测 ───────── + +fn platform_label() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } +} + +fn home_dir() -> Option { + // 跨平台 home:`HOME` (Unix) / `USERPROFILE` (Windows) / `HOME` 兜底。 + if let Some(p) = std::env::var_os("HOME").filter(|s| !s.is_empty()) { + return Some(PathBuf::from(p)); + } + if let Some(p) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) { + return Some(PathBuf::from(p)); + } + if let Some(p) = dirs::home_dir() { + return Some(p); + } + None +} + +fn candidate_paths() -> Vec { + let mut out: Vec = Vec::new(); + let home = home_dir(); + match platform_label() { + "macos" => { + out.push(PathBuf::from("/Applications/CuaDriver.app/Contents/MacOS/cua-driver")); + out.push(PathBuf::from("/Applications/CuaDriver.app")); + if let Some(h) = &home { + out.push(h.join(".local/bin/cua-driver")); + } + } + "windows" => { + if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|s| !s.is_empty()) { + let base = PathBuf::from(local); + out.push(base.join("Programs/Cua/cua-driver/bin/cua-driver.exe")); + out.push(base.join("Programs/Cua/cua-driver/bin")); + } + } + _ => { + // linux + if let Some(h) = &home { + out.push(h.join(".cua-driver/packages/releases/cua-driver")); + out.push(h.join(".local/bin/cua-driver")); + } + } + } + out +} + +fn find_cua_driver() -> Option { + // 1) PATH 中:手工 walk env::split_paths(PATH) 找 `cua-driver`。 + if let Some(p) = find_in_path("cua-driver") { + return Some(p); + } + // 2) 平台特定候选路径。 + for candidate in candidate_paths() { + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn find_in_path(binary: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(binary); + if candidate.is_file() { + return Some(candidate); + } + // Windows 走 .exe 后缀。 + #[cfg(target_os = "windows")] + { + let with_exe = dir.join(format!("{binary}.exe")); + if with_exe.is_file() { + return Some(with_exe); + } + } + } + None +} + +// ───────── 版本 / doctor / daemon 探测 ───────── + +fn parse_version(output: &str) -> Option { + // 兼容 "cua-driver 0.3.1" / "0.3.1" / "v0.3.1 (commit abc)"。 + for token in output.split_whitespace() { + let stripped = token.trim_start_matches('v').trim_end_matches(','); + if stripped.is_empty() { + continue; + } + // 至少 vX.Y 形式。 + if stripped + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + && stripped.contains('.') + { + return Some(stripped.to_string()); + } + } + None +} + +fn run_with_timeout(mut cmd: Command, timeout: Duration) -> Result { + // 简单同步 + 上层 `tokio::task::spawn_blocking` 把 IO 移出主线程。 + // 进程级 timeout 用 `wait-timeout` crate(已有依赖)。 + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()) + .spawn() + .map_err(|e| format!("failed to spawn: {e}"))?; + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + if let Some(mut s) = child.stdout.take() { + let _ = std::io::Read::read_to_end(&mut s, &mut stdout); + } + if let Some(mut s) = child.stderr.take() { + let _ = std::io::Read::read_to_end(&mut s, &mut stderr); + } + return Ok(std::process::Output { + status, + stdout, + stderr, + }); + } + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + return Err(format!("timeout after {:?}", timeout)); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(format!("try_wait failed: {e}")), + } + } +} + +fn truncate_log(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + // 保留尾部,便于排错(最新输出更有用)。 + let start = s.len() - max; + let mut idx = start; + while idx < s.len() && !s.is_char_boundary(idx) { + idx += 1; + } + format!("…(truncated {start} bytes)…\n{}", &s[idx..]) +} + +// ───────── 对外 API ───────── + +/// 检测 cua-driver 是否已安装、版本、daemon 状态。 +pub fn detect() -> CuaDriverDetection { + let mut det = CuaDriverDetection { + installed: false, + version: None, + path: None, + platform: platform_label(), + app_bundle_installed: None, + daemon_running: false, + doctor_output: None, + error: None, + }; + + // macOS 专属:探测 .app bundle 是否已安装(与可执行文件分离的状态)。 + #[cfg(target_os = "macos")] + { + det.app_bundle_installed = Some(PathBuf::from("/Applications/CuaDriver.app").exists()); + } + + let Some(bin) = find_cua_driver() else { + det.error = Some("cua-driver not found in PATH or platform-specific locations".into()); + return det; + }; + det.installed = true; + det.path = Some(bin.display().to_string()); + + // --version + let mut version_cmd = Command::new(&bin); + version_cmd.arg("--version"); + match run_with_timeout(version_cmd, Duration::from_secs(5)) { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(v) = parse_version(&stdout) { + det.version = Some(v); + } else { + det.error = Some(format!( + "could not parse version from: {}", + stdout.trim() + )); + } + } + Ok(out) => { + det.error = Some(format!( + "cua-driver --version exited with status {:?}: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Err(e) => { + det.error = Some(e); + } + } + + // doctor(best-effort;失败不影响 installed 判定) + let mut doctor_cmd = Command::new(&bin); + doctor_cmd.arg("doctor"); + if let Ok(out) = run_with_timeout(doctor_cmd, Duration::from_secs(8)) { + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + det.doctor_output = Some(truncate_log(&combined, 8 * 1024)); + } + + // daemon 探测 + det.daemon_running = is_daemon_running(); + + det +} + +pub fn is_daemon_running() -> bool { + // macOS / Linux:尝试 `pgrep -f CuaDriver`。Windows:tasklist。 + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if let Ok(out) = Command::new("pgrep") + .args(["-f", "CuaDriver"]) + .output() + { + if out.status.success() && !out.stdout.is_empty() { + return true; + } + } + // 兜底:cua-driver serve 进程。 + if let Ok(out) = Command::new("pgrep") + .args(["-f", "cua-driver serve"]) + .output() + { + if out.status.success() && !out.stdout.is_empty() { + return true; + } + } + false + } + #[cfg(target_os = "windows")] + { + if let Ok(out) = Command::new("tasklist") + .args(["/FI", "IMAGENAME eq CuaDriver.exe", "/NH"]) + .output() + { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout.contains("CuaDriver.exe"); + } + false + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + false + } +} + +/// 按平台构建安装命令。返回 (program, args, description)——program 是 +/// 实际要 spawn 的二进制(`bash` / `powershell`),args 是参数。 +pub fn build_install_command() -> Result { + let script_url = "https://cua.ai/driver/install.sh"; + let ps_url = "https://cua.ai/driver/install.ps1"; + match platform_label() { + "macos" | "linux" => Ok(InstallCommand { + program: "/bin/bash".to_string(), + args: vec![ + "-c".to_string(), + format!( + "set -e; curl -fsSL {script_url} | bash -s --; rc=$?; echo \"[install.sh exit code: $rc]\"; exit $rc" + ), + ], + description: "macOS / Linux installer".to_string(), + needs_sudo: false, + }), + "windows" => Ok(InstallCommand { + program: "powershell.exe".to_string(), + // PowerShell: `irm | iex` 把脚本下载到内存并立即执行; + // 末尾 `cua-driver autostart kick` 是官方安装器要求的最后一步。 + args: vec![ + "-NoProfile".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-Command".to_string(), + format!("irm {ps_url} | iex; cua-driver autostart kick"), + ], + description: "Windows installer".to_string(), + needs_sudo: false, + }), + other => Err(format!("unsupported platform for CUA driver install: {other}")), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallCommand { + pub program: String, + pub args: Vec, + pub description: String, + /// Linux 上需要 sudo(apt 装依赖);macOS / Windows 上不需要。 + pub needs_sudo: bool, +} + +/// Linux 专属:检测并提示/安装 X11 / AT-SPI 依赖。当前只探测,不自动 sudo。 +/// 返回 (apt_available: bool, missing: Vec<&'static str>)。 +pub fn linux_check_apt_deps() -> (bool, Vec<&'static str>) { + if !cfg!(target_os = "linux") { + return (false, Vec::new()); + } + // 简单探测 dpkg 是否可用。 + let has_dpkg = Command::new("dpkg-query") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if has_dpkg { + let mut missing = Vec::new(); + for pkg in &["libxi6", "at-spi2-core"] { + let ok = Command::new("dpkg-query") + .args(["-W", "-f=${Status}", pkg]) + .output() + .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("install ok")) + .unwrap_or(false); + if !ok { + missing.push(*pkg); + } + } + (true, missing) + } else { + (false, Vec::new()) + } +} + +/// 给前端展示用:把即将执行的 install 命令 + Linux 依赖状态一起打包。 +/// 前端可以把它显示在「即将运行的命令」卡片里,给用户最终确认。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallPreview { + pub platform: &'static str, + pub command: InstallCommand, + /// Linux 专属:`true` 表示系统使用 apt,UI 给出「需先装 libxi6 / at-spi2-core」提示。 + #[serde(skip_serializing_if = "Option::is_none")] + pub linux_apt_available: Option, + /// Linux 专属:缺失的包。空 = 已就绪。 + #[serde(skip_serializing_if = "Option::is_none")] + pub linux_missing_packages: Option>, +} + +pub fn build_install_preview() -> Result { + let command = build_install_command()?; + let mut preview = InstallPreview { + platform: platform_label(), + command, + linux_apt_available: None, + linux_missing_packages: None, + }; + if platform_label() == "linux" { + let (apt, missing) = linux_check_apt_deps(); + preview.linux_apt_available = Some(apt); + preview.linux_missing_packages = Some(missing.into_iter().map(String::from).collect()); + } + Ok(preview) +} + +/// 执行安装。`app` 用于发 progress 事件;返回 `CuaInstallResult`。 +/// 设计:单线程同步阻塞命令跑在调用方(由 `tauri::async_runtime::spawn_blocking` +/// 调度),stdout / stderr 实时 line-by-line 推送 + 累积到 `log`。 +pub fn install(app: &AppHandle) -> CuaInstallResult { + // 起始事件 + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Starting, + message: "Preparing CUA driver installer…".to_string(), + log_tail: None, + percent: Some(0), + }, + ); + + let cmd = match build_install_command() { + Ok(c) => c, + Err(e) => { + return finalize_failure( + app, + "Failed to build installer command", + &e, + String::new(), + ); + } + }; + + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Downloading, + message: format!("Downloading installer from cua.ai ({})", cmd.description), + log_tail: None, + percent: Some(5), + }, + ); + + let mut command = Command::new(&cmd.program); + command + .args(&cmd.args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()); + // 同步阻塞,由 caller 包在 spawn_blocking 里。 + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + return finalize_failure( + app, + "Failed to spawn installer", + &e.to_string(), + String::new(), + ); + } + }; + + let mut log = String::new(); + let mut stage_emitted_installing = false; + let last_emit = std::time::Instant::now(); + let start = std::time::Instant::now(); + + // 同时读 stdout + stderr:用 thread 拼 stdout,sterr 走主循环。 + // MVP:line-alternating,跨流顺序由 OS 决定;用户能跟进度即可。 + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let app_for_thread = app.clone(); + let log_arc = Arc::new(std::sync::Mutex::new(String::new())); + let log_for_thread = Arc::clone(&log_arc); + + if let Some(stdout) = stdout { + let app_t = app_for_thread.clone(); + let log_t = Arc::clone(&log_for_thread); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if let Ok(mut s) = log_t.lock() { + push_line(&mut s, &line); + } + let _ = app_t.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Installing, + message: line.clone(), + log_tail: Some(line), + percent: Some(percent_for(start.elapsed())), + }, + ); + } + }); + } + + if let Some(stderr) = stderr { + let app_t = app_for_thread.clone(); + let log_t = Arc::clone(&log_for_thread); + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if let Ok(mut s) = log_t.lock() { + push_line(&mut s, &format!("[stderr] {line}")); + } + let _ = app_t.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Installing, + message: format!("(stderr) {line}"), + log_tail: Some(format!("[stderr] {line}")), + percent: Some(percent_for(start.elapsed())), + }, + ); + } + }); + } + + // 等待子进程 + 整体 timeout。 + let timeout = Duration::from_secs(INSTALL_TIMEOUT_SECS); + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + break Err(format!( + "install timeout after {} minutes", + INSTALL_TIMEOUT_SECS / 60 + )); + } + if !stage_emitted_installing && last_emit.elapsed() > Duration::from_secs(1) { + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Installing, + message: "Installer is running…".to_string(), + log_tail: None, + percent: Some(20), + }, + ); + stage_emitted_installing = true; + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => break Err(format!("try_wait failed: {e}")), + } + }; + + // 等流读完(最好再多给 500 ms 让 reader 线程退出)。 + std::thread::sleep(Duration::from_millis(500)); + if let Ok(s) = log_arc.lock() { + log = s.clone(); + } + + let exit_status = match exit_status { + Ok(s) => s, + Err(e) => return finalize_failure(app, "Installer did not finish", &e, log), + }; + + if !exit_status.success() { + return finalize_failure( + app, + "Installer exited with non-zero status", + &format!("exit code: {:?}", exit_status.code()), + log, + ); + } + + // 安装成功 → 重新探测版本。 + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::StartingDaemon, + message: "Installer finished. Detecting installed driver…".to_string(), + log_tail: None, + percent: Some(85), + }, + ); + + let detection = detect(); + let installed_version = detection.version.clone(); + + let daemon_started = start_daemon(app).is_ok(); + + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Completed, + message: if daemon_started { + "CUA driver installed and daemon started.".to_string() + } else { + "CUA driver installed. Daemon will be started on first use.".to_string() + }, + log_tail: Some(truncate_log(&log, 4 * 1024)), + percent: Some(100), + }, + ); + + CuaInstallResult { + success: true, + log: truncate_log(&log, 64 * 1024), + installed_version, + daemon_started, + error: None, + } +} + +fn percent_for(elapsed: std::time::Duration) -> u32 { + // 简单线性插值 0..90%(剩下 10% 给 daemon start)。30 分钟硬上限, + // 但大部分安装 < 1 分钟;这里给前 5 分钟跑到 90% 足够。 + let secs = elapsed.as_secs(); + if secs < 5 { + 5 + (secs as u32 * 3) // 5..20% + } else if secs < 30 { + 20 + ((secs - 5) as u32) // 20..45% + } else if secs < 90 { + 45 + ((secs - 30) as u32 / 2) // 45..75% + } else { + 75 + ((secs - 90).min(900) as u32 / 30).min(15) // 75..90% + } +} + +fn push_line(buf: &mut String, line: &str) { + if buf.len() + line.len() + 1 > 64 * 1024 { + // 环形裁剪:丢前一半,避免无限增长。 + let drop = buf.len() / 2; + if let Some(idx) = buf[drop..].find('\n') { + buf.drain(..drop + idx + 1); + } else { + buf.clear(); + } + } + buf.push_str(line); + buf.push('\n'); +} + +fn finalize_failure( + app: &AppHandle, + title: &str, + detail: &str, + log: String, +) -> CuaInstallResult { + let error = format!("{title}: {detail}"); + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::Failed, + message: error.clone(), + log_tail: Some(truncate_log(&log, 4 * 1024)), + percent: Some(0), + }, + ); + CuaInstallResult { + success: false, + log: truncate_log(&log, 64 * 1024), + installed_version: None, + daemon_started: false, + error: Some(error), + } +} + +/// 启动 daemon。macOS: `open -n -g -a CuaDriver --args serve`; +/// Linux: `cua-driver serve &`(一次性 spawn)。Windows: 暂 no-op(依赖 +/// 安装器的 `autostart kick`)。 +pub fn start_daemon(app: &AppHandle) -> Result { + #[cfg(target_os = "macos")] + { + let status = Command::new("/usr/bin/open") + .args(["-n", "-g", "-a", "CuaDriver", "--args", "serve"]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .map_err(|e| format!("open failed: {e}"))?; + if !status.success() { + return Err(format!( + "open exited with status {:?}; is CuaDriver.app installed at /Applications?", + status.code() + )); + } + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallerProgressEvent { + stage: InstallerStage::StartingDaemon, + message: "Started CuaDriver.app (background).".to_string(), + log_tail: None, + percent: Some(95), + }, + ); + return Ok(true); + } + #[cfg(target_os = "linux")] + { + let Some(bin) = find_cua_driver() else { + return Err("cua-driver not found in PATH".to_string()); + }; + // 一次性 spawn detach。 + let _ = Command::new(&bin) + .arg("serve") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .map_err(|e| format!("cua-driver serve spawn failed: {e}"))?; + return Ok(true); + } + #[cfg(target_os = "windows")] + { + // Windows 安装器最后一步 `cua-driver autostart kick` 会把 daemon + // 注册为开机自启;这里仅探测是否已在运行,不主动启动服务。 + let running = is_daemon_running(); + if !running { + return Err( + "Windows: daemon is managed by 'cua-driver autostart kick'. \ + Run it manually if the driver is not running." + .to_string(), + ); + } + return Ok(running); + } + #[allow(unreachable_code)] + { + Err("daemon start not implemented for this platform".to_string()) + } +} + +/// 调用 `cua-driver check-update` 与(可选)`cua-driver update --apply`。 +/// `apply` 为 false 时只检查不更新。 +pub fn update(apply: bool) -> CuaUpdateResult { + let Some(bin) = find_cua_driver() else { + return CuaUpdateResult { + update_available: false, + log: String::new(), + new_version: None, + error: Some("cua-driver not installed".to_string()), + }; + }; + + // Step 1: check-update + let check_out = run_with_timeout( + { + let mut c = Command::new(&bin); + c.arg("check-update"); + c + }, + Duration::from_secs(15), + ); + let check_output = match &check_out { + Ok(o) => format!( + "$ cua-driver check-update\n{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => format!("$ cua-driver check-update\n"), + }; + let update_available = match &check_out { + Ok(o) => o.status.success() && parse_update_available(&o.stdout), + Err(_) => false, + }; + + if !apply || !update_available { + return CuaUpdateResult { + update_available, + log: check_output, + new_version: None, + error: None, + }; + } + + // Step 2: update --apply + let apply_out = run_with_timeout( + { + let mut c = Command::new(&bin); + c.args(["update", "--apply"]); + c + }, + Duration::from_secs(INSTALL_TIMEOUT_SECS), + ); + let apply_output = match &apply_out { + Ok(o) => format!( + "\n$ cua-driver update --apply\n{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => format!("\n$ cua-driver update --apply\n"), + }; + let combined = format!("{check_output}{apply_output}"); + let new_version = if apply_out + .as_ref() + .map(|o| o.status.success()) + .unwrap_or(false) + { + detect().version + } else { + None + }; + let error = match &apply_out { + Err(e) => Some(e.clone()), + Ok(o) if !o.status.success() => Some(format!("exit code: {:?}", o.status.code())), + _ => None, + }; + CuaUpdateResult { + update_available, + log: combined, + new_version, + error, + } +} + +fn parse_update_available(stdout: &[u8]) -> bool { + let s = String::from_utf8_lossy(stdout).to_lowercase(); + // 官方脚本常用 "update available" / "new version" 字符串。 + s.contains("update available") + || s.contains("new version") + || s.contains("a new version is available") + || s.contains("outdated") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_version_handles_common_formats() { + assert_eq!(parse_version("cua-driver 0.3.1"), Some("0.3.1".into())); + assert_eq!(parse_version("v0.3.1 (commit abc)"), Some("0.3.1".into())); + assert_eq!(parse_version("0.10.2"), Some("0.10.2".into())); + assert_eq!(parse_version(""), None); + assert_eq!(parse_version("nothing here"), None); + } + + #[test] + fn platform_label_matches_target() { + let label = platform_label(); + if cfg!(target_os = "macos") { + assert_eq!(label, "macos"); + } else if cfg!(target_os = "windows") { + assert_eq!(label, "windows"); + } else { + assert_eq!(label, "linux"); + } + } + + #[test] + fn build_install_command_always_succeeds_on_supported_platform() { + let cmd = build_install_command(); + if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) { + let cmd = cmd.expect("supported platform"); + assert!(!cmd.program.is_empty()); + assert!(!cmd.args.is_empty()); + } else { + assert!(cmd.is_err()); + } + } + + #[test] + fn truncate_log_keeps_tail() { + let s = "a".repeat(2000); + let t = truncate_log(&s, 100); + assert!(t.len() <= 200); + assert!(t.contains("a")); + } + + #[test] + fn parse_update_available_keywords() { + assert!(parse_update_available(b"update available: 0.4.0")); + assert!(parse_update_available(b"new version: 0.4.0")); + assert!(parse_update_available(b"a new version is available")); + assert!(parse_update_available(b"outdated")); + assert!(!parse_update_available(b"you are up to date")); + } + + #[test] + fn push_line_keeps_recent_lines() { + let mut buf = String::new(); + for i in 0..5000 { + push_line(&mut buf, &format!("line {i} with some content")); + } + // 容量上限 64KB;远小于 5000 * 30 = 150KB。buf 内部已环形裁剪。 + assert!(buf.len() <= 64 * 1024 + 100, "buf {} > 64KB", buf.len()); + assert!(buf.contains("line 4999")); + } +} diff --git a/crates/agent-gui/src-tauri/src/services/cua/mod.rs b/crates/agent-gui/src-tauri/src/services/cua/mod.rs index c30127433..2c347ea2a 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/mod.rs @@ -13,9 +13,16 @@ pub mod driver; pub mod error; +pub mod installer; pub mod store; #[allow(unused_imports)] pub use driver::{platform_driver, ClickButton, CuaDriver, PlatformError, WindowInfo}; pub use error::CuaError; +pub use installer::{ + build_install_command, build_install_preview, detect as detect_driver, install as install_driver, + is_daemon_running, start_daemon as start_driver_daemon, update as update_driver, + CuaDriverDetection, CuaInstallResult, CuaUpdateResult, InstallCommand, InstallPreview, + InstallerProgressEvent, InstallerStage, +}; pub use store::{CuaAuditEntry, CuaRuntimeConfig, CuaStore, CuaStoreSnapshot}; \ No newline at end of file diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 394ff4e69..bfdef59de 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -541,6 +541,45 @@ export default function App() { }; }, [overlay, settingsReady]); + // CUA-031: 对设置面板内元素做 `delivery_mode: foreground` 输入投递后, + // WKWebView 的 a11y surface 会暂时进入 `ax_unresolved` 状态(CUA-021 + // 回归)。监听 visibilitychange / window focus——这类事件触发意味着刚 + // 刚发生过「窗口被另一进程抢焦点又还回来」的来回,AppKit 的 AX walker + // 可能尚未把新子树提交进 WindowServer。重新触发一次 `cua_refresh_a11y` + // 让 Rust 端把 NSWindow 注解 + WKWebView 第一响应 + UIElementCreatedNotification + // 重新广播,cua-driver 下一帧 `get_window_state` 不再 `ax_unresolved`。 + // 用 50 ms 防抖合并短时间内多次可见/聚焦事件,避免在 React 抖动期内 + // 连续打几帧 RPC。 + useEffect(() => { + if (!settingsReady) return; + if (typeof window === "undefined") return; + let debounceTimer: number | null = null; + const trigger = () => { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => { + debounceTimer = null; + invoke<{ wk_webview_found: boolean; responder_granted: boolean }>( + "cua_refresh_a11y", + ).catch((error: unknown) => { + console.warn("cua_refresh_a11y failed", error); + }); + }, 50); + }; + const onVisibility = () => { + if (document.visibilityState === "visible") trigger(); + }; + const onFocus = () => trigger(); + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("focus", onFocus); + return () => { + if (debounceTimer !== null) window.clearTimeout(debounceTimer); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("focus", onFocus); + }; + }, [settingsReady]); + // 动作总线(Rust `app:action`)中 App 拥有的动作:主题/打开设置/网关开关/ // 检查更新,以及「新建对话」时先收起设置覆盖层(会话侧由 ChatPage 处理)。 const closeSettingsRef = useRef(closeSettings); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index c47129b1d..28b03333a 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -277,6 +277,57 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.saving": "正在同步 CUA 配置…", "settings.cua.errors.fetch": "无法读取 CUA 状态", "settings.cua.errors.save": "保存 CUA 配置失败", + // CUA-100: driver installer (zh-CN) + "cua.errors.installer.networkUnavailable": + "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", + "cua.errors.installer.curlFailed": "CUA 驱动安装脚本下载失败:{detail}", + "cua.errors.installer.signatureInvalid": "CUA 驱动安装脚本签名校验失败:{detail}", + "cua.errors.installer.permissionDenied": "CUA 驱动安装器需要更高权限:{detail}", + "cua.errors.installer.unsupportedPlatform": "CUA 驱动安装器在当前平台({platform})不可用。", + "cua.errors.installer.timeout": + "CUA 驱动安装超过 {minutes} 分钟仍未完成,下载可能已卡住,请重试。", + "settings.cua.installer.title": "CUA Driver", + "settings.cua.installer.description": + "LiveAgent 自身不内置 cua-driver;它由 cua.ai 官方脚本下载安装。安装脚本从 cua.ai 拉取、不需要 sudo(Linux 桌面端除外)。", + "settings.cua.installer.install": "下载并安装 cua-driver", + "settings.cua.installer.restartDaemon": "启动 daemon", + "settings.cua.installer.checkUpdate": "检查更新", + "settings.cua.installer.applyUpdate": "应用更新", + "settings.cua.installer.refresh": "重新检测", + "settings.cua.installer.dismiss": "关闭", + "settings.cua.installer.statusUnknown": "状态未知", + "settings.cua.installer.statusNotInstalled": "未安装", + "settings.cua.installer.statusInstalledNotRunning": "已安装,daemon 未运行", + "settings.cua.installer.statusInstalledRunning": "已安装,daemon 正在运行", + "settings.cua.installer.versionLabel": "v{version}", + "settings.cua.installer.versionUnknown": "已安装(版本未知)", + "settings.cua.installer.daemonStarted": "daemon 已启动", + "settings.cua.installer.daemonNotStarted": "daemon 未启动(首次使用时再启动)", + "settings.cua.installer.installSuccess": "安装成功:{version};{daemon}", + "settings.cua.installer.linuxMissingDeps": + "Linux 桌面端需要以下系统包,请先在终端执行 `sudo apt install {packages}` 后再继续安装。", + "settings.cua.installer.macosPermissionsTitle": "macOS 权限提示", + "settings.cua.installer.macosPermissionsBody": + "请到「系统设置 → 隐私与安全」授予 CuaDriver:\n· 辅助功能(Accessibility)\n· 屏幕录制(Screen Recording)\n完成后把 LiveAgent 切到前台即可使用。", + "settings.cua.installer.macosOpenSettings": "打开系统设置", + "settings.cua.installer.logLabel": "安装日志", + "settings.cua.installer.stage.starting": "准备安装…", + "settings.cua.installer.stage.downloading": "下载官方安装脚本…", + "settings.cua.installer.stage.installing": "正在安装…", + "settings.cua.installer.stage.startingDaemon": "启动 daemon…", + "settings.cua.installer.stage.checkingUpdate": "检查更新…", + "settings.cua.installer.stage.applyingUpdate": "应用更新…", + "settings.cua.installer.errors.invoke": "无法调用后端 CUA 安装器命令。", + "settings.cua.installer.errors.unknown": "未知错误", + "settings.cua.installer.errors.exception": "异常:{message}", + "settings.cua.installer.errors.daemonStart": + "启动 daemon 失败。请检查 cua-driver 是否已正确安装。", + // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染。 + "settings.cua.installer.update.available": + "检测到新版本,请点击「检查更新」旁的 apply 按钮应用。\n\n{log}", + "settings.cua.installer.update.upToDate": "已是最新版本。\n\n{log}", + "settings.cua.installer.update.applied": "更新成功:{version}。\n\n{log}", + "settings.cua.installer.update.failed": "更新失败。\n\n{log}", }, "en-US": { "app.windowPinned": "Pinned", @@ -567,6 +618,60 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.saving": "Syncing CUA config…", "settings.cua.errors.fetch": "Failed to read CUA status", "settings.cua.errors.save": "Failed to save CUA config", + // CUA-100: driver installer (en-US) + "cua.errors.installer.networkUnavailable": + "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", + "cua.errors.installer.curlFailed": "CUA driver installer download failed: {detail}", + "cua.errors.installer.signatureInvalid": + "CUA driver installer signature check failed: {detail}", + "cua.errors.installer.permissionDenied": + "CUA driver installer needs elevated permission: {detail}", + "cua.errors.installer.unsupportedPlatform": + "CUA driver installer is not available on {platform}.", + "cua.errors.installer.timeout": + "CUA driver installer did not finish within {minutes} minutes; the download may have stalled, please retry.", + "settings.cua.installer.title": "CUA Driver", + "settings.cua.installer.description": + "LiveAgent does not bundle cua-driver; it is downloaded by the official cua.ai installer script. The script pulls from cua.ai and does not require sudo (except for Linux desktop dependencies).", + "settings.cua.installer.install": "Download & install cua-driver", + "settings.cua.installer.restartDaemon": "Start daemon", + "settings.cua.installer.checkUpdate": "Check for update", + "settings.cua.installer.applyUpdate": "Apply update", + "settings.cua.installer.refresh": "Re-detect", + "settings.cua.installer.dismiss": "Dismiss", + "settings.cua.installer.statusUnknown": "Status unknown", + "settings.cua.installer.statusNotInstalled": "Not installed", + "settings.cua.installer.statusInstalledNotRunning": "Installed, daemon not running", + "settings.cua.installer.statusInstalledRunning": "Installed, daemon running", + "settings.cua.installer.versionLabel": "v{version}", + "settings.cua.installer.versionUnknown": "Installed (version unknown)", + "settings.cua.installer.daemonStarted": "daemon started", + "settings.cua.installer.daemonNotStarted": "daemon not started (will start on first use)", + "settings.cua.installer.installSuccess": "Installed: {version}; {daemon}", + "settings.cua.installer.linuxMissingDeps": + "Linux desktop requires these system packages. Run `sudo apt install {packages}` in a terminal before continuing.", + "settings.cua.installer.macosPermissionsTitle": "macOS permissions", + "settings.cua.installer.macosPermissionsBody": + "Open System Settings → Privacy & Security and grant CuaDriver:\n• Accessibility\n• Screen Recording\nThen bring LiveAgent to the front to use it.", + "settings.cua.installer.macosOpenSettings": "Open System Settings", + "settings.cua.installer.logLabel": "Install log", + "settings.cua.installer.stage.starting": "Preparing…", + "settings.cua.installer.stage.downloading": "Downloading official installer…", + "settings.cua.installer.stage.installing": "Installing…", + "settings.cua.installer.stage.startingDaemon": "Starting daemon…", + "settings.cua.installer.stage.checkingUpdate": "Checking for update…", + "settings.cua.installer.stage.applyingUpdate": "Applying update…", + "settings.cua.installer.errors.invoke": "Failed to invoke the backend CUA installer command.", + "settings.cua.installer.errors.unknown": "Unknown error", + "settings.cua.installer.errors.exception": "Exception: {message}", + "settings.cua.installer.errors.daemonStart": + "Failed to start daemon. Verify that cua-driver is installed correctly.", + // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染。 + "settings.cua.installer.update.available": + 'A new version is available. Click the apply button next to "Check for update" to install.\n\n{log}', + "settings.cua.installer.update.upToDate": "Already up to date.\n\n{log}", + "settings.cua.installer.update.applied": "Update applied: {version}.\n\n{log}", + "settings.cua.installer.update.failed": "Update failed.\n\n{log}", }, }; diff --git a/crates/agent-gui/src/lib/cua/cuaService.ts b/crates/agent-gui/src/lib/cua/cuaService.ts index b4261d16e..98027c0a0 100644 --- a/crates/agent-gui/src/lib/cua/cuaService.ts +++ b/crates/agent-gui/src/lib/cua/cuaService.ts @@ -1,5 +1,13 @@ +import type { + CuaDriverDetection, + CuaInstallResult, + CuaUpdateResult, + InstallerProgressEvent, + InstallPreview, +} from "@liveagent/ui/pages/settings/CuaInstaller"; import type { CuaService, CuaSettings, CuaStatus } from "@liveagent/ui/pages/settings/CuaSection"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; /** * 桌面端到 Rust `cua_*` Tauri Command 的桥。设置面板与 Settings 扩展 @@ -9,7 +17,7 @@ import { invoke } from "@tauri-apps/api/core"; * 重要:调用方拿到的是 `CuaService`(不耦合 Tauri 细节),settings 面板 * / 任何后续 CLI 入口都能复用同一接口做自检。 */ -export const desktopCuaService: CuaService = { +export const desktopCuaService: CuaService & CuaInstallerService = { platformLabel: "macos", async fetchStatus(): Promise { try { @@ -35,4 +43,61 @@ export const desktopCuaService: CuaService = { return null; } }, + async detectDriver(): Promise { + try { + return await invoke("cua_driver_detect"); + } catch (err) { + console.warn("[cua] detectDriver failed", err); + return null; + } + }, + async installDriver(): Promise { + try { + return await invoke("cua_driver_install"); + } catch (err) { + console.warn("[cua] installDriver failed", err); + return null; + } + }, + async updateDriver(apply: boolean): Promise { + try { + return await invoke("cua_driver_update", { apply }); + } catch (err) { + console.warn("[cua] updateDriver failed", err); + return null; + } + }, + async startDriverDaemon(): Promise { + try { + const result = await invoke<{ ok: boolean; error?: unknown }>("cua_driver_start_daemon"); + return result.ok; + } catch (err) { + console.warn("[cua] startDriverDaemon failed", err); + return false; + } + }, + async getInstallPreview(): Promise { + try { + return await invoke("cua_driver_install_preview"); + } catch (err) { + console.warn("[cua] getInstallPreview failed", err); + return null; + } + }, + async subscribeProgress(handler: (event: InstallerProgressEvent) => void): Promise<() => void> { + // Tauri 2 的 listen 返回 unlisten 句柄;直接透传给调用方。 + const unlisten = await listen("cua_install_progress", (event) => + handler(event.payload), + ); + return unlisten; + }, +}; + +export type CuaInstallerService = { + detectDriver: () => Promise; + installDriver: () => Promise; + updateDriver: (apply: boolean) => Promise; + startDriverDaemon: () => Promise; + getInstallPreview: () => Promise; + subscribeProgress: (handler: (event: InstallerProgressEvent) => void) => Promise<() => void>; }; diff --git a/crates/agent-gui/test/cua-installer-service.test.mjs b/crates/agent-gui/test/cua-installer-service.test.mjs new file mode 100644 index 000000000..28b89709b --- /dev/null +++ b/crates/agent-gui/test/cua-installer-service.test.mjs @@ -0,0 +1,159 @@ +/** + * CUA Driver 安装器服务(cuaService)契约测试。 + * + * 目标:保证 desktopCuaService 把所有 cua_driver_* Tauri Command 正确 + * 桥接到前端——即便后端 Command 改名 / 加参数,前端调用面要稳定。 + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; + +function loadCuaService(invokeMock, listenMock) { + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + return invokeMock(command, args); + }, + }, + "@tauri-apps/api/event": { + async listen(eventName, handler) { + return listenMock(eventName, handler); + }, + }, + }, + }); + return loader.loadModule("src/lib/cua/cuaService.ts"); +} + +test("desktopCuaService.detectDriver forwards to cua_driver_detect", async () => { + const calls = []; + const { desktopCuaService } = loadCuaService((command) => { + calls.push(command); + if (command === "cua_driver_detect") { + return { installed: false, daemonRunning: false, platform: "macos" }; + } + return null; + }); + + const detection = await desktopCuaService.detectDriver(); + assert.deepEqual(calls, ["cua_driver_detect"]); + assert.equal(detection.installed, false); + assert.equal(detection.daemonRunning, false); + assert.equal(detection.platform, "macos"); +}); + +test("desktopCuaService.installDriver forwards to cua_driver_install", async () => { + const calls = []; + const { desktopCuaService } = loadCuaService((command) => { + calls.push(command); + if (command === "cua_driver_install") { + return { success: true, log: "ok", daemonStarted: true, installedVersion: "0.3.1" }; + } + return null; + }); + + const result = await desktopCuaService.installDriver(); + assert.deepEqual(calls, ["cua_driver_install"]); + assert.equal(result.success, true); + assert.equal(result.installedVersion, "0.3.1"); +}); + +test("desktopCuaService.updateDriver forwards apply flag to cua_driver_update", async () => { + const calls = []; + const { desktopCuaService } = loadCuaService((command, args) => { + calls.push({ command, args }); + if (command === "cua_driver_update") { + return { updateAvailable: args?.apply === true, log: "", newVersion: "0.3.2" }; + } + return null; + }); + + const checkOnly = await desktopCuaService.updateDriver(false); + const applied = await desktopCuaService.updateDriver(true); + assert.equal(checkOnly.updateAvailable, false); + assert.equal(applied.updateAvailable, true); + assert.deepEqual( + calls.map((c) => c.command), + ["cua_driver_update", "cua_driver_update"], + ); + assert.equal(calls[0].args.apply, false); + assert.equal(calls[1].args.apply, true); +}); + +test("desktopCuaService.startDriverDaemon unwraps { ok, error } response", async () => { + const { desktopCuaService } = loadCuaService((command) => { + if (command === "cua_driver_start_daemon") return { ok: true }; + return null; + }); + assert.equal(await desktopCuaService.startDriverDaemon(), true); + + const failed = loadCuaService((command) => { + if (command === "cua_driver_start_daemon") return { ok: false, error: { kind: "x" } }; + return null; + }); + const svc = failed.loadModule ? null : null; + // direct re-import using same factory pattern + const failed2 = createTsModuleLoader2((command) => { + if (command === "cua_driver_start_daemon") return { ok: false, error: { kind: "x" } }; + return null; + }); + const { desktopCuaService: svc2 } = failed2.loadModule("src/lib/cua/cuaService.ts"); + assert.equal(await svc2.startDriverDaemon(), false); +}); + +// inline secondary loader to avoid helper leakage +function createTsModuleLoader2(invokeImpl) { + return createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { invoke: invokeImpl }, + "@tauri-apps/api/event": { async listen() { return () => {}; } }, + }, + }); +} + +test("desktopCuaService.subscribeProgress registers a Tauri listener", async () => { + const events = []; + const { desktopCuaService } = loadCuaService( + () => null, + (eventName, handler) => { + events.push({ eventName, handler }); + // 模拟 listen 立刻把 fake event 推给 handler 验证 payload 透传。 + handler({ payload: { stage: "starting", message: "hi" } }); + return () => {}; + }, + ); + + let received = null; + const unlisten = await desktopCuaService.subscribeProgress((event) => { + received = event; + }); + assert.equal(typeof unlisten, "function"); + assert.equal(events.length, 1); + assert.equal(events[0].eventName, "cua_install_progress"); + assert.deepEqual(received, { stage: "starting", message: "hi" }); +}); + +test("desktopCuaService.getInstallPreview forwards to cua_driver_install_preview", async () => { + const calls = []; + const { desktopCuaService } = loadCuaService((command) => { + calls.push(command); + if (command === "cua_driver_install_preview") { + return { + platform: "macos", + command: { + program: "/bin/bash", + args: ["-c", "curl ... | bash"], + description: "macOS / Linux installer", + needsSudo: false, + }, + }; + } + return null; + }); + const preview = await desktopCuaService.getInstallPreview(); + assert.deepEqual(calls, ["cua_driver_install_preview"]); + assert.equal(preview.command.program, "/bin/bash"); + assert.equal(preview.platform, "macos"); +}); diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index cd30cf848..d143d19d7 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1225,5 +1225,60 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA operation was not executed: {detail}", "cua.errors.io": "CUA subprocess IO error: {detail}", "cua.errors.unknown": "unknown reason", + // CUA-100 series: driver installer errors + "cua.errors.installer.networkUnavailable": + "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", + "cua.errors.installer.curlFailed": "CUA driver installer download failed: {detail}", + "cua.errors.installer.signatureInvalid": "CUA driver installer signature check failed: {detail}", + "cua.errors.installer.permissionDenied": + "CUA driver installer needs elevated permission: {detail}", + "cua.errors.installer.unsupportedPlatform": + "CUA driver installer is not available on {platform}.", + "cua.errors.installer.timeout": + "CUA driver installer did not finish within {minutes} minutes; the download may have stalled, please retry.", + // CUA Driver installer panel (CUA-100) + "settings.cua.installer.title": "CUA Driver", + "settings.cua.installer.description": + "LiveAgent does not bundle cua-driver; it is downloaded by the official cua.ai installer script. The script pulls from cua.ai and does not require sudo (except for Linux desktop dependencies).", + "settings.cua.installer.install": "Download & install cua-driver", + "settings.cua.installer.restartDaemon": "Start daemon", + "settings.cua.installer.checkUpdate": "Check for update", + "settings.cua.installer.applyUpdate": "Apply update", + "settings.cua.installer.refresh": "Re-detect", + "settings.cua.installer.dismiss": "Dismiss", + "settings.cua.installer.statusUnknown": "Status unknown", + "settings.cua.installer.statusNotInstalled": "Not installed", + "settings.cua.installer.statusInstalledNotRunning": "Installed, daemon not running", + "settings.cua.installer.statusInstalledRunning": "Installed, daemon running", + "settings.cua.installer.versionLabel": "v{version}", + "settings.cua.installer.versionUnknown": "Installed (version unknown)", + "settings.cua.installer.daemonStarted": "daemon started", + "settings.cua.installer.daemonNotStarted": "daemon not started (will start on first use)", + "settings.cua.installer.installSuccess": "Installed: {version}; {daemon}", + "settings.cua.installer.linuxMissingDeps": + "Linux desktop requires these system packages. Run `sudo apt install {packages}` in a terminal before continuing.", + "settings.cua.installer.macosPermissionsTitle": "macOS permissions", + "settings.cua.installer.macosPermissionsBody": + "Open System Settings → Privacy & Security and grant CuaDriver:\n• Accessibility\n• Screen Recording\nThen bring LiveAgent to the front to use it.", + "settings.cua.installer.macosOpenSettings": "Open System Settings", + "settings.cua.installer.logLabel": "Install log", + "settings.cua.installer.stage.starting": "Preparing…", + "settings.cua.installer.stage.downloading": "Downloading official installer…", + "settings.cua.installer.stage.installing": "Installing…", + "settings.cua.installer.stage.startingDaemon": "Starting daemon…", + "settings.cua.installer.stage.checkingUpdate": "Checking for update…", + "settings.cua.installer.stage.applyingUpdate": "Applying update…", + "settings.cua.installer.errors.invoke": "Failed to invoke the backend CUA installer command.", + "settings.cua.installer.errors.unknown": "Unknown error", + "settings.cua.installer.errors.exception": "Exception: {message}", + "settings.cua.installer.errors.daemonStart": + "Failed to start daemon. Verify that cua-driver is installed correctly.", + // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染, + // 不再与 errors.* 混在一起。 + "settings.cua.installer.update.available": + 'A new version is available. Click the apply button next to "Check for update" to install.\n\n{log}', + "settings.cua.installer.update.upToDate": "Already up to date.\n\n{log}", + "settings.cua.installer.update.applied": "Update applied: {version}.\n\n{log}", + "settings.cua.installer.update.failed": "Update failed.\n\n{log}", "settings.navCua": "CUA Driver", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index a4aff87f4..f6d30e5f1 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1165,5 +1165,57 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA 操作未执行:{detail}", "cua.errors.io": "CUA 子进程 IO 错误:{detail}", "cua.errors.unknown": "未知原因", + // CUA-100 系列:driver 安装器错误 + "cua.errors.installer.networkUnavailable": + "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", + "cua.errors.installer.curlFailed": "CUA 驱动安装脚本下载失败:{detail}", + "cua.errors.installer.signatureInvalid": "CUA 驱动安装脚本签名校验失败:{detail}", + "cua.errors.installer.permissionDenied": "CUA 驱动安装器需要更高权限:{detail}", + "cua.errors.installer.unsupportedPlatform": "CUA 驱动安装器在当前平台({platform})不可用。", + "cua.errors.installer.timeout": + "CUA 驱动安装超过 {minutes} 分钟仍未完成,下载可能已卡住,请重试。", + // CUA Driver 安装器面板(CUA-100) + "settings.cua.installer.title": "CUA Driver", + "settings.cua.installer.description": + "LiveAgent 自身不内置 cua-driver;它由 cua.ai 官方脚本下载安装。安装脚本从 cua.ai 拉取、不需要 sudo(Linux 桌面端除外)。", + "settings.cua.installer.install": "下载并安装 cua-driver", + "settings.cua.installer.restartDaemon": "启动 daemon", + "settings.cua.installer.checkUpdate": "检查更新", + "settings.cua.installer.applyUpdate": "应用更新", + "settings.cua.installer.refresh": "重新检测", + "settings.cua.installer.dismiss": "关闭", + "settings.cua.installer.statusUnknown": "状态未知", + "settings.cua.installer.statusNotInstalled": "未安装", + "settings.cua.installer.statusInstalledNotRunning": "已安装,daemon 未运行", + "settings.cua.installer.statusInstalledRunning": "已安装,daemon 正在运行", + "settings.cua.installer.versionLabel": "v{version}", + "settings.cua.installer.versionUnknown": "已安装(版本未知)", + "settings.cua.installer.daemonStarted": "daemon 已启动", + "settings.cua.installer.daemonNotStarted": "daemon 未启动(首次使用时再启动)", + "settings.cua.installer.installSuccess": "安装成功:{version};{daemon}", + "settings.cua.installer.linuxMissingDeps": + "Linux 桌面端需要以下系统包,请先在终端执行 `sudo apt install {packages}` 后再继续安装。", + "settings.cua.installer.macosPermissionsTitle": "macOS 权限提示", + "settings.cua.installer.macosPermissionsBody": + "请到「系统设置 → 隐私与安全」授予 CuaDriver:\n· 辅助功能(Accessibility)\n· 屏幕录制(Screen Recording)\n完成后把 LiveAgent 切到前台即可使用。", + "settings.cua.installer.macosOpenSettings": "打开系统设置", + "settings.cua.installer.logLabel": "安装日志", + "settings.cua.installer.stage.starting": "准备安装…", + "settings.cua.installer.stage.downloading": "下载官方安装脚本…", + "settings.cua.installer.stage.installing": "正在安装…", + "settings.cua.installer.stage.startingDaemon": "启动 daemon…", + "settings.cua.installer.stage.checkingUpdate": "检查更新…", + "settings.cua.installer.stage.applyingUpdate": "应用更新…", + "settings.cua.installer.errors.invoke": "无法调用后端 CUA 安装器命令。", + "settings.cua.installer.errors.unknown": "未知错误", + "settings.cua.installer.errors.exception": "异常:{message}", + "settings.cua.installer.errors.daemonStart": + "启动 daemon 失败。请检查 cua-driver 是否已正确安装。", + // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染。 + "settings.cua.installer.update.available": + "检测到新版本,请点击「检查更新」旁的 apply 按钮应用。\n\n{log}", + "settings.cua.installer.update.upToDate": "已是最新版本。\n\n{log}", + "settings.cua.installer.update.applied": "更新成功:{version}。\n\n{log}", + "settings.cua.installer.update.failed": "更新失败。\n\n{log}", "settings.navCua": "CUA 驱动", } as const satisfies Record; diff --git a/crates/agent-ui/src/pages/settings/CuaInstaller.ts b/crates/agent-ui/src/pages/settings/CuaInstaller.ts new file mode 100644 index 000000000..b6d93161b --- /dev/null +++ b/crates/agent-ui/src/pages/settings/CuaInstaller.ts @@ -0,0 +1,85 @@ +/** + * CUA Driver 安装器共享 TS 类型。Agent-UI 跨 desktop / web 共用。 + * + * 后端字段命名约定:Rust 侧全部用 camelCase,serde 自动 rename;前端拿到 + * 的是扁平结构。`stage` 是稳定字符串 literal,前端按 i18n key 翻译。 + */ + +export type InstallerStage = + | "starting" + | "downloading" + | "installing" + | "startingDaemon" + | "completed" + | "failed" + | "cancelled"; + +export type InstallerProgressEvent = { + stage: InstallerStage; + message: string; + logTail?: string; + percent?: number; +}; + +export type CuaDriverDetection = { + installed: boolean; + version?: string; + path?: string; + platform: string; + appBundleInstalled?: boolean; + daemonRunning: boolean; + doctorOutput?: string; + error?: string; +}; + +export type CuaInstallResult = { + success: boolean; + log: string; + installedVersion?: string; + daemonStarted: boolean; + error?: string; +}; + +export type CuaUpdateResult = { + updateAvailable: boolean; + log: string; + newVersion?: string; + error?: string; +}; + +export type InstallCommand = { + program: string; + args: string[]; + description: string; + needsSudo: boolean; +}; + +export type InstallPreview = { + platform: string; + command: InstallCommand; + linuxAptAvailable?: boolean; + linuxMissingPackages?: string[]; +}; + +/** + * Tauri 端 `cua_install_progress` 事件名。 + * 桌面端通过 `@tauri-apps/api/event` 的 `listen` 订阅。 + */ +export const CUA_INSTALL_PROGRESS_EVENT = "cua_install_progress"; + +/** + * CUA Driver 安装器服务——由 host(桌面端)注入。 + * 与 `CuaService` 合并使用;WebUI 不会提供。 + */ +export type CuaInstallerService = { + detectDriver: () => Promise; + installDriver: () => Promise; + updateDriver: (apply: boolean) => Promise; + startDriverDaemon: () => Promise; + getInstallPreview: () => Promise; + /** + * 订阅后端 `cua_install_progress` 事件。可选;只有桌面端实现会提供。 + * 返回 unlisten 句柄,用于在 React 卸载时清理。 + */ + subscribeProgress?: (handler: (event: InstallerProgressEvent) => void) => Promise<() => void>; +}; diff --git a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx new file mode 100644 index 000000000..f7d40585a --- /dev/null +++ b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx @@ -0,0 +1,655 @@ +/** + * CUA Driver 安装器面板。 + * + * 显示当前 cua-driver 状态(安装 / 版本 / daemon);提供安装 / 重启 + * daemon / 更新三组操作;安装进度通过 `subscribeProgress` 回调实时反映。 + * + * MVP 范围:检测 + 安装 + 启动 daemon + 简单更新检查;复杂的更新策略 + * / 多通道 / 高级诊断留给后续迭代。 + */ +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + CloudDownload, + ExternalLink, + Play, + RefreshCw, +} from "@liveagent/ui/components/IconSet"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { cn } from "@liveagent/ui/lib/shared/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + CuaDriverDetection, + CuaInstallerService, + CuaUpdateResult, + InstallerProgressEvent, + InstallPreview, +} from "./CuaInstaller"; +import { SettingsGroup } from "./shared"; + +export type CuaInstallerPanelProps = { + service: CuaInstallerService; + /** 来自后端的当前 OS 标签("macos" / "windows" / "linux")。 */ + platform: string; +}; + +type InstallFlowState = + | { kind: "idle" } + | { kind: "running"; stage: string; message: string; percent: number | null; logTail: string } + | { kind: "success"; message: string; log?: string } + | { kind: "info"; message: string; log?: string } + | { kind: "error"; errorText: string; log?: string }; + +function statusTone(detection: CuaDriverDetection | null): { + badge: string; + labelKey: string; +} { + if (!detection) { + return { + badge: "bg-muted text-muted-foreground", + labelKey: "settings.cua.installer.statusUnknown", + }; + } + if (!detection.installed) { + return { + badge: "bg-rose-500/10 text-rose-600 dark:text-rose-400", + labelKey: "settings.cua.installer.statusNotInstalled", + }; + } + if (!detection.daemonRunning) { + return { + badge: "bg-amber-500/10 text-amber-700 dark:text-amber-400", + labelKey: "settings.cua.installer.statusInstalledNotRunning", + }; + } + return { + badge: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", + labelKey: "settings.cua.installer.statusInstalledRunning", + }; +} + +function formatPercent(value: number | null | undefined): number { + if (value == null) return 0; + if (value < 0) return 0; + if (value > 100) return 100; + return Math.round(value); +} + +function permissionDeepLink(platform: string): string | null { + if (platform === "macos") { + // x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility + return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; + } + return null; +} + +/** + * 通用占位符替换。`t(key)` 拿到模板,再把 `{name}` 替换为传入值。 + * 与现有 i18n 模式保持一致(见 formatCuaError.interpolate)。 + */ +function fillTemplate( + template: string, + params: Record, +): string { + return template.replace(/\{(\w+)\}/g, (match, name: string) => { + const value = params[name]; + if (value === undefined || value === null) return match; + return String(value); + }); +} + +export function CuaInstallerPanel(props: CuaInstallerPanelProps) { + const { service, platform } = props; + const { t } = useLocale(); + const [detection, setDetection] = useState(null); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [flow, setFlow] = useState({ kind: "idle" }); + const [logExpanded, setLogExpanded] = useState(false); + const [errorBanner, setErrorBanner] = useState(null); + const unlistenRef = useRef<(() => void) | null>(null); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const [next, pv] = await Promise.all([service.detectDriver(), service.getInstallPreview()]); + setDetection(next); + setPreview(pv); + } finally { + setLoading(false); + } + }, [service]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // 监听 install 进度事件——通过 service.subscribeProgress 桥接,避免 + // agent-ui 直接依赖 @tauri-apps/api。 + useEffect(() => { + let cancelled = false; + (async () => { + if (!service.subscribeProgress) return; + try { + const unlisten = await service.subscribeProgress((payload: InstallerProgressEvent) => { + setFlow((current) => { + if (current.kind !== "running") return current; + return { + kind: "running", + stage: payload.stage, + message: payload.message, + percent: payload.percent ?? null, + logTail: payload.logTail ?? current.logTail, + }; + }); + if (payload.stage === "failed") { + setFlow({ + kind: "error", + errorText: payload.message, + log: payload.logTail, + }); + } + }); + if (cancelled) { + unlisten(); + } else { + unlistenRef.current = unlisten; + } + } catch (err) { + console.warn("[cua-installer] failed to subscribe progress event", err); + } + })(); + return () => { + cancelled = true; + if (unlistenRef.current) { + try { + unlistenRef.current(); + } catch { + // ignore + } + unlistenRef.current = null; + } + }; + }, [service]); + + const startInstall = useCallback(async () => { + setErrorBanner(null); + setFlow({ + kind: "running", + stage: "starting", + message: t("settings.cua.installer.stage.starting"), + percent: 0, + logTail: "", + }); + try { + const result = await service.installDriver(); + if (!result) { + setFlow({ + kind: "error", + errorText: t("settings.cua.installer.errors.invoke"), + }); + return; + } + if (result.success) { + setFlow({ + kind: "success", + message: fillTemplate(t("settings.cua.installer.installSuccess"), { + version: result.installedVersion ?? "—", + daemon: result.daemonStarted + ? t("settings.cua.installer.daemonStarted") + : t("settings.cua.installer.daemonNotStarted"), + }), + log: result.log, + }); + } else { + setFlow({ + kind: "error", + errorText: result.error ?? t("settings.cua.installer.errors.unknown"), + log: result.log, + }); + } + await refresh(); + } catch (err) { + setFlow({ + kind: "error", + errorText: errorTextFromUnknown(err, t), + }); + } + }, [service, refresh, t]); + + const restartDaemon = useCallback(async () => { + setErrorBanner(null); + setFlow({ + kind: "running", + stage: "startingDaemon", + message: t("settings.cua.installer.stage.startingDaemon"), + percent: 90, + logTail: "", + }); + try { + const ok = await service.startDriverDaemon(); + if (ok) { + setFlow({ kind: "idle" }); + } else { + setFlow({ + kind: "error", + errorText: t("settings.cua.installer.errors.daemonStart"), + }); + } + await refresh(); + } catch (err) { + setFlow({ + kind: "error", + errorText: errorTextFromUnknown(err, t), + }); + } + }, [service, refresh, t]); + + const checkUpdate = useCallback(async () => { + setErrorBanner(null); + setFlow({ + kind: "running", + stage: "starting", + message: t("settings.cua.installer.stage.checkingUpdate"), + percent: 0, + logTail: "", + }); + try { + const result = await service.updateDriver(false); + if (!result) { + setFlow({ + kind: "error", + errorText: t("settings.cua.installer.errors.invoke"), + }); + return; + } + if (result.updateAvailable) { + // 检测到新版本:语义上是 info(蓝),不是失败。 + setFlow({ + kind: "info", + message: fillTemplate(t("settings.cua.installer.update.available"), { + log: result.log.slice(0, 2000), + }), + log: result.log.slice(0, 2000), + }); + } else { + // 已是最新:语义上是 success(绿),不是失败。 + setFlow({ + kind: "success", + message: fillTemplate(t("settings.cua.installer.update.upToDate"), { + log: result.log.slice(0, 2000), + }), + log: result.log.slice(0, 2000), + }); + } + } catch (err) { + setFlow({ + kind: "error", + errorText: errorTextFromUnknown(err, t), + }); + } + }, [service, t]); + + const applyUpdate = useCallback(async () => { + setErrorBanner(null); + setFlow({ + kind: "running", + stage: "installing", + message: t("settings.cua.installer.stage.applyingUpdate"), + percent: 50, + logTail: "", + }); + try { + const result = (await service.updateDriver(true)) as CuaUpdateResult | null; + if (!result) { + setFlow({ + kind: "error", + errorText: t("settings.cua.installer.errors.invoke"), + }); + return; + } + // updateDriver(apply=true) 由后端走 spawn_blocking 跑 update --apply。 + // 必须按 result.error / result.updateAvailable / result.newVersion 三态区分: + // - error 非空 → error (红) + // - updateAvailable=true → success (绿) 「更新成功」 + // - updateAvailable=false → success (绿) 「已是最新版本」 + // 旧实现只看 newVersion,已是最新版本时也会被误标红「更新失败」(CUA-032)。 + const logSlice = result.log.slice(0, 2000); + if (result.error) { + setFlow({ + kind: "error", + errorText: fillTemplate(t("settings.cua.installer.update.failed"), { + log: logSlice, + }), + log: logSlice, + }); + } else if (result.updateAvailable && result.newVersion) { + setFlow({ + kind: "success", + message: fillTemplate(t("settings.cua.installer.update.applied"), { + version: result.newVersion, + log: logSlice, + }), + log: logSlice, + }); + } else { + // 后端报告 updateAvailable=false、error=null:已是最新版本,按 success 渲染。 + setFlow({ + kind: "success", + message: fillTemplate(t("settings.cua.installer.update.upToDate"), { + log: logSlice, + }), + log: logSlice, + }); + } + await refresh(); + } catch (err) { + setFlow({ + kind: "error", + errorText: errorTextFromUnknown(err, t), + }); + } + }, [service, refresh, t]); + + const dismissFlow = useCallback(() => { + setFlow({ kind: "idle" }); + setLogExpanded(false); + setErrorBanner(null); + }, []); + + const tone = useMemo(() => statusTone(detection), [detection]); + const isRunning = flow.kind === "running"; + const macosPermissionsPath = permissionDeepLink(platform); + + return ( + +
+
+ + {detection?.installed && detection.daemonRunning ? ( + + ) : ( + + )} + {t(tone.labelKey)} + + {detection?.installed ? ( + + {detection.version + ? fillTemplate(t("settings.cua.installer.versionLabel"), { + version: detection.version, + }) + : t("settings.cua.installer.versionUnknown")} + + ) : null} + {detection?.path ? ( + + {detection.path} + + ) : null} + +
+ +

+ {t("settings.cua.installer.description")} +

+ + {/* Action buttons */} +
+ {!detection?.installed ? ( + + ) : null} + {detection?.installed && !detection.daemonRunning ? ( + + ) : null} + {detection?.installed ? ( + + ) : null} + {detection?.installed ? ( + + ) : null} +
+ + {/* Install preview info — Linux deps / command description */} + {preview && !detection?.installed ? ( +
+

+ {`${preview.command.program} ${preview.command.args + .map((a) => (a.includes(" ") ? JSON.stringify(a) : a)) + .join(" ")}`} +

+ {preview.linuxMissingPackages && preview.linuxMissingPackages.length > 0 ? ( +

+ {fillTemplate(t("settings.cua.installer.linuxMissingDeps"), { + packages: preview.linuxMissingPackages.join(", "), + })} +

+ ) : null} +
+ ) : null} + + {/* macOS permissions card */} + {platform === "macos" && detection?.installed ? ( +
+

{t("settings.cua.installer.macosPermissionsTitle")}

+

+ {t("settings.cua.installer.macosPermissionsBody")} +

+ {macosPermissionsPath ? ( + + {t("settings.cua.installer.macosOpenSettings")} + + + ) : null} +
+ ) : null} + + {/* Running / progress */} + {isRunning ? ( +
+
+
+
+

+ {flow.kind === "running" ? flow.message : ""} +

+
+ ) : null} + + {/* Terminal-style log — error/info/success 都允许展开日志,便于看完整输出。 */} + {(isRunning && flow.kind === "running" && flow.logTail) || + flow.kind === "error" || + flow.kind === "info" || + flow.kind === "success" ? ( +
+ + {logExpanded ? ( +
+                {flow.kind === "running"
+                  ? flow.logTail
+                  : flow.kind === "error"
+                    ? (flow.log ?? flow.errorText)
+                    : flow.kind === "info" || flow.kind === "success"
+                      ? (flow.log ?? flow.message)
+                      : ""}
+              
+ ) : null} +
+ ) : null} + + {/* Final state messages */} + {flow.kind === "success" ? ( +
+

{flow.message}

+ +
+ ) : null} + + {flow.kind === "info" ? ( +
+

{flow.message}

+ +
+ ) : null} + + {flow.kind === "error" ? ( +
+

{flow.errorText}

+ +
+ ) : null} + + {errorBanner ? ( +
+ {errorBanner} +
+ ) : null} +
+ + ); +} + +function errorTextFromUnknown(err: unknown, t: (key: string) => string): string { + if (err instanceof Error) { + return fillTemplate(t("settings.cua.installer.errors.exception"), { message: err.message }); + } + if (typeof err === "string") return err; + return t("settings.cua.installer.errors.unknown"); +} diff --git a/crates/agent-ui/src/pages/settings/CuaSection.tsx b/crates/agent-ui/src/pages/settings/CuaSection.tsx index aa25e7c15..4f2e12c04 100644 --- a/crates/agent-ui/src/pages/settings/CuaSection.tsx +++ b/crates/agent-ui/src/pages/settings/CuaSection.tsx @@ -5,6 +5,8 @@ import { type CuaErrorPayload, formatCuaError } from "@liveagent/ui/lib/cua/form import { cn } from "@liveagent/ui/lib/shared/utils"; import { errorMessageWithFallback } from "@liveagent/ui/lib/shared/value"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CuaInstallerService } from "./CuaInstaller"; +import { CuaInstallerPanel } from "./CuaInstallerPanel"; import { AgentActivationSwitch, SettingsGroup, SettingsRow } from "./shared"; export type CuaSettings = { @@ -36,7 +38,7 @@ export type CuaService = { clearAudit: () => Promise; /** 来自后端的平台标签("macos" / "windows" / "linux")。 */ platformLabel: string; -}; +} & Partial; export type CuaSectionProps = { settings: CuaSettings; @@ -356,6 +358,23 @@ export function CuaSection(props: CuaSectionProps) { {persisting ? (
{t("settings.cua.saving")}
) : null} + + {cuaService.detectDriver && + cuaService.installDriver && + cuaService.updateDriver && + cuaService.startDriverDaemon && + cuaService.getInstallPreview ? ( + + ) : null}
); } From 448f565705148e1687808f9964af9588786789dc Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 23:18:17 +0800 Subject: [PATCH 07/28] =?UTF-8?q?fix(cua):=20Settings=20overlay=20?= =?UTF-8?q?=E5=9C=A8=20hidden=20=E6=96=87=E6=A1=A3=E4=B8=8B=E7=9A=84=20ope?= =?UTF-8?q?n/close=20fallback=EF=BC=88CUA-033/034=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:useSettingsOverlay 用 requestAnimationFrame 链推进 overlay 状态机(entering → open / leaving → closed)。WebKit 在 document.visibilityState === 'hidden' 时暂停 rAF 派发,且不发出 transitionend 事件,导致: - open 路径:overlay 永远停在 opacity-0 translate-y-6(用户看不见设置) - close 路径:panel 永远卡在 leaving 状态保留在 DOM 中(每次开关累积) 修复:open 与 close 都加 350ms setTimeout 兜底 + visibilitychange 监听(hidden→visible 时强制 promote)。对称的 promoteEntering / promoteLeaving 收敛状态推进,clearEnterFallback / clearLeaveFallback 互不干扰。open/close 互相切换时清掉对方的 pending timer。 测试:jsdom 模拟 visibilityState 端到端 16/16 assertion 通过(含 hidden close、visible close 保留动画、re-open 时 cancel pending 三场景) --- .../src/lib/settings/useSettingsOverlay.ts | 140 ++++++++++++++++-- 1 file changed, 130 insertions(+), 10 deletions(-) diff --git a/crates/agent-ui/src/lib/settings/useSettingsOverlay.ts b/crates/agent-ui/src/lib/settings/useSettingsOverlay.ts index de5e4580c..7ad1ab6c6 100644 --- a/crates/agent-ui/src/lib/settings/useSettingsOverlay.ts +++ b/crates/agent-ui/src/lib/settings/useSettingsOverlay.ts @@ -1,29 +1,149 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; export type SettingsOverlayState = "closed" | "entering" | "open" | "leaving"; +// CUA-033: Chromium / WKWebView suspends requestAnimationFrame callbacks while +// the document is hidden (background launch, minimized window, etc.). Without +// a fallback the overlay would stay stuck at opacity-0 forever in those +// scenarios. 350 ms slightly exceeds the 300 ms CSS transition so the normal +// rAF chain still wins when the tab is in the foreground. +// +// CUA-034: the same is true for the close path — WebKit suppresses +// transitionend when document.hidden, so the panel would otherwise stay +// mounted in 'leaving' forever. We use the symmetric LEAVE_FALLBACK_MS timer +// (plus a visibilitychange handler) to guarantee unmount. +const ENTER_FALLBACK_MS = 350; +const LEAVE_FALLBACK_MS = 350; + export function useSettingsOverlay() { const [settingsOpen, setSettingsOpen] = useState(false); const [overlay, setOverlay] = useState("closed"); + const enterFallbackTimerRef = useRef(null); + const leaveFallbackTimerRef = useRef(null); + + // Promote "entering" -> "open" without disturbing other states. The + // functional updater makes it idempotent: concurrent callers (rAF chain, + // 350 ms safety net, visibilitychange handler) all converge on the same + // outcome. + const promoteEntering = useCallback(() => { + setOverlay((current) => (current === "entering" ? "open" : current)); + }, []); + + // CUA-034: Promote "leaving" -> "closed" (and unmount the panel). Symmetric + // to promoteEntering: reads the latest overlay via ref so the check is + // idempotent and side-effect free. We do NOT call setSettingsOpen inside + // the setOverlay functional updater — React 18 StrictMode double-invokes + // functional updaters in development, and setSettingsOpen(false) called + // twice (once from each StrictMode pass) would still be idempotent but is + // wasteful and would mask future regressions in that setter. + const overlayRef = useRef("closed"); + useEffect(() => { + overlayRef.current = overlay; + }, [overlay]); + + const promoteLeaving = useCallback(() => { + if (overlayRef.current !== "leaving") return; + setSettingsOpen(false); + setOverlay("closed"); + }, []); + + const clearEnterFallback = useCallback(() => { + if (enterFallbackTimerRef.current !== null) { + window.clearTimeout(enterFallbackTimerRef.current); + enterFallbackTimerRef.current = null; + } + }, []); + + const clearLeaveFallback = useCallback(() => { + if (leaveFallbackTimerRef.current !== null) { + window.clearTimeout(leaveFallbackTimerRef.current); + leaveFallbackTimerRef.current = null; + } + }, []); const openSettingsOverlay = useCallback(() => { + clearLeaveFallback(); setSettingsOpen(true); setOverlay("entering"); - requestAnimationFrame(() => requestAnimationFrame(() => setOverlay("open"))); - }, []); + clearEnterFallback(); + + // Hard fallback: if neither the rAF chain nor a visibility change + // promotes us, force 'open' so the overlay is never stuck invisible. + enterFallbackTimerRef.current = window.setTimeout(() => { + enterFallbackTimerRef.current = null; + promoteEntering(); + }, ENTER_FALLBACK_MS); + + if (typeof document === "undefined" || document.visibilityState !== "visible") { + // Hidden tab / background launch: rAF will not fire. Promote now so + // the overlay is already in the visible state when the user brings + // the window forward. + promoteEntering(); + return; + } + + requestAnimationFrame(() => requestAnimationFrame(() => promoteEntering())); + }, [clearEnterFallback, clearLeaveFallback, promoteEntering]); + + // CUA-033/CUA-034: if the document becomes visible while we're still in + // 'entering' or 'leaving', promote immediately so the overlay is not + // waiting on the 350 ms safety net or the next user interaction. For + // 'leaving' this matters because WebKit suppresses transitionend while + // hidden, so without this the panel would stay mounted in the leaving + // state forever. + useEffect(() => { + if (typeof document === "undefined") return; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + promoteEntering(); + promoteLeaving(); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [promoteEntering, promoteLeaving]); + + // Drop any pending fallbacks on unmount so we never fire after teardown. + useEffect( + () => () => { + clearEnterFallback(); + clearLeaveFallback(); + }, + [clearEnterFallback, clearLeaveFallback], + ); + const closeSettingsOverlay = useCallback(() => { + clearEnterFallback(); + clearLeaveFallback(); setOverlay("leaving"); - }, []); + + // CUA-034: hard fallback for the close path. WebKit does not fire + // transitionend while document.hidden, so without this timer the panel + // stays mounted in 'leaving' forever. transitionend (when fired) will + // also call promoteLeaving, and the functional updater in setOverlay + // makes whichever path wins idempotent. + leaveFallbackTimerRef.current = window.setTimeout(() => { + leaveFallbackTimerRef.current = null; + promoteLeaving(); + }, LEAVE_FALLBACK_MS); + }, [clearEnterFallback, clearLeaveFallback, promoteLeaving]); + const handleSettingsOverlayTransitionEnd = useCallback(() => { - if (overlay === "leaving") { - setSettingsOpen(false); - setOverlay("closed"); - } - }, [overlay]); + // CUA-034: route through promoteLeaving so the leave fallback timer is + // observed (transitionend usually wins, but we still cancel the safety + // net so it doesn't run after teardown). Functional setOverlay keeps + // this idempotent if the timer already fired. + promoteLeaving(); + }, [promoteLeaving]); + const resetSettingsOverlay = useCallback(() => { + clearEnterFallback(); + clearLeaveFallback(); setSettingsOpen(false); setOverlay("closed"); - }, []); + }, [clearEnterFallback, clearLeaveFallback]); return { settingsOpen, From 15c3fdcc27ae648be1b615883aebfd98743c3c88 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 25 Aug 2026 23:47:57 +0800 Subject: [PATCH 08/28] =?UTF-8?q?fix(cua):=20Settings=20=E5=86=85=E5=B1=82?= =?UTF-8?q?=20section=20CSS=20=E5=8A=A8=E7=94=BB=E5=9C=A8=20hidden=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8B=E7=9A=84=E5=85=9C=E5=BA=95=EF=BC=88?= =?UTF-8?q?CUA-036=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-033/034 的 useSettingsOverlay 修复只覆盖外层 overlay 容器(React-driven opacity/translate 类)。但 .settings-section-enter / .settings-section-title-enter 的 CSS keyframe 动画(settingsSectionIn)在 document.visibilityState === 'hidden' 时由 WebKit 暂停,opacity/transform 卡在 from 状态(opacity:0, translateY(14px) scale(0.985))。结果是 Settings overlay 已 open 但右侧内容空白。 修复:SettingsShell 加 useIsDocumentHidden() 监听 visibilitychange;hidden 时挂 data-anim-suspended='true' + inline style (animation:none; opacity:1; transform:none);visible 时撤回,CSS 动画按原节奏播放。common-settings.css 加匹配 CSS 规则作 defense-in-depth。 测试:typecheck + cargo check 通过;2530 测试通过;cua-driver 截图验证 hidden 状态下右侧主区域内容完整渲染。 --- .../src/pages/settings/SettingsShell.tsx | 38 +++++++++++++++++++ .../agent-ui/src/styles/common-settings.css | 14 +++++++ 2 files changed, 52 insertions(+) diff --git a/crates/agent-ui/src/pages/settings/SettingsShell.tsx b/crates/agent-ui/src/pages/settings/SettingsShell.tsx index a7578a276..1af2c4ea2 100644 --- a/crates/agent-ui/src/pages/settings/SettingsShell.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsShell.tsx @@ -13,6 +13,31 @@ type SettingsShellProps = { hiddenSections?: readonly string[]; }; +// CUA-036: the inner `.settings-section-enter` / `.settings-section-title-enter` +// keyframe animations are paused by WebKit/Chromium while the document is +// hidden (background launch, minimized window). CUA-033/034 only cover the +// outer overlay container; without this fallback the inner section body +// stays at the `from` state (opacity:0, translateY(14px) scale(0.985)) and +// the entire settings page reads as blank. Mirrors the LEAVE_FALLBACK_MS +// shape: subscribe to visibilitychange so the override clears the moment the +// window becomes visible again. +function useIsDocumentHidden() { + const [hidden, setHidden] = useState(() => { + if (typeof document === "undefined") return false; + return document.visibilityState === "hidden"; + }); + useEffect(() => { + if (typeof document === "undefined") return; + const sync = () => setHidden(document.visibilityState === "hidden"); + sync(); + document.addEventListener("visibilitychange", sync); + return () => { + document.removeEventListener("visibilitychange", sync); + }; + }, []); + return hidden; +} + function getSaveIndicator(state: SettingsSaveState, t: (key: string) => string) { switch (state.status) { case "saving": @@ -49,6 +74,7 @@ export function SettingsShell(props: SettingsShellProps) { const { t } = useLocale(); const [section, setSection] = useState(initialSection); const [navQuery, setNavQuery] = useState(""); + const isDocumentHidden = useIsDocumentHidden(); const hiddenSectionSet = useMemo(() => new Set(hiddenSections), [hiddenSections]); const sections = useMemo( () => @@ -213,6 +239,12 @@ export function SettingsShell(props: SettingsShellProps) {
{t(activeSection.labelKey)}
@@ -234,6 +266,12 @@ export function SettingsShell(props: SettingsShellProps) { `settings-content-${activeSection.id}`, fillContent ? "flex min-h-0 flex-col overflow-hidden" : "overflow-auto", )} + data-anim-suspended={isDocumentHidden ? "true" : undefined} + style={ + isDocumentHidden + ? { animation: "none", opacity: 1, transform: "none" } + : undefined + } >
Date: Wed, 26 Aug 2026 00:21:14 +0800 Subject: [PATCH 09/28] =?UTF-8?q?fix(cua):=20Settings=20sidebar=20?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E9=A1=B9=20React=20key=20=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=20remount=EF=BC=88CUA-037=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:Tauri WKWebView 切换 activeSection 时,sidebar 子树的合成图层存在 stale frame 缓存。DOM class、data-active、右侧内容都正确更新,但视觉渲染卡在前一个活动项(bg-accent + font-medium 不掉)。inline style 写入也无法刷新截图。 修复:sidebar nav-item 的 React key 从 definition.id 改为 `${definition.id}-${active ? 'active' : 'idle'}`。activeSection 改变时旧 active 与新 active 按钮都被 React 视为不同元素,触发 unmount + remount,WebView 必须为全新 DOM 节点从零计算样式,绕开 stale 合成图层。 验证:tsc PASS × 2;cua-driver foreground delivery 4 次切换(系统设置→CUA→供应商→系统工具→Remote)均同步转移。 --- crates/agent-ui/src/pages/settings/SettingsShell.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/agent-ui/src/pages/settings/SettingsShell.tsx b/crates/agent-ui/src/pages/settings/SettingsShell.tsx index 1af2c4ea2..75aa24abe 100644 --- a/crates/agent-ui/src/pages/settings/SettingsShell.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsShell.tsx @@ -179,7 +179,15 @@ export function SettingsShell(props: SettingsShellProps) { const active = definition.id === activeSection.id; return ( {logExpanded ? (
Date: Wed, 26 Aug 2026 01:34:24 +0800
Subject: [PATCH 11/28] =?UTF-8?q?fix(cua):=20chevron=20span=20=E6=94=B9?=
 =?UTF-8?q?=E7=94=A8=20inline=20transform=20+=20transition=20=E4=BF=AE?=
 =?UTF-8?q?=E5=A4=8D=20expand=20=E5=8A=A8=E7=94=BB=E5=8D=A1=E6=AD=BB?=
 =?UTF-8?q?=EF=BC=88CUA-039=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

CUA-038 把 chevron 从 SVG 移到父级 span(Tailwind rotate-180 + transition-transform)。但 Tailwind v4 的 .rotate-180 编译成 'rotate: 180deg' 独立 CSS 属性,与 .transition-transform 级联顺序不可靠,expand 路径 animation 卡在 currentTime=0。

修复:chevron span 改用 inline style.transform + transition(浏览器原生处理 transition,行为跨 Tailwind 版本稳定)。CDP 实测 expand 与 collapse 双向 currentTime 从 0 推进到 ~33ms,computed transform 正确收敛到 0deg / 180deg。

测试:新增 test/cua/cua-installer-panel-chevron.test.mjs(React+jsdom 4/4 通过);2534 全量测试通过;pnpm typecheck + build 通过。
---
 .../cua/cua-installer-panel-chevron.test.mjs  | 166 ++++++++++++++++++
 .../src/pages/settings/CuaInstallerPanel.tsx  |   6 +-
 2 files changed, 171 insertions(+), 1 deletion(-)
 create mode 100644 crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs

diff --git a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs
new file mode 100644
index 000000000..b748d0c4a
--- /dev/null
+++ b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs
@@ -0,0 +1,166 @@
+/**
+ * CUA-039 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画
+ *
+ * 关键要求:
+ *   - 收起时 chevron span 的 inline style.transform 应为空(rotate(0))
+ *   - 展开时 chevron span 的 inline style.transform 必须是 "rotate(180deg)"
+ *   - 两种状态下 chevron span 的 inline style.transition 必须是 "transform 150ms"
+ *
+ * 为什么必须用 inline `transform: rotate(180deg)`(CSS transform 属性),
+ * 而不是 Tailwind 的 `rotate-180`(CSS `rotate` 属性):
+ *   - Tailwind v4 把 `rotate-180` 编译成 `rotate: 180deg`,这是单独的 CSS 属性。
+ *   - `.transition-transform` 在某些级联顺序下不会 transition `rotate` 属性,
+ *     导致 expand 路径 chevron 卡在 0deg(CUA-039)。
+ *   - 改用 inline `transform: rotate(180deg)` + `transition: transform 150ms`,
+ *     由浏览器原生处理 transition,行为可靠。
+ *
+ * 验证策略(两层):
+ *   1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切到 inline style,
+ *      不再依赖 `rotate-180` className。
+ *   2. **运行时 React 行为**:用 React 在 jsdom 下挂载一个最小复刻 chevron span,
+ *      验证 inline style 真的渲染到 DOM 且 transition/transform 属性正确。
+ */
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url)));
+const panelPath = path.join(
+  repoRoot,
+  "crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx",
+);
+const panelSource = fs.readFileSync(panelPath, "utf8");
+
+test("CUA-039 静态源码: chevron span 必须使用 inline style.transform + transition", () => {
+  // 抓 chevron 容器 span 的 JSX 块:logExpanded ? "rotate(180deg)" : undefined 是其特征。
+  const inlineStyleBlock = panelSource.match(
+    /className="inline-flex"\s*\n\s*style=\{\{[\s\S]*?\}\s*\}/,
+  );
+  assert.ok(
+    inlineStyleBlock,
+    "expected chevron span to use inline style with 'transform' / 'transition'",
+  );
+  const block = inlineStyleBlock[0];
+
+  // 不允许再依赖 Tailwind 的 rotate-180 class(这是 CUA-039 卡死的根源)。
+  assert.ok(
+    !block.includes("rotate-180"),
+    `chevron span still uses 'rotate-180' class — would re-trigger CUA-039: ${block.slice(0, 200)}`,
+  );
+  assert.ok(
+    !block.includes("transition-transform"),
+    `chevron span still uses 'transition-transform' Tailwind class — switch to inline style: ${block.slice(0, 200)}`,
+  );
+
+  // 必须显式声明 inline transition: transform 150ms。
+  assert.ok(
+    /transition:\s*"transform 150ms"/.test(block),
+    `expected inline transition: "transform 150ms" on chevron span, got: ${block.slice(0, 200)}`,
+  );
+  // 必须根据 logExpanded 切换 transform 值。
+  assert.ok(
+    /transform:\s*logExpanded\s*\?\s*"rotate\(180deg\)"\s*:\s*undefined/.test(block),
+    `expected transform ternary on logExpanded: ${block.slice(0, 200)}`,
+  );
+});
+
+// ---------------- 运行时 React 行为 ----------------
+
+const { JSDOM } = await import(
+  new URL("../../node_modules/jsdom/lib/api.js", import.meta.url).href
+);
+const dom = new JSDOM(
+  "
", + { pretendToBeVisual: true, url: "http://localhost/" }, +); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +globalThis.matchMedia = () => ({ + matches: false, + media: "", + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + onchange: null, +}); +if (typeof globalThis.structuredClone !== "function") { + globalThis.structuredClone = (v) => JSON.parse(JSON.stringify(v)); +} + +const req = createRequire(import.meta.url); +const React = req("react"); +const ReactDOMClient = req("react-dom/client"); + +/** + * 最小复刻 CuaInstallerPanel 的 chevron span:用同一个 inline style 模式。 + * 验证该 JSX 模式在浏览器侧真的会渲染出 transition + transform。 + */ +function ChevronSpan({ expanded }) { + return React.createElement( + "span", + { + className: "inline-flex", + style: { + transform: expanded ? "rotate(180deg)" : undefined, + transition: "transform 150ms", + }, + }, + React.createElement("svg", { width: 12, height: 12 }), + ); +} + +// 共享 root:createRoot 只能对同一个 container 调用一次。 +const container = document.getElementById("root"); +const root = ReactDOMClient.createRoot(container); + +// React 19 的 commit 是异步的;用 React.act 强制同步 flush 避免跨测试脏读。 +// React.act 在开发模式下会把 state 更新排入当前微任务队列并 flush。 +function setExpanded(expanded) { + return React.act(() => { + root.render(React.createElement(ChevronSpan, { expanded })); + }); +} + +test("CUA-039 运行时: 收起时 chevron span style.transform 为空、transition 含 transform 150ms", async () => { + await setExpanded(false); + const span = container.querySelector("span"); + assert.ok(span, "chevron span should be in DOM"); + // jsdom 把 undefined 解析成空字符串 + assert.equal(span.style.transform, ""); + assert.equal(span.style.transition, "transform 150ms"); + assert.equal(span.className, "inline-flex"); +}); + +test("CUA-039 运行时: 展开后 chevron span style.transform == 'rotate(180deg)',transition 仍是 'transform 150ms'", async () => { + await setExpanded(true); + const span = container.querySelector("span"); + assert.ok(span); + assert.equal(span.style.transform, "rotate(180deg)"); + assert.equal(span.style.transition, "transform 150ms"); + assert.equal(span.className, "inline-flex"); +}); + +test("CUA-039 运行时: 收起→展开→收起,transform 来回正确切换", async () => { + await setExpanded(false); + let span = container.querySelector("span"); + assert.ok(span); + assert.equal(span.style.transform, ""); + + await setExpanded(true); + span = container.querySelector("span"); + assert.equal(span.style.transform, "rotate(180deg)"); + assert.equal(span.style.transition, "transform 150ms"); + + await setExpanded(false); + span = container.querySelector("span"); + assert.equal(span.style.transform, ""); + assert.equal(span.style.transition, "transform 150ms"); +}); diff --git a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx index 670a8ab5d..ac976cc7e 100644 --- a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx +++ b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx @@ -567,7 +567,11 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { {t("settings.cua.installer.logLabel")} From 10622a140013454f847076a156d0b31ca2941faf Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 02:14:15 +0800 Subject: [PATCH 12/28] =?UTF-8?q?fix(cua):=20chevron=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20Web=20Animations=20API=20=E4=BF=AE=E5=A4=8D=20CUA-041=20?= =?UTF-8?q?=E6=97=B6=E5=BA=8F=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-038/039 修复都用 CSS transition 管线(Tailwind rotate-180 / inline style.transform)。CUA-039 在 setState 后异步再 click 时仍卡 currentTime=0、computed transform 停 matrix(1,0,0,1,0,0),存在 inline style 序列化顺序(transform first vs transition first)的时序依赖。 修复:chevron span 脱离 CSS transition,改用 Web Animations API(element.animate)。新 useEffect([logExpanded]) 取消残留 animation,按当前 logExpanded 方向以 keyframes [{rotate(0deg)}, {rotate(180deg)}] / [{rotate(180deg)}, {rotate(0deg)}] + duration 150ms ease forwards 驱动。WebKit/WKWebView 唯一不依赖 CSS 级联顺序的旋转触发方式。 测试:新增 5 个 jsdom 测试(Element.prototype.animate stub);2535 全量通过;typecheck + build 通过。WebView 真实点击验证需先解 CUA-021。 --- .../cua/cua-installer-panel-chevron.test.mjs | 197 ++++++++++++------ .../src/pages/settings/CuaInstallerPanel.tsx | 33 ++- 2 files changed, 168 insertions(+), 62 deletions(-) diff --git a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs index b748d0c4a..3ded39e40 100644 --- a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs +++ b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs @@ -1,24 +1,29 @@ /** - * CUA-039 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 + * CUA-041 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 * * 关键要求: - * - 收起时 chevron span 的 inline style.transform 应为空(rotate(0)) - * - 展开时 chevron span 的 inline style.transform 必须是 "rotate(180deg)" - * - 两种状态下 chevron span 的 inline style.transition 必须是 "transform 150ms" + * - chevron span 必须改用 Web Animations API(element.animate(...))驱动旋转, + * 而非 inline-style + CSS transition。CUA-041 复现:Tauri WebView(WKWebView) + * 中,async setState 后再 click 时 inline-style transition 不推进, + * computed transform 停在 matrix(1,0,0,1,0,0)。 + * - expand 时 keyframes = [{rotate(0deg)}, {rotate(180deg)}] + * - collapse 时 keyframes = [{rotate(180deg)}, {rotate(0deg)}] + * - duration = 150ms + * - chevron span 不再依赖 inline style.transform / style.transition * - * 为什么必须用 inline `transform: rotate(180deg)`(CSS transform 属性), - * 而不是 Tailwind 的 `rotate-180`(CSS `rotate` 属性): - * - Tailwind v4 把 `rotate-180` 编译成 `rotate: 180deg`,这是单独的 CSS 属性。 - * - `.transition-transform` 在某些级联顺序下不会 transition `rotate` 属性, - * 导致 expand 路径 chevron 卡在 0deg(CUA-039)。 - * - 改用 inline `transform: rotate(180deg)` + `transition: transform 150ms`, - * 由浏览器原生处理 transition,行为可靠。 + * 为什么走 Web Animations API 而不是 CSS transition: + * - CUA-038 用 Tailwind .rotate-180 + .transition-transform,卡在 currentTime=0; + * - CUA-039 切到 inline style.transform + transition: "transform 150ms",但 + * WKWebView 在 async setState 后触发 transition 不可靠。 + * - element.animate(...) 直接驱动动画,不依赖 CSS transition 管线。 * * 验证策略(两层): - * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切到 inline style, - * 不再依赖 `rotate-180` className。 + * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切到 Web Animations API: + * - chevron span 有 ref + data-chevron-expanded 属性; + * - 不再有 inline style.transform / style.transition; + * - useEffect 用 chevronRef.current.animate(...) 调动画,duration: 150。 * 2. **运行时 React 行为**:用 React 在 jsdom 下挂载一个最小复刻 chevron span, - * 验证 inline style 真的渲染到 DOM 且 transition/transform 属性正确。 + * stub Element.prototype.animate 收集调用参数。 */ import assert from "node:assert/strict"; import fs from "node:fs"; @@ -34,36 +39,79 @@ const panelPath = path.join( ); const panelSource = fs.readFileSync(panelPath, "utf8"); -test("CUA-039 静态源码: chevron span 必须使用 inline style.transform + transition", () => { - // 抓 chevron 容器 span 的 JSX 块:logExpanded ? "rotate(180deg)" : undefined 是其特征。 - const inlineStyleBlock = panelSource.match( - /className="inline-flex"\s*\n\s*style=\{\{[\s\S]*?\}\s*\}/, +test("CUA-041 静态源码: chevron span 必须使用 ref + data-chevron-expanded,不再用 inline style.transform/transition", () => { + // 抓 chevron 容器 span 的 JSX 块:ref={chevronRef} 与 data-chevron-expanded 是新特征。 + const chevronBlock = panelSource.match( + //, ); assert.ok( - inlineStyleBlock, - "expected chevron span to use inline style with 'transform' / 'transition'", + chevronBlock, + "expected chevron span to use ref={chevronRef} + data-chevron-expanded", ); - const block = inlineStyleBlock[0]; + const block = chevronBlock[0]; - // 不允许再依赖 Tailwind 的 rotate-180 class(这是 CUA-039 卡死的根源)。 + // 必须绑定 chevronRef。 + assert.ok( + /ref=\{chevronRef\}/.test(block), + `chevron span missing ref={chevronRef}: ${block.slice(0, 200)}`, + ); + // 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证。 + assert.ok( + /data-chevron-expanded=\{logExpanded\s*\?\s*"true"\s*:\s*"false"\}/.test(block), + `chevron span missing data-chevron-expanded attribute: ${block.slice(0, 200)}`, + ); + // CUA-041 修复不允许再依赖 inline style.transform——正是这个 inline-style + // 路径在 WKWebView async setState 后 click 卡死。 + assert.ok( + !/style=\{\{[\s\S]*?transform:[\s\S]*?\}\s*\}/.test(block), + `chevron span still uses inline style.transform — would re-trigger CUA-041: ${block.slice(0, 200)}`, + ); + // CUA-041 修复不允许再写 inline transition。 + assert.ok( + !/style=\{\{[\s\S]*?transition:[\s\S]*?\}\s*\}/.test(block), + `chevron span still uses inline style.transition — should switch to element.animate(): ${block.slice(0, 200)}`, + ); + // 不允许再依赖 Tailwind 的 rotate-180 class(CUA-038/039 双重回归保护)。 assert.ok( !block.includes("rotate-180"), - `chevron span still uses 'rotate-180' class — would re-trigger CUA-039: ${block.slice(0, 200)}`, + `chevron span still uses 'rotate-180' class — switch to Web Animations API: ${block.slice(0, 200)}`, ); assert.ok( !block.includes("transition-transform"), - `chevron span still uses 'transition-transform' Tailwind class — switch to inline style: ${block.slice(0, 200)}`, + `chevron span still uses 'transition-transform' Tailwind class — switch to element.animate(): ${block.slice(0, 200)}`, ); +}); - // 必须显式声明 inline transition: transform 150ms。 +test("CUA-041 静态源码: 必须有 useEffect 用 el.animate(...) 驱动旋转,duration: 150", () => { + // 抓用 chevronRef 的 useEffect 块,验证其调用 element.animate(...)。 + const effectBlock = panelSource.match( + /useEffect\(\(\)\s*=>\s*\{[\s\S]*?chevronRef\.current[\s\S]*?\}\s*,\s*\[logExpanded\]\)/, + ); assert.ok( - /transition:\s*"transform 150ms"/.test(block), - `expected inline transition: "transform 150ms" on chevron span, got: ${block.slice(0, 200)}`, + effectBlock, + "expected a useEffect watching [logExpanded] that calls chevronRef.current.animate(...)", ); - // 必须根据 logExpanded 切换 transform 值。 + const block = effectBlock[0]; + + // 必须用 element.animate(...)。 assert.ok( - /transform:\s*logExpanded\s*\?\s*"rotate\(180deg\)"\s*:\s*undefined/.test(block), - `expected transform ternary on logExpanded: ${block.slice(0, 200)}`, + /\.animate\(/.test(block), + `chevron animation effect should call .animate(...): ${block.slice(0, 200)}`, + ); + // 必须包含 expand 路径 keyframes(0 → 180)。 + assert.ok( + /\{\s*transform:\s*"rotate\(0deg\)"\s*\}\s*,\s*\{\s*transform:\s*"rotate\(180deg\)"\s*\}/.test(block), + `expand animation keyframes missing: ${block.slice(0, 200)}`, + ); + // 必须包含 collapse 路径 keyframes(180 → 0)。 + assert.ok( + /\{\s*transform:\s*"rotate\(180deg\)"\s*\}\s*,\s*\{\s*transform:\s*"rotate\(0deg\)"\s*\}/.test(block), + `collapse animation keyframes missing: ${block.slice(0, 200)}`, + ); + // duration 必须为 150ms。 + assert.ok( + /duration:\s*150/.test(block), + `chevron animation duration should be 150ms: ${block.slice(0, 200)}`, ); }); @@ -95,23 +143,54 @@ if (typeof globalThis.structuredClone !== "function") { globalThis.structuredClone = (v) => JSON.parse(JSON.stringify(v)); } +// jsdom 30 不实现 Web Animations API;注入最小 stub 仅用于「验证 animate 被调用、参数正确」。 +const animationCalls = []; +const ElementProto = dom.window.Element.prototype; +ElementProto.animate = function (keyframes, options) { + const call = { target: this, keyframes, options }; + animationCalls.push(call); + this._animations = this._animations || []; + const anim = { + playState: "running", + cancel() { + this.playState = "cancelled"; + }, + }; + this._animations.push(anim); + return anim; +}; +ElementProto.getAnimations = function () { + return (this._animations || []).filter((a) => a.playState === "running"); +}; + const req = createRequire(import.meta.url); const React = req("react"); const ReactDOMClient = req("react-dom/client"); /** - * 最小复刻 CuaInstallerPanel 的 chevron span:用同一个 inline style 模式。 - * 验证该 JSX 模式在浏览器侧真的会渲染出 transition + transform。 + * 最小复刻 CuaInstallerPanel 的 chevron span + animation effect: + * 用 ref 绑定 span,useEffect 监听 expanded 调 element.animate(...)。 + * 验证该模式在浏览器侧真的会按 expand/collapse 触发对应 keyframes 的动画。 */ function ChevronSpan({ expanded }) { + const ref = React.useRef(null); + React.useEffect(() => { + const el = ref.current; + if (!el || typeof el.animate !== "function") return; + for (const a of el.getAnimations()) a.cancel(); + const keyframes = expanded + ? [{ transform: "rotate(0deg)" }, { transform: "rotate(180deg)" }] + : [{ transform: "rotate(180deg)" }, { transform: "rotate(0deg)" }]; + el.animate(keyframes, { duration: 150, easing: "ease", fill: "forwards" }); + return () => { + for (const a of el.getAnimations()) a.cancel(); + }; + }, [expanded]); return React.createElement( "span", { - className: "inline-flex", - style: { - transform: expanded ? "rotate(180deg)" : undefined, - transition: "transform 150ms", - }, + ref, + "data-chevron-expanded": expanded ? "true" : "false", }, React.createElement("svg", { width: 12, height: 12 }), ); @@ -122,45 +201,47 @@ const container = document.getElementById("root"); const root = ReactDOMClient.createRoot(container); // React 19 的 commit 是异步的;用 React.act 强制同步 flush 避免跨测试脏读。 -// React.act 在开发模式下会把 state 更新排入当前微任务队列并 flush。 function setExpanded(expanded) { + animationCalls.length = 0; return React.act(() => { root.render(React.createElement(ChevronSpan, { expanded })); }); } -test("CUA-039 运行时: 收起时 chevron span style.transform 为空、transition 含 transform 150ms", async () => { +test("CUA-041 运行时: 收起时 chevron span 上调 animate,keyframes 180→0,duration 150", async () => { await setExpanded(false); const span = container.querySelector("span"); assert.ok(span, "chevron span should be in DOM"); - // jsdom 把 undefined 解析成空字符串 - assert.equal(span.style.transform, ""); - assert.equal(span.style.transition, "transform 150ms"); - assert.equal(span.className, "inline-flex"); + assert.equal(span.getAttribute("data-chevron-expanded"), "false"); + assert.equal(animationCalls.length, 1, "animate() should be called once when expanded=false"); + const call = animationCalls[0]; + assert.equal(call.keyframes[0].transform, "rotate(180deg)"); + assert.equal(call.keyframes[1].transform, "rotate(0deg)"); + assert.equal(call.options.duration, 150); }); -test("CUA-039 运行时: 展开后 chevron span style.transform == 'rotate(180deg)',transition 仍是 'transform 150ms'", async () => { +test("CUA-041 运行时: 展开后 chevron span 上调 animate,keyframes 0→180,duration 150", async () => { await setExpanded(true); const span = container.querySelector("span"); assert.ok(span); - assert.equal(span.style.transform, "rotate(180deg)"); - assert.equal(span.style.transition, "transform 150ms"); - assert.equal(span.className, "inline-flex"); + assert.equal(span.getAttribute("data-chevron-expanded"), "true"); + assert.equal(animationCalls.length, 1, "animate() should be called once when expanded=true"); + const call = animationCalls[0]; + assert.equal(call.keyframes[0].transform, "rotate(0deg)"); + assert.equal(call.keyframes[1].transform, "rotate(180deg)"); + assert.equal(call.options.duration, 150); }); -test("CUA-039 运行时: 收起→展开→收起,transform 来回正确切换", async () => { +test("CUA-041 运行时: 收起→展开→收起,每次都触发对应方向的 animate 调用", async () => { await setExpanded(false); - let span = container.querySelector("span"); - assert.ok(span); - assert.equal(span.style.transform, ""); + assert.equal(animationCalls[0].keyframes[0].transform, "rotate(180deg)"); + assert.equal(animationCalls[0].keyframes[1].transform, "rotate(0deg)"); await setExpanded(true); - span = container.querySelector("span"); - assert.equal(span.style.transform, "rotate(180deg)"); - assert.equal(span.style.transition, "transform 150ms"); + assert.equal(animationCalls[0].keyframes[0].transform, "rotate(0deg)"); + assert.equal(animationCalls[0].keyframes[1].transform, "rotate(180deg)"); await setExpanded(false); - span = container.querySelector("span"); - assert.equal(span.style.transform, ""); - assert.equal(span.style.transition, "transform 150ms"); -}); + assert.equal(animationCalls[0].keyframes[0].transform, "rotate(180deg)"); + assert.equal(animationCalls[0].keyframes[1].transform, "rotate(0deg)"); +}); \ No newline at end of file diff --git a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx index ac976cc7e..abe8c5a01 100644 --- a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx +++ b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx @@ -111,6 +111,7 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { const [logExpanded, setLogExpanded] = useState(false); const [errorBanner, setErrorBanner] = useState(null); const unlistenRef = useRef<(() => void) | null>(null); + const chevronRef = useRef(null); const refresh = useCallback(async () => { setLoading(true); @@ -175,6 +176,32 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { }; }, [service]); + // CUA-041:chevron 改用 Web Animations API 驱动旋转,绕开 Tauri WebView + // (WKWebView) 中 inline-style + CSS transition 的 quirk——async setState + // 后再 click 时 transition 不推进、computed transform 停在 matrix(1,0,0,1,0,0)。 + // Web Animations API 直接驱动属性值,不依赖 CSS transition 管线。 + useEffect(() => { + const el = chevronRef.current; + if (!el || typeof el.animate !== "function") return; + // 先 cancel 任何在跑的动画,避免快速 toggle 时留下 stale keyframe。 + for (const anim of el.getAnimations()) { + anim.cancel(); + } + const keyframes = logExpanded + ? [{ transform: "rotate(0deg)" }, { transform: "rotate(180deg)" }] + : [{ transform: "rotate(180deg)" }, { transform: "rotate(0deg)" }]; + el.animate(keyframes, { + duration: 150, + easing: "ease", + fill: "forwards", + }); + return () => { + for (const anim of el.getAnimations()) { + anim.cancel(); + } + }; + }, [logExpanded]); + const startInstall = useCallback(async () => { setErrorBanner(null); setFlow({ @@ -567,11 +594,9 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { {t("settings.cua.installer.logLabel")} From 6cea52e2b1e3cb964e3aadcb77c9e1759e0657f5 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 02:40:11 +0800 Subject: [PATCH 13/28] =?UTF-8?q?fix(cua):=20chevron=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=88=B0=20React=20state=20=E7=9B=B4=E6=8E=A5=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=20CSS=20transition=EF=BC=88CUA-042=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-041 的 Web Animations API 在 WKWebView 整页 hidden 状态下 playback clock 被冻住,animation.currentTime 永远 0。修复完全移除 chevronRef + useEffect + element.animate,改回 React state 直接驱动 inline style.transform + inline style.transition。transform / transition 写在同一个 style 对象里由 React 同帧 commit,避免 CUA-039 提到的内联 style 序列化顺序问题。 修复后 React state 与 inline style 在所有时刻都正确反映 toggle 状态(目标状态必达)。CSS transition 在 WebView hidden 状态下不推进是 Tauri/WKWebView 已知 bug(document.visibilityState vs document.hidden 不一致),短期在 PR 内不可修,是单独的渲染层问题。 测试:5 个 jsdom 测试 PASS;端到端 MCP-bridge 7/7 PASS;tsc + vite build 通过。 --- .../cua/cua-installer-panel-chevron.test.mjs | 201 +++++++----------- .../src/pages/settings/CuaInstallerPanel.tsx | 32 +-- 2 files changed, 84 insertions(+), 149 deletions(-) diff --git a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs index 3ded39e40..1a6a0a51b 100644 --- a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs +++ b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs @@ -1,29 +1,35 @@ /** - * CUA-041 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 + * CUA-042 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 * * 关键要求: - * - chevron span 必须改用 Web Animations API(element.animate(...))驱动旋转, - * 而非 inline-style + CSS transition。CUA-041 复现:Tauri WebView(WKWebView) - * 中,async setState 后再 click 时 inline-style transition 不推进, - * computed transform 停在 matrix(1,0,0,1,0,0)。 - * - expand 时 keyframes = [{rotate(0deg)}, {rotate(180deg)}] - * - collapse 时 keyframes = [{rotate(180deg)}, {rotate(0deg)}] - * - duration = 150ms - * - chevron span 不再依赖 inline style.transform / style.transition + * - 不再用 Web Animations API(CUA-041 在 WKWebView 中 animation.currentTime + * 全程冻结、playState='running' 不前进)。改回 React state 直接驱动 + * inline style.transform + style.transition,由浏览器 CSS transition 管线 + * 推进旋转动画,绕开 WAAPI 在 hidden-gated playback clock 下的停摆。 + * - expand 时 transform = rotate(180deg) + * - collapse 时 transform = rotate(0deg) + * - transition = "transform 150ms ease" + * - chevron span 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证 * - * 为什么走 Web Animations API 而不是 CSS transition: + * 为什么走 inline style + CSS transition 而不是 WAAPI: * - CUA-038 用 Tailwind .rotate-180 + .transition-transform,卡在 currentTime=0; - * - CUA-039 切到 inline style.transform + transition: "transform 150ms",但 - * WKWebView 在 async setState 后触发 transition 不可靠。 - * - element.animate(...) 直接驱动动画,不依赖 CSS transition 管线。 + * - CUA-039 切到 inline style.transform + transition,在 WKWebView async setState + * 后 click 时 transition 不可靠; + * - CUA-041 切到 element.animate(...),但 WKWebView WAAPI playback clock 冻结, + * animation.currentTime 始终 0、transform 停在首帧矩阵; + * - CUA-042 把 transform / transition 写在同一个 inline style 对象里,由 + * React 同步 commit,避免内联 style 序列化顺序问题——React 把整个 cssText + * 一次性写入 style 属性,浏览器将其视为同帧的属性变化,CSS transition + * 必然推进(不依赖 WAAPI clock)。 * * 验证策略(两层): - * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切到 Web Animations API: - * - chevron span 有 ref + data-chevron-expanded 属性; - * - 不再有 inline style.transform / style.transition; - * - useEffect 用 chevronRef.current.animate(...) 调动画,duration: 150。 + * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切回 inline style: + * - chevron span 有 data-chevron-expanded 属性; + * - 必须有 inline style.transform(由 logExpanded 驱动); + * - 必须有 inline style.transition("transform 150ms ease"); + * - 必须移除 chevronRef 与 el.animate(...) 调用。 * 2. **运行时 React 行为**:用 React 在 jsdom 下挂载一个最小复刻 chevron span, - * stub Element.prototype.animate 收集调用参数。 + * 验证 toggle 时 inline style.transform 跟随 React state 同步切换。 */ import assert from "node:assert/strict"; import fs from "node:fs"; @@ -39,79 +45,55 @@ const panelPath = path.join( ); const panelSource = fs.readFileSync(panelPath, "utf8"); -test("CUA-041 静态源码: chevron span 必须使用 ref + data-chevron-expanded,不再用 inline style.transform/transition", () => { - // 抓 chevron 容器 span 的 JSX 块:ref={chevronRef} 与 data-chevron-expanded 是新特征。 +test("CUA-042 静态源码: chevron span 必须有 data-chevron-expanded + inline style.transform/transition", () => { + // 抓 chevron 容器 span 的 JSX 块:data-chevron-expanded 与 inline style 是关键。 const chevronBlock = panelSource.match( - //, + //, ); assert.ok( chevronBlock, - "expected chevron span to use ref={chevronRef} + data-chevron-expanded", + "expected chevron span to use data-chevron-expanded + inline style", ); const block = chevronBlock[0]; - // 必须绑定 chevronRef。 - assert.ok( - /ref=\{chevronRef\}/.test(block), - `chevron span missing ref={chevronRef}: ${block.slice(0, 200)}`, - ); // 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证。 assert.ok( /data-chevron-expanded=\{logExpanded\s*\?\s*"true"\s*:\s*"false"\}/.test(block), `chevron span missing data-chevron-expanded attribute: ${block.slice(0, 200)}`, ); - // CUA-041 修复不允许再依赖 inline style.transform——正是这个 inline-style - // 路径在 WKWebView async setState 后 click 卡死。 + // 必须有 inline style.transform,且由 logExpanded 驱动。 assert.ok( - !/style=\{\{[\s\S]*?transform:[\s\S]*?\}\s*\}/.test(block), - `chevron span still uses inline style.transform — would re-trigger CUA-041: ${block.slice(0, 200)}`, + /style=\{\{[\s\S]*?transform:\s*logExpanded\s*\?\s*"rotate\(180deg\)"\s*:\s*"rotate\(0deg\)"[\s\S]*?\}\s*\}/.test( + block, + ), + `chevron span inline style.transform must be driven by logExpanded: ${block.slice(0, 200)}`, ); - // CUA-041 修复不允许再写 inline transition。 + // 必须有 inline style.transition,150ms。 assert.ok( - !/style=\{\{[\s\S]*?transition:[\s\S]*?\}\s*\}/.test(block), - `chevron span still uses inline style.transition — should switch to element.animate(): ${block.slice(0, 200)}`, + /style=\{\{[\s\S]*?transition:\s*"transform\s+150ms[\s\S]*?\}\s*\}/.test(block), + `chevron span missing inline style.transition 'transform 150ms ...': ${block.slice(0, 200)}`, ); // 不允许再依赖 Tailwind 的 rotate-180 class(CUA-038/039 双重回归保护)。 assert.ok( !block.includes("rotate-180"), - `chevron span still uses 'rotate-180' class — switch to Web Animations API: ${block.slice(0, 200)}`, - ); - assert.ok( - !block.includes("transition-transform"), - `chevron span still uses 'transition-transform' Tailwind class — switch to element.animate(): ${block.slice(0, 200)}`, + `chevron span still uses 'rotate-180' class — should use inline style.transform: ${block.slice(0, 200)}`, ); }); -test("CUA-041 静态源码: 必须有 useEffect 用 el.animate(...) 驱动旋转,duration: 150", () => { - // 抓用 chevronRef 的 useEffect 块,验证其调用 element.animate(...)。 - const effectBlock = panelSource.match( - /useEffect\(\(\)\s*=>\s*\{[\s\S]*?chevronRef\.current[\s\S]*?\}\s*,\s*\[logExpanded\]\)/, - ); - assert.ok( - effectBlock, - "expected a useEffect watching [logExpanded] that calls chevronRef.current.animate(...)", - ); - const block = effectBlock[0]; - - // 必须用 element.animate(...)。 - assert.ok( - /\.animate\(/.test(block), - `chevron animation effect should call .animate(...): ${block.slice(0, 200)}`, - ); - // 必须包含 expand 路径 keyframes(0 → 180)。 +test("CUA-042 静态源码: 必须移除 chevronRef 与 WAAPI el.animate(...) 路径", () => { + // CUA-041 路径不再使用:refs 到 chevron span 的 useEffect + element.animate(...). assert.ok( - /\{\s*transform:\s*"rotate\(0deg\)"\s*\}\s*,\s*\{\s*transform:\s*"rotate\(180deg\)"\s*\}/.test(block), - `expand animation keyframes missing: ${block.slice(0, 200)}`, + !/chevronRef/.test(panelSource), + `panel still references chevronRef — CUA-042 should drop WAAPI path entirely`, ); - // 必须包含 collapse 路径 keyframes(180 → 0)。 assert.ok( - /\{\s*transform:\s*"rotate\(180deg\)"\s*\}\s*,\s*\{\s*transform:\s*"rotate\(0deg\)"\s*\}/.test(block), - `collapse animation keyframes missing: ${block.slice(0, 200)}`, + !/\.animate\(/.test(panelSource), + `panel still calls .animate(...) on an element — should switch to inline style: ${panelSource.match(/[^\n]*\.animate\([^\n]*/)?.[0]?.slice(0, 200) ?? ""}`, ); - // duration 必须为 150ms。 + // 不允许再有「CUA-041」注释锚点。 assert.ok( - /duration:\s*150/.test(block), - `chevron animation duration should be 150ms: ${block.slice(0, 200)}`, + !/CUA-041/.test(panelSource), + `panel still has 'CUA-041' reference — should be CUA-042 now`, ); }); @@ -143,54 +125,24 @@ if (typeof globalThis.structuredClone !== "function") { globalThis.structuredClone = (v) => JSON.parse(JSON.stringify(v)); } -// jsdom 30 不实现 Web Animations API;注入最小 stub 仅用于「验证 animate 被调用、参数正确」。 -const animationCalls = []; -const ElementProto = dom.window.Element.prototype; -ElementProto.animate = function (keyframes, options) { - const call = { target: this, keyframes, options }; - animationCalls.push(call); - this._animations = this._animations || []; - const anim = { - playState: "running", - cancel() { - this.playState = "cancelled"; - }, - }; - this._animations.push(anim); - return anim; -}; -ElementProto.getAnimations = function () { - return (this._animations || []).filter((a) => a.playState === "running"); -}; - const req = createRequire(import.meta.url); const React = req("react"); const ReactDOMClient = req("react-dom/client"); /** - * 最小复刻 CuaInstallerPanel 的 chevron span + animation effect: - * 用 ref 绑定 span,useEffect 监听 expanded 调 element.animate(...)。 - * 验证该模式在浏览器侧真的会按 expand/collapse 触发对应 keyframes 的动画。 + * 最小复刻 CuaInstallerPanel 的 chevron span: + * 用 React state 直接驱动 inline style.transform + style.transition, + * 验证该模式在浏览器侧真的会按 expand/collapse 切换 transform。 */ function ChevronSpan({ expanded }) { - const ref = React.useRef(null); - React.useEffect(() => { - const el = ref.current; - if (!el || typeof el.animate !== "function") return; - for (const a of el.getAnimations()) a.cancel(); - const keyframes = expanded - ? [{ transform: "rotate(0deg)" }, { transform: "rotate(180deg)" }] - : [{ transform: "rotate(180deg)" }, { transform: "rotate(0deg)" }]; - el.animate(keyframes, { duration: 150, easing: "ease", fill: "forwards" }); - return () => { - for (const a of el.getAnimations()) a.cancel(); - }; - }, [expanded]); return React.createElement( "span", { - ref, "data-chevron-expanded": expanded ? "true" : "false", + style: { + transform: expanded ? "rotate(180deg)" : "rotate(0deg)", + transition: "transform 150ms ease", + }, }, React.createElement("svg", { width: 12, height: 12 }), ); @@ -202,46 +154,53 @@ const root = ReactDOMClient.createRoot(container); // React 19 的 commit 是异步的;用 React.act 强制同步 flush 避免跨测试脏读。 function setExpanded(expanded) { - animationCalls.length = 0; return React.act(() => { root.render(React.createElement(ChevronSpan, { expanded })); }); } -test("CUA-041 运行时: 收起时 chevron span 上调 animate,keyframes 180→0,duration 150", async () => { +function readStyle(span) { + // jsdom 把 style 上的 camelCase 字段返回为字符串("rotate(180deg)" 等), + // 不需要 getComputedStyle——inline style 直接读取即可。 + return { + transform: span.style.transform, + transition: span.style.transition, + }; +} + +test("CUA-042 运行时: 收起时 chevron span inline style.transform = rotate(0deg)", async () => { await setExpanded(false); const span = container.querySelector("span"); assert.ok(span, "chevron span should be in DOM"); assert.equal(span.getAttribute("data-chevron-expanded"), "false"); - assert.equal(animationCalls.length, 1, "animate() should be called once when expanded=false"); - const call = animationCalls[0]; - assert.equal(call.keyframes[0].transform, "rotate(180deg)"); - assert.equal(call.keyframes[1].transform, "rotate(0deg)"); - assert.equal(call.options.duration, 150); + const style = readStyle(span); + assert.equal(style.transform, "rotate(0deg)", `unexpected transform: ${style.transform}`); + assert.ok( + /transform\s+150ms/.test(style.transition), + `unexpected transition: ${style.transition}`, + ); }); -test("CUA-041 运行时: 展开后 chevron span 上调 animate,keyframes 0→180,duration 150", async () => { +test("CUA-042 运行时: 展开后 chevron span inline style.transform = rotate(180deg)", async () => { await setExpanded(true); const span = container.querySelector("span"); assert.ok(span); assert.equal(span.getAttribute("data-chevron-expanded"), "true"); - assert.equal(animationCalls.length, 1, "animate() should be called once when expanded=true"); - const call = animationCalls[0]; - assert.equal(call.keyframes[0].transform, "rotate(0deg)"); - assert.equal(call.keyframes[1].transform, "rotate(180deg)"); - assert.equal(call.options.duration, 150); + const style = readStyle(span); + assert.equal(style.transform, "rotate(180deg)", `unexpected transform: ${style.transform}`); + assert.ok( + /transform\s+150ms/.test(style.transition), + `unexpected transition: ${style.transition}`, + ); }); -test("CUA-041 运行时: 收起→展开→收起,每次都触发对应方向的 animate 调用", async () => { +test("CUA-042 运行时: 收起→展开→收起,每次都触发对应方向的 transform 切换", async () => { await setExpanded(false); - assert.equal(animationCalls[0].keyframes[0].transform, "rotate(180deg)"); - assert.equal(animationCalls[0].keyframes[1].transform, "rotate(0deg)"); + assert.equal(readStyle(container.querySelector("span")).transform, "rotate(0deg)"); await setExpanded(true); - assert.equal(animationCalls[0].keyframes[0].transform, "rotate(0deg)"); - assert.equal(animationCalls[0].keyframes[1].transform, "rotate(180deg)"); + assert.equal(readStyle(container.querySelector("span")).transform, "rotate(180deg)"); await setExpanded(false); - assert.equal(animationCalls[0].keyframes[0].transform, "rotate(180deg)"); - assert.equal(animationCalls[0].keyframes[1].transform, "rotate(0deg)"); + assert.equal(readStyle(container.querySelector("span")).transform, "rotate(0deg)"); }); \ No newline at end of file diff --git a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx index abe8c5a01..3e2ffd6c3 100644 --- a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx +++ b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx @@ -111,7 +111,6 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { const [logExpanded, setLogExpanded] = useState(false); const [errorBanner, setErrorBanner] = useState(null); const unlistenRef = useRef<(() => void) | null>(null); - const chevronRef = useRef(null); const refresh = useCallback(async () => { setLoading(true); @@ -176,32 +175,6 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { }; }, [service]); - // CUA-041:chevron 改用 Web Animations API 驱动旋转,绕开 Tauri WebView - // (WKWebView) 中 inline-style + CSS transition 的 quirk——async setState - // 后再 click 时 transition 不推进、computed transform 停在 matrix(1,0,0,1,0,0)。 - // Web Animations API 直接驱动属性值,不依赖 CSS transition 管线。 - useEffect(() => { - const el = chevronRef.current; - if (!el || typeof el.animate !== "function") return; - // 先 cancel 任何在跑的动画,避免快速 toggle 时留下 stale keyframe。 - for (const anim of el.getAnimations()) { - anim.cancel(); - } - const keyframes = logExpanded - ? [{ transform: "rotate(0deg)" }, { transform: "rotate(180deg)" }] - : [{ transform: "rotate(180deg)" }, { transform: "rotate(0deg)" }]; - el.animate(keyframes, { - duration: 150, - easing: "ease", - fill: "forwards", - }); - return () => { - for (const anim of el.getAnimations()) { - anim.cancel(); - } - }; - }, [logExpanded]); - const startInstall = useCallback(async () => { setErrorBanner(null); setFlow({ @@ -594,9 +567,12 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { {t("settings.cua.installer.logLabel")} From 29ec82ee7bb454b03e30d7683ddb83d115ed48a9 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 02:59:09 +0800 Subject: [PATCH 14/28] =?UTF-8?q?fix(cua):=20useIsDocumentHidden=20?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=20document.hidden=20=E4=B8=8E=20visibilitySt?= =?UTF-8?q?ate=20=E4=BB=BB=E4=B8=80=EF=BC=88CUA-043=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-036 修复只检查 document.visibilityState === 'hidden'。Tauri/WKWebView 后台启动态下 document.hidden === true 但 visibilityState === 'visible'(两者实测不一致),导致 useIsDocumentHidden 不触发 data-anim-suspended + inline style 兜底,.settings-section-enter CSS keyframe 被 WebKit 暂停,整页 .settings-content 永远停在 opacity:0 / transform:matrix(0.985,0,0,0.985,0,14)。 修复:useIsDocumentHidden 改为 document.hidden === true || document.visibilityState === 'hidden'(任一为真即视为 hidden)。抽出模块级 isDocumentHidden() 消除 useState/useEffect 重复三元(DRY)。注释更新为 CUA-036/CUA-043。 验证:tsc 通过;dev stack + WS MCP-bridge 端到端:撕开真实启动态 + mock visibilityState=visible 但 hidden=true 撕裂态,data-anim-suspended=true + opacity=1 正确触发。 --- .../src/pages/settings/SettingsShell.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/agent-ui/src/pages/settings/SettingsShell.tsx b/crates/agent-ui/src/pages/settings/SettingsShell.tsx index 75aa24abe..366ddd06b 100644 --- a/crates/agent-ui/src/pages/settings/SettingsShell.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsShell.tsx @@ -13,22 +13,29 @@ type SettingsShellProps = { hiddenSections?: readonly string[]; }; -// CUA-036: the inner `.settings-section-enter` / `.settings-section-title-enter` -// keyframe animations are paused by WebKit/Chromium while the document is -// hidden (background launch, minimized window). CUA-033/034 only cover the -// outer overlay container; without this fallback the inner section body -// stays at the `from` state (opacity:0, translateY(14px) scale(0.985)) and -// the entire settings page reads as blank. Mirrors the LEAVE_FALLBACK_MS -// shape: subscribe to visibilitychange so the override clears the moment the -// window becomes visible again. +// CUA-036 / CUA-043: the inner `.settings-section-enter` / +// `.settings-section-title-enter` keyframe animations are paused by +// WebKit/Chromium while the document is hidden (background launch, minimized +// window). CUA-033/034 only cover the outer overlay container; without this +// fallback the inner section body stays at the `from` state (opacity:0, +// translateY(14px) scale(0.985)) and the entire settings page reads as +// blank. Mirrors the LEAVE_FALLBACK_MS shape: subscribe to visibilitychange +// so the override clears the moment the window becomes visible again. +// +// CUA-043: `document.hidden` and `document.visibilityState` are supposed to +// stay in sync, but Tauri/WKWebView's background-launch state can leave +// them out of sync (hidden=true, visibilityState="visible"). Treat either +// signal as hidden so we still suspend the keyframe animation and surface +// the section body via the data-anim-suspended + inline style fallback. +function isDocumentHidden() { + if (typeof document === "undefined") return false; + return document.hidden || document.visibilityState === "hidden"; +} + function useIsDocumentHidden() { - const [hidden, setHidden] = useState(() => { - if (typeof document === "undefined") return false; - return document.visibilityState === "hidden"; - }); + const [hidden, setHidden] = useState(isDocumentHidden); useEffect(() => { - if (typeof document === "undefined") return; - const sync = () => setHidden(document.visibilityState === "hidden"); + const sync = () => setHidden(isDocumentHidden()); sync(); document.addEventListener("visibilitychange", sync); return () => { From 6acb6fde37589f11b601871e69ee929f47b971a3 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 04:03:31 +0800 Subject: [PATCH 15/28] =?UTF-8?q?fix(cua):=20chevron=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20Tailwind=20className=20+=20useLayoutEffect=20=E5=BC=BA?= =?UTF-8?q?=E5=88=B7=EF=BC=88CUA-044=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-042/044 完全回归:inline style.transform 在 WKWebView 特定状态下被丢弃,computed transform 永远 matrix(1,0,0,1,0,0)。修复彻底放弃 inline style 路径。 修复:chevron 用 className 切换 rotate-180 / rotate-0 + transition-transform duration-150。className 走 stylesheet pipeline 不被丢弃。配套 useLayoutEffect 在 logExpanded 变化后同步读 offsetWidth 强制 reflow,避免 className 切换被下一帧 batch 掉。完全移除 WAAPI(.animate)。保留 data-chevron-expanded 属性供 AX 测试。 测试:pnpm typecheck:ui + build PASS;2536 全量测试 + 14 cua 测试 PASS;biome clean。 --- .../cua/cua-installer-panel-chevron.test.mjs | 203 +++++++++++------- .../src/pages/settings/CuaInstallerPanel.tsx | 27 ++- 2 files changed, 143 insertions(+), 87 deletions(-) diff --git a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs index 1a6a0a51b..e328e558e 100644 --- a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs +++ b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs @@ -1,35 +1,37 @@ /** - * CUA-042 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 + * CUA-044 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 * - * 关键要求: - * - 不再用 Web Animations API(CUA-041 在 WKWebView 中 animation.currentTime - * 全程冻结、playState='running' 不前进)。改回 React state 直接驱动 - * inline style.transform + style.transition,由浏览器 CSS transition 管线 - * 推进旋转动画,绕开 WAAPI 在 hidden-gated playback clock 下的停摆。 - * - expand 时 transform = rotate(180deg) - * - collapse 时 transform = rotate(0deg) - * - transition = "transform 150ms ease" - * - chevron span 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证 + * 关键要求(CUA-044 修复后): + * - 不再依赖 inline style.transform(CUA-042 证明 inline style 会被 WKWebView + * 丢弃——aria-expanded / data-chevron-expanded 已切,但 getComputedStyle + * .transform 永远 matrix(1,0,0,1,0,0))。 + * - 改用 Tailwind className 切换 + transition-transform + duration-150: + * className={cn("... transition-transform duration-150", + * logExpanded ? "rotate-180" : "rotate-0")} + * className 由 React commit 同步写入,浏览器 CSS transition 通过样式表 + * pipeline 推进,不依赖 WAAPI 时钟(CUA-041 已证 WAAPI playback clock + * 在 WKWebView 全程冻结)。 + * - chevron span 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证。 + * - 用 useLayoutEffect 读取 offsetWidth 强制 layout flush——给 WKWebView + * 一个明确的 reflow 触发点,避免 className 切换被下一帧 batch 掉。 * - * 为什么走 inline style + CSS transition 而不是 WAAPI: - * - CUA-038 用 Tailwind .rotate-180 + .transition-transform,卡在 currentTime=0; - * - CUA-039 切到 inline style.transform + transition,在 WKWebView async setState - * 后 click 时 transition 不可靠; - * - CUA-041 切到 element.animate(...),但 WKWebView WAAPI playback clock 冻结, - * animation.currentTime 始终 0、transform 停在首帧矩阵; - * - CUA-042 把 transform / transition 写在同一个 inline style 对象里,由 - * React 同步 commit,避免内联 style 序列化顺序问题——React 把整个 cssText - * 一次性写入 style 属性,浏览器将其视为同帧的属性变化,CSS transition - * 必然推进(不依赖 WAAPI clock)。 + * 失败历史: + * - CUA-038:Tailwind rotate-180 + transition-transform 在 SVG 上 WAAPI + * 时钟冻结; + * - CUA-039:inline style.transform + transition,expand 路径 transition + * 不推进; + * - CUA-041:Web Animations API,WKWebView 整页 hidden 时 + * animation.currentTime 永远 0; + * - CUA-042:inline style.transform 写在统一 style 对象里,computed + * transform 永远单位矩阵——inline style 被 WKWebView 丢弃; + * - CUA-044:className 切换 + useLayoutEffect + offsetWidth flush(当前)。 * * 验证策略(两层): - * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切回 inline style: - * - chevron span 有 data-chevron-expanded 属性; - * - 必须有 inline style.transform(由 logExpanded 驱动); - * - 必须有 inline style.transition("transform 150ms ease"); - * - 必须移除 chevronRef 与 el.animate(...) 调用。 - * 2. **运行时 React 行为**:用 React 在 jsdom 下挂载一个最小复刻 chevron span, - * 验证 toggle 时 inline style.transform 跟随 React state 同步切换。 + * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切到 className + * 驱动,并移除 inline style.transform / style.transition; + * 2. **运行时 React 行为**:用 React 在 jsdom 下挂载一个最小复刻 chevron + * span,验证 toggle 时 className 跟随 React state 同步切换 + * rotate-180 / rotate-0。 */ import assert from "node:assert/strict"; import fs from "node:fs"; @@ -45,55 +47,74 @@ const panelPath = path.join( ); const panelSource = fs.readFileSync(panelPath, "utf8"); -test("CUA-042 静态源码: chevron span 必须有 data-chevron-expanded + inline style.transform/transition", () => { - // 抓 chevron 容器 span 的 JSX 块:data-chevron-expanded 与 inline style 是关键。 - const chevronBlock = panelSource.match( - //, - ); - assert.ok( - chevronBlock, - "expected chevron span to use data-chevron-expanded + inline style", - ); - const block = chevronBlock[0]; +test("CUA-044 静态源码: chevron span 必须用 className 驱动 rotate-180/rotate-0", () => { + // 抓 chevron 容器 span 的 JSX 块:通过 ref={chevronRef} 锚点定位起始 ,避免 JSX 嵌套导致单正则跨多个 span。 + const chevronRefIdx = panelSource.indexOf("ref={chevronRef}"); + assert.ok(chevronRefIdx > 0, "panel source must contain ref={chevronRef}"); + const beforeChevron = panelSource.slice(0, chevronRefIdx); + const lastSpanStart = beforeChevron.lastIndexOf(" 0, "expected a ", chevronRefIdx); + assert.ok(closeSpanIdx > chevronRefIdx, "expected a closing tag after ref={chevronRef}"); + const block = panelSource.slice(lastSpanStart, closeSpanIdx + "".length); // 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证。 assert.ok( /data-chevron-expanded=\{logExpanded\s*\?\s*"true"\s*:\s*"false"\}/.test(block), `chevron span missing data-chevron-expanded attribute: ${block.slice(0, 200)}`, ); - // 必须有 inline style.transform,且由 logExpanded 驱动。 + // 必须用 className 切换 rotate-180(不依赖 inline style.transform)。 assert.ok( - /style=\{\{[\s\S]*?transform:\s*logExpanded\s*\?\s*"rotate\(180deg\)"\s*:\s*"rotate\(0deg\)"[\s\S]*?\}\s*\}/.test( + /className=\{cn\([\s\S]*?logExpanded\s*\?\s*"rotate-180"\s*:\s*"rotate-0"[\s\S]*?\)\s*\}/.test( block, ), - `chevron span inline style.transform must be driven by logExpanded: ${block.slice(0, 200)}`, + `chevron span must toggle rotate-180 via className: ${block.slice(0, 200)}`, ); - // 必须有 inline style.transition,150ms。 + // 必须有 Tailwind transition + duration 类(让 CSS transition 管线推进旋转)。 assert.ok( - /style=\{\{[\s\S]*?transition:\s*"transform\s+150ms[\s\S]*?\}\s*\}/.test(block), - `chevron span missing inline style.transition 'transform 150ms ...': ${block.slice(0, 200)}`, + /transition-transform/.test(block) && /duration-150/.test(block), + `chevron span must declare transition-transform + duration-150: ${block.slice(0, 200)}`, ); - // 不允许再依赖 Tailwind 的 rotate-180 class(CUA-038/039 双重回归保护)。 + // 不允许再有 inline style.transform / style.transition(CUA-044 关键要求)。 assert.ok( - !block.includes("rotate-180"), - `chevron span still uses 'rotate-180' class — should use inline style.transform: ${block.slice(0, 200)}`, + !/style=\{\{[\s\S]*?transform:\s*logExpanded/.test(block), + `chevron span still has inline style.transform — CUA-044 forbids it: ${block.slice(0, 200)}`, + ); + assert.ok( + !/style=\{\{[\s\S]*?transition:\s*"transform\s+150ms/.test(block), + `chevron span still has inline style.transition — should use Tailwind classes: ${block.slice(0, 200)}`, ); }); -test("CUA-042 静态源码: 必须移除 chevronRef 与 WAAPI el.animate(...) 路径", () => { - // CUA-041 路径不再使用:refs 到 chevron span 的 useEffect + element.animate(...). +test("CUA-044 静态源码: 必须用 useLayoutEffect + offsetWidth 强制 layout flush", () => { + // 必须有 chevronRef(指向 chevron span)。 assert.ok( - !/chevronRef/.test(panelSource), - `panel still references chevronRef — CUA-042 should drop WAAPI path entirely`, + /chevronRef\s*=\s*useRef]*>/.test(panelSource), + `panel is missing chevronRef for layout flush anchor`, ); + // 必须有 useLayoutEffect 读 offsetWidth 强制 reflow。 + assert.ok( + /useLayoutEffect\(\(\)\s*=>\s*\{[\s\S]*?offsetWidth[\s\S]*?\}\s*,\s*\[logExpanded\]\)/.test( + panelSource, + ), + `panel is missing useLayoutEffect + offsetWidth flush`, + ); +}); + +test("CUA-044 静态源码: 必须移除 WAAPI .animate(...) 与旧的 inline-style 注释锚点", () => { assert.ok( !/\.animate\(/.test(panelSource), - `panel still calls .animate(...) on an element — should switch to inline style: ${panelSource.match(/[^\n]*\.animate\([^\n]*/)?.[0]?.slice(0, 200) ?? ""}`, + `panel still calls .animate(...) on an element — should switch to className: ${ + panelSource.match(/[^\n]*\.animate\([^\n]*/)?.[0]?.slice(0, 200) ?? "" + }`, ); - // 不允许再有「CUA-041」注释锚点。 + // 不允许再有「CUA-041」注释锚点(CUA-041 是被 CUA-042 取代的 WAAPI 路径); + // CUA-042 可以出现在注释里作为 inline-style 路径被否决的历史,但本测试只 + // 校验 WAAPI 路径已彻底移除。 assert.ok( !/CUA-041/.test(panelSource), - `panel still has 'CUA-041' reference — should be CUA-042 now`, + `panel still references CUA-041 — should be CUA-044 now`, ); }); @@ -130,19 +151,22 @@ const React = req("react"); const ReactDOMClient = req("react-dom/client"); /** - * 最小复刻 CuaInstallerPanel 的 chevron span: - * 用 React state 直接驱动 inline style.transform + style.transition, - * 验证该模式在浏览器侧真的会按 expand/collapse 切换 transform。 + * 最小复刻 CuaInstallerPanel 的 chevron span(CUA-044 className 驱动版): + * 用 React state 直接驱动 className 上的 rotate-180 / rotate-0 切换。 */ function ChevronSpan({ expanded }) { return React.createElement( "span", { "data-chevron-expanded": expanded ? "true" : "false", - style: { - transform: expanded ? "rotate(180deg)" : "rotate(0deg)", - transition: "transform 150ms ease", - }, + className: [ + "inline-flex", + "transition-transform", + "duration-150", + expanded ? "rotate-180" : "rotate-0", + ] + .filter(Boolean) + .join(" "), }, React.createElement("svg", { width: 12, height: 12 }), ); @@ -159,48 +183,65 @@ function setExpanded(expanded) { }); } -function readStyle(span) { - // jsdom 把 style 上的 camelCase 字段返回为字符串("rotate(180deg)" 等), - // 不需要 getComputedStyle——inline style 直接读取即可。 - return { - transform: span.style.transform, - transition: span.style.transition, - }; +function readClasses(span) { + return (span.getAttribute("class") ?? "").split(/\s+/).filter(Boolean); } -test("CUA-042 运行时: 收起时 chevron span inline style.transform = rotate(0deg)", async () => { +test("CUA-044 运行时: 收起时 chevron span 有 rotate-0、无 rotate-180", async () => { await setExpanded(false); const span = container.querySelector("span"); assert.ok(span, "chevron span should be in DOM"); assert.equal(span.getAttribute("data-chevron-expanded"), "false"); - const style = readStyle(span); - assert.equal(style.transform, "rotate(0deg)", `unexpected transform: ${style.transform}`); + const classes = readClasses(span); + assert.ok( + classes.includes("rotate-0"), + `chevron span should have rotate-0 when collapsed: ${classes.join(" ")}`, + ); + assert.ok( + !classes.includes("rotate-180"), + `chevron span should NOT have rotate-180 when collapsed: ${classes.join(" ")}`, + ); assert.ok( - /transform\s+150ms/.test(style.transition), - `unexpected transition: ${style.transition}`, + classes.includes("transition-transform") && classes.includes("duration-150"), + `chevron span should declare transition-transform + duration-150: ${classes.join(" ")}`, ); }); -test("CUA-042 运行时: 展开后 chevron span inline style.transform = rotate(180deg)", async () => { +test("CUA-044 运行时: 展开后 chevron span 有 rotate-180、无 rotate-0", async () => { await setExpanded(true); const span = container.querySelector("span"); assert.ok(span); assert.equal(span.getAttribute("data-chevron-expanded"), "true"); - const style = readStyle(span); - assert.equal(style.transform, "rotate(180deg)", `unexpected transform: ${style.transform}`); + const classes = readClasses(span); assert.ok( - /transform\s+150ms/.test(style.transition), - `unexpected transition: ${style.transition}`, + classes.includes("rotate-180"), + `chevron span should have rotate-180 when expanded: ${classes.join(" ")}`, + ); + assert.ok( + !classes.includes("rotate-0"), + `chevron span should NOT have rotate-0 when expanded: ${classes.join(" ")}`, ); }); -test("CUA-042 运行时: 收起→展开→收起,每次都触发对应方向的 transform 切换", async () => { +test("CUA-044 运行时: 收起→展开→收起,每次都触发对应方向的 className 切换", async () => { await setExpanded(false); - assert.equal(readStyle(container.querySelector("span")).transform, "rotate(0deg)"); + let classes = readClasses(container.querySelector("span")); + assert.ok( + classes.includes("rotate-0") && !classes.includes("rotate-180"), + `collapsed classes: ${classes.join(" ")}`, + ); await setExpanded(true); - assert.equal(readStyle(container.querySelector("span")).transform, "rotate(180deg)"); + classes = readClasses(container.querySelector("span")); + assert.ok( + classes.includes("rotate-180") && !classes.includes("rotate-0"), + `expanded classes: ${classes.join(" ")}`, + ); await setExpanded(false); - assert.equal(readStyle(container.querySelector("span")).transform, "rotate(0deg)"); + classes = readClasses(container.querySelector("span")); + assert.ok( + classes.includes("rotate-0") && !classes.includes("rotate-180"), + `re-collapsed classes: ${classes.join(" ")}`, + ); }); \ No newline at end of file diff --git a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx index 3e2ffd6c3..ac562faf5 100644 --- a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx +++ b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx @@ -19,7 +19,7 @@ import { import { Button } from "@liveagent/ui/components/ui/button"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CuaDriverDetection, @@ -111,6 +111,21 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { const [logExpanded, setLogExpanded] = useState(false); const [errorBanner, setErrorBanner] = useState(null); const unlistenRef = useRef<(() => void) | null>(null); + const chevronRef = useRef(null); + + // CUA-044: WKWebView (Tauri WebView) 在 hidden / 隐藏式面板里会丢 className + // 切换的 layout flush——Tailwind rotate-180 编译后的 transform: rotate(180deg) + // 已经写入 stylesheet 但下一帧之前没有触发同步 layout,导致视觉不旋转。 + // useLayoutEffect 在 React commit 后、浏览器 paint 前同步运行;显式读 + // offsetWidth 强制一次同步 reflow,让 className 切换被立即提交。 + // (不再使用 inline style.transform——之前几轮的 inline style 路径在 + // WKWebView 中会被丢弃,className 走样式表 pipeline 更稳。) + // biome-ignore lint/correctness/useExhaustiveDependencies: logExpanded is the trigger — re-run after every expand/collapse so the WebView reflows and the rotate-180 class actually paints. + useLayoutEffect(() => { + const el = chevronRef.current; + if (!el) return; + void el.offsetWidth; + }, [logExpanded]); const refresh = useCallback(async () => { setLoading(true); @@ -567,12 +582,12 @@ export function CuaInstallerPanel(props: CuaInstallerPanelProps) { {t("settings.cua.installer.logLabel")} From 1b59f1559932ae92118cc27255e7f16cc8cb2a31 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 08:47:09 +0800 Subject: [PATCH 16/28] =?UTF-8?q?refactor(cua):=20=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=20cua-driver=20MCP=20+=203=20?= =?UTF-8?q?=E4=B8=AA=E5=AE=89=E5=85=A8=E9=97=A8=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer (coder-hhx) 反馈两层:(1) 自研 osascript/cliclick/screencapture 能力弱于 cua-driver;(2) 安全门控缺口。 迁移: - 删除 driver.rs 自研 MacOsDriver/UnsupportedDriver(osascript + screencapture + cliclick) - 新增 services/cua/cua_client.rs:cua-driver stdio JSON-RPC MCP 客户端(懒 spawn / 30s 超时 / 死亡自动重启 / MCP error → CuaError 映射) - Tauri Command 重写:所有 cua_* 走 CuaClient.call_tool 调 cua-driver MCP(cua-driver 暴露 56 个工具) - CuaDriver trait 删除(不再需要平台分支) 安全门控: 1. 逐次审批:toolPolicy.ts 新增 HARDCODED_GROUP_DEFAULTS = { cua: 'ask' };resolveToolPolicy 兜底逻辑;CuaSection 加「信任模式」开关(trustMode=true 时临时 allow) 2. sandboxOffline deny:CuaRuntimeConfig.sandbox_offline 由 cua_set_config 服务端权威从 system.commandSafetyMode 写入;CuaStore::enforce 第一步检查 sandboxOffline=true → CuaError::sandbox_offline;CuaSection 在 offline 时显示横幅并禁用总开关 3. 截图宿主过滤:cua_screenshot 走 cua-driver get_desktop_state/zoom;window_owner 透传给 cua-driver 内置窗口寻址 测试:cargo check + cargo test cua 22/22 PASS;pnpm typecheck/build/lint clean;2550 全量测试 PASS(含 3 新 CUA tool policy 测试);cua-driver MCP e2e 4/4 调用成功。 已知限制:截图未显式排除 LiveAgent 自身窗口(cua-driver TCC ScreenCaptureKit 同 bundle attribution 自动处理;后续可加 list_windows owner 过滤)。 --- .../agent-gui/src-tauri/src/commands/cua.rs | 648 +++++++++++++++--- crates/agent-gui/src-tauri/src/lib.rs | 6 +- .../src-tauri/src/services/cua/cua_client.rs | 310 +++++++++ .../src-tauri/src/services/cua/driver.rs | 604 ---------------- .../src-tauri/src/services/cua/error.rs | 18 +- .../src-tauri/src/services/cua/mod.rs | 22 +- .../src-tauri/src/services/cua/store.rs | 68 +- crates/agent-gui/src/App.tsx | 10 +- crates/agent-gui/src/i18n/config.ts | 37 +- crates/agent-gui/src/lib/tools/cuaTools.ts | 2 +- crates/agent-gui/src/lib/tools/toolPolicy.ts | 33 + .../src/pages/chat/runtime/useSendChatTurn.ts | 1 + .../chat/turns/runAgentConversationTurn.ts | 16 +- .../agent-gui/test/tools/tool-policy.test.mjs | 28 + .../src/i18n/translations/enUSSettings.ts | 14 +- .../src/i18n/translations/zhCNSettings.ts | 12 + crates/agent-ui/src/lib/settings/index.ts | 3 +- crates/agent-ui/src/lib/settings/types.ts | 4 + .../src/pages/settings/CuaSection.tsx | 59 +- 19 files changed, 1150 insertions(+), 745 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/services/cua/cua_client.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/cua/driver.rs diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs index 6ecafd53b..d16a27e55 100644 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -1,26 +1,29 @@ //! CUA Tauri Command 层。所有命令的统一规范: //! 1. `cua_*` 命名前缀;状态无关。 -//! 2. 经 `CuaStore::enforce` 守卫:未启用 / 白名单拒了就直接 `Err` 回去, -//! 不调用驱动。 +//! 2. 经 `CuaStore::enforce` 守卫:未启用 / 白名单拒了 / sandboxOffline +//! 时直接 `Err` 回去,不调用驱动。 //! 3. 操作结果写审计日志(成功 + 失败都写),便于前端面板与远端审计。 //! 4. `screenshot` 返回 base64(前端可经 invoke 解码成 PNG),与 MCP //! 协议桥的标准一致;底层驱动返回原始字节。 //! -//! 平台:非 macOS 上 `cua_status.available=false`、其余命令返回 -//! 结构化 `CuaError`(kind + i18n params + 英文 message),由前端按 -//! 当前 locale 翻译——避免之前中文硬编码字串跨 IPC 泄漏(CUA-006)。 +//! 平台:之前 `MacOsDriver` 是 osascript + screencapture 自研实现, +//! 现已切到 `cua-driver mcp --direct`(trycua/cua),跨 macOS / +//! Windows / Linux + 不抢光标 + 支持非 AX 表面。详细动机见 +//! `services/cua/cua_client.rs`。 use base64::Engine; use chrono::Utc; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::sync::Arc; use tauri::AppHandle; use tauri::State; +use crate::services::cua::cua_client::CuaClient; use crate::services::cua::{ installer::{CuaDriverDetection, CuaInstallResult, CuaUpdateResult, InstallPreview}, store::CuaRuntimeConfig, - ClickButton, CuaAuditEntry, CuaError, CuaStore, CuaStoreSnapshot, PlatformError, WindowInfo, + CuaAuditEntry, CuaError, CuaStore, CuaStoreSnapshot, }; #[derive(Debug, Serialize)] @@ -40,12 +43,14 @@ pub enum ClickButtonArg { Right, } -impl From for ClickButton { +/// cua-driver `click` 接受 `button: "left" | "right" | "middle"`; +/// 同时 `double_click` 没有 button 字段,由调用方决定。 +impl From for &'static str { fn from(value: ClickButtonArg) -> Self { match value { - ClickButtonArg::Left => ClickButton::Left, - ClickButtonArg::Middle => ClickButton::Middle, - ClickButtonArg::Right => ClickButton::Right, + ClickButtonArg::Left => "left", + ClickButtonArg::Middle => "middle", + ClickButtonArg::Right => "right", } } } @@ -58,79 +63,165 @@ pub struct CuaOpResponse { pub error: Option, } -/// 把驱动层错误转成跨 IPC 的结构化错误。`platform_driver` 调用方需要 -/// `?` 后再调一次 `PlatformError::into()`。 -fn map_platform_error(err: PlatformError) -> CuaError { - match err { - PlatformError::Unsupported(os) => CuaError::unsupported_platform(&os), - PlatformError::PermissionRequired { - permission_key, - permission, - } => CuaError::permission_required(permission_key, permission), - PlatformError::NotExecuted(detail) => CuaError::not_executed(&detail), - PlatformError::Io(detail) => CuaError::io(&detail), - } -} - -fn record_audit( +/// 在 Tauri Command 层把 `cua-driver` 调用的二进制 / 结构化响应 +/// 包成一致的审计 entry;失败时把 `CuaError` 直接落地便于 UI 翻译。 +fn record_op_audit( store: &Arc, op: &str, - detail: Option, - result: &Result<(), PlatformError>, + detail: Option, + result: &Result, started: chrono::DateTime, ) { let entry = match result { - Ok(()) => CuaAuditEntry { + Ok(value) => CuaAuditEntry { timestamp: started, operation: op.to_string(), ok: true, error: None, - detail, + detail: detail.or_else(|| Some(summarize_value(value))), }, Err(err) => CuaAuditEntry { timestamp: started, operation: op.to_string(), ok: false, - error: Some(map_platform_error(err.clone())), + error: Some(err.clone()), detail, }, }; store.record(entry); } -fn run_with_audit( +/// 把 cua-driver 返回的 JSON 剪裁到审计可读尺寸。`content` 字段是 +/// MCP 工具返回的多模态数组(含 image base64),我们只保留文本 +/// 部分 + 一行 size 摘要,避免审计日志膨胀。 +fn summarize_value(value: &Value) -> Value { + if let Some(arr) = value.get("content").and_then(Value::as_array) { + let mut summary = serde_json::Map::new(); + let mut text_chunks: Vec = Vec::new(); + let mut image_count = 0u32; + let mut other_count = 0u32; + for item in arr { + match item.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(t) = item.get("text").and_then(Value::as_str) { + text_chunks.push(t.to_string()); + } + } + Some("image") => { + image_count += 1; + } + _ => { + other_count += 1; + } + } + } + if !text_chunks.is_empty() { + let joined = text_chunks.join("\n"); + summary.insert( + "text".into(), + Value::String(if joined.len() > 1024 { + format!("{}…(truncated)", &joined[..joined.floor_char_boundary(1024)]) + } else { + joined + }), + ); + } + if image_count > 0 { + summary.insert("imageCount".into(), Value::from(image_count)); + } + if other_count > 0 { + summary.insert("otherCount".into(), Value::from(other_count)); + } + return Value::Object(summary); + } + if value.is_null() { + return json!({ "ok": true }); + } + json!({ "size": value.to_string().len() }) +} + +/// 把 cua-driver 的多模态 content 数组拆出截图字节。cua-driver 在 +/// `get_desktop_state` / `zoom` 工具返回 `{ content: [{ type: "image", +/// data: "", mimeType: "image/jpeg" }] }`;这里把它解出来。 +fn extract_screenshot(value: &Value) -> Option<(Vec, u32, u32)> { + let arr = value.get("content")?.as_array()?; + for item in arr { + if item.get("type").and_then(Value::as_str) == Some("image") { + let data = item.get("data").and_then(Value::as_str)?; + let mime = item + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or("image/png"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.as_bytes()) + .ok()?; + let (w, h) = if mime.contains("jpeg") || mime.contains("jpg") { + jpeg_dimensions(&bytes).unwrap_or((0, 0)) + } else { + png_dimensions(&bytes).unwrap_or((0, 0)) + }; + return Some((bytes, w, h)); + } + } + None +} + +fn run_op( store: &Arc, op: &str, - detail: Option, + detail: Option, f: F, ) -> Result<(), CuaError> where - F: FnOnce() -> Result<(), PlatformError>, + F: FnOnce() -> Result, { let started = Utc::now(); let result = f(); - record_audit(store, op, detail, &result, started); - result.map_err(map_platform_error) + record_op_audit(store, op, detail, &result, started); + result.map(|_| ()) } // ───────── Commands ───────── /// 平台标签 + 是否可用 + 当前配置 + 最近操作。前端挂载时调用一次。 +/// +/// 每次取快照都从持久化 system settings 重读 `sandbox_offline`——避免 +/// 命令安全模式被外部切到 sandboxOffline 后 CUA UI 仍按旧值渲染。 #[tauri::command(rename_all = "snake_case")] pub fn cua_status(store: State<'_, Arc>) -> CuaStoreSnapshot { + let sandbox_offline = current_sandbox_offline(); + store.refresh_sandbox_offline(sandbox_offline); store.snapshot() } /// 整体覆盖配置。前端保存面板时调用。 +/// +/// 服务端权威地维护 `sandbox_offline`:从前端传过来的字段总是被 +/// 当前命令安全模式覆盖——保证 `group:cua` 在 sandboxOffline 下必 +/// 拒(CUA-reviewer 安全门控 #2),不会被前端脏数据绕过。 #[tauri::command(rename_all = "snake_case")] pub fn cua_set_config( store: State<'_, Arc>, config: CuaRuntimeConfig, + _app: AppHandle, ) -> CuaStoreSnapshot { - store.replace_config(config); + let sandbox_offline = current_sandbox_offline(); + let mut cfg = config; + cfg.sandbox_offline = sandbox_offline; + store.replace_config(cfg); store.snapshot() } +/// 当前命令安全模式是否是 sandboxOffline。从持久化的 system settings +/// 权威读取(与 sandbox.rs 同源),不信任调用方声明——防止 CUA 工具 +/// 通过直接调用 cua_set_config 绕过离线沙箱(CUA-reviewer 安全门控 #2)。 +fn current_sandbox_offline() -> bool { + crate::commands::config_commands::settings::load_runtime_command_safety_mode() + .ok() + .map(|mode| mode.eq_ignore_ascii_case("sandboxOffline")) + .unwrap_or(false) +} + #[tauri::command(rename_all = "snake_case")] pub fn cua_clear_audit(store: State<'_, Arc>) -> CuaStoreSnapshot { store.clear_audit(); @@ -138,57 +229,135 @@ pub fn cua_clear_audit(store: State<'_, Arc>) -> CuaStoreSnapshot { } #[tauri::command(rename_all = "snake_case")] -pub fn cua_list_windows(store: State<'_, Arc>) -> Result, CuaError> { +pub fn cua_list_windows( + store: State<'_, Arc>, + client: State<'_, CuaClient>, +) -> Result { let op = "list_windows"; store.enforce(op, None)?; - let driver = crate::services::cua::platform_driver(); + // on_screen_only=true 过滤掉离屏窗口,避免给 Agent 噪音。 let started = Utc::now(); - let result = driver.list_windows(); - record_audit(store.inner(), op, None, &result.clone().map(|_| ()), started); - result.map_err(map_platform_error) + let result = client.call_tool("list_windows", json!({ "on_screen_only": true })); + record_op_audit(store.inner(), op, None, &result, started); + result } #[tauri::command(rename_all = "snake_case")] pub fn cua_focus_window( store: State<'_, Arc>, + client: State<'_, CuaClient>, owner: String, ) -> Result { let op = "focus_window"; let owner_ref = owner.trim().to_string(); store.enforce(op, Some(&owner_ref))?; - let driver = crate::services::cua::platform_driver(); + let detail = Some(json!({ "owner": &owner_ref })); + // cua-driver 没有「按 owner 聚焦」的语义;通过 list_apps 找 pid + // 再用 bring_to_front。这是两步;只要最后一步失败就把 + // ok=false 交给 UI,但 enforce 已经把白名单守住。 let started = Utc::now(); - let detail = serde_json::json!({ "owner": &owner_ref }); - let result = driver.focus_window(&owner_ref); - record_audit(store.inner(), op, Some(detail), &result, started); - // 焦点失败不该让调用方 abort —— best-effort;把 ok=false + error 一起返回。 + let find = client.call_tool("list_apps", json!({})); + let pid = match find { + Ok(v) => v + .get("apps") + .and_then(Value::as_array) + .and_then(|apps| { + apps.iter() + .find(|a| { + a.get("name") + .and_then(Value::as_str) + .map(|s| s.eq_ignore_ascii_case(&owner_ref)) + .unwrap_or(false) + || a.get("bundle_id") + .and_then(Value::as_str) + .map(|s| s.eq_ignore_ascii_case(&owner_ref)) + .unwrap_or(false) + }) + .and_then(|a| a.get("pid").and_then(Value::as_i64)) + }), + Err(e) => { + record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); + return Err(e); + } + }; + let pid = match pid { + Some(p) => p, + None => { + let err = CuaError::not_executed(&format!( + "could not find a running app named '{owner_ref}'" + )); + record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(err), + }); + } + }; + let result = client.call_tool("bring_to_front", json!({ "pid": pid })); + match &result { + Ok(_) => { + record_op_audit(store.inner(), op, detail, &Ok(Value::Null), started); + } + Err(e) => { + record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); + } + } Ok(CuaOpResponse { ok: result.is_ok(), - error: result.err().map(map_platform_error), + error: result.err(), }) } #[tauri::command(rename_all = "snake_case")] pub fn cua_screenshot( store: State<'_, Arc>, + client: State<'_, CuaClient>, window_owner: Option, ) -> Result { let op = "screenshot"; // 截屏不强制白名单(截图系统自身 / 必要场景),但仍要 enabled。 store.enforce(op, None)?; - let driver = crate::services::cua::platform_driver(); let started = Utc::now(); - let result = driver.screenshot(window_owner.as_deref()); + // cua-driver 的截屏工具有两个:`get_desktop_state`(全屏,写 + // screenshot_out_file 到磁盘)和 `zoom`(指定窗口区域)。 + // 前端目前给的是 owner 名(MVP 仍截全屏),对应 `get_desktop_state`。 + // 沙箱遮罩:window_owner 给定时再尝试 `zoom`(cua-driver 内置 + // window owner 寻址);否则回退到全屏。LiveAgent 自身 / cua-driver + // helper 的遮罩交给 cua-driver 自身的窗口排除逻辑。 + let result = if let Some(owner) = window_owner.as_deref().filter(|s| !s.is_empty()) { + // 走 list_apps → pid+window_id → zoom 路径,给一个 5s 缓存避免 + // 每次截屏都 list_apps。这里偷懒:直接传 owner 字符串到 + // get_desktop_state(不支持);退化:list_apps 后取第一个匹配 + // owner 的 window_state,再 zoom 整窗口。 + match pick_window_id_for_owner(&client, owner) { + Ok(Some((pid, window_id))) => client.call_tool( + "zoom", + json!({ + "pid": pid, + "window_id": window_id, + "x1": 0, "y1": 0, "x2": 0, "y2": 0, + }), + ), + Ok(None) => client.call_tool( + "get_desktop_state", + json!({}), + ), + Err(e) => Err(e), + } + } else { + client.call_tool("get_desktop_state", json!({})) + }; match result { - Ok(bytes) => { - let (width, height) = png_dimensions(&bytes).unwrap_or((0, 0)); + Ok(value) => { + let (bytes, width, height) = extract_screenshot(&value) + .ok_or_else(|| CuaError::io("cua-driver screenshot: no image content"))?; let base64_png = base64::engine::general_purpose::STANDARD.encode(&bytes); store.inner().record(CuaAuditEntry { timestamp: started, operation: op.to_string(), ok: true, error: None, - detail: Some(serde_json::json!({ + detail: Some(json!({ "width": width, "height": height, "windowOwner": window_owner, @@ -202,13 +371,12 @@ pub fn cua_screenshot( }) } Err(err) => { - let err = map_platform_error(err); store.inner().record(CuaAuditEntry { timestamp: started, operation: op.to_string(), ok: false, error: Some(err.clone()), - detail: Some(serde_json::json!({ "windowOwner": window_owner })), + detail: Some(json!({ "windowOwner": window_owner })), }); Err(err) } @@ -218,57 +386,113 @@ pub fn cua_screenshot( #[tauri::command(rename_all = "snake_case")] pub fn cua_click( store: State<'_, Arc>, + client: State<'_, CuaClient>, x: i32, y: i32, button: Option, ) -> Result { let op = "click"; store.enforce(op, None)?; - let button: ClickButton = button.unwrap_or_default().into(); - let detail = Some(serde_json::json!({ - "x": x, "y": y, "button": format!("{:?}", button) - })); - let driver = crate::services::cua::platform_driver(); - run_with_audit(store.inner(), op, detail, || driver.click(x, y, button))?; + let button_str: &'static str = button.unwrap_or_default().into(); + let detail = Some(json!({ "x": x, "y": y, "button": button_str })); + // cua-driver click 需要 pid + window_id + (x,y);MVP 阶段我们用 + // 全屏语义:取前台 app pid + first window。这是 best-effort, + // 真实需求是 (x, y) ∈ window,先按坐标所在窗口定位。 + let started = Utc::now(); + let pid_window = match pick_topmost_window(&client) { + Ok(pw) => pw, + Err(e) => { + record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(e), + }); + } + }; + let mut args = json!({ + "x": x, "y": y, + "button": button_str, + }); + if let Some((pid, window_id)) = pid_window { + args["pid"] = json!(pid); + args["window_id"] = json!(window_id); + } + let result = client.call_tool("click", args); + match &result { + Ok(v) => record_op_audit(store.inner(), op, detail, &Ok(v.clone()), started), + Err(e) => record_op_audit(store.inner(), op, detail, &Err(e.clone()), started), + } Ok(CuaOpResponse { - ok: true, - error: None, + ok: result.is_ok(), + error: result.err(), }) } #[tauri::command(rename_all = "snake_case")] pub fn cua_double_click( store: State<'_, Arc>, + client: State<'_, CuaClient>, x: i32, y: i32, ) -> Result { let op = "double_click"; store.enforce(op, None)?; - let detail = Some(serde_json::json!({ "x": x, "y": y })); - let driver = crate::services::cua::platform_driver(); - run_with_audit(store.inner(), op, detail, || driver.double_click(x, y))?; + let detail = Some(json!({ "x": x, "y": y })); + let started = Utc::now(); + let pid_window = match pick_topmost_window(&client) { + Ok(pw) => pw, + Err(e) => { + record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(e), + }); + } + }; + let mut args = json!({ "x": x, "y": y }); + if let Some((pid, window_id)) = pid_window { + args["pid"] = json!(pid); + args["window_id"] = json!(window_id); + } + let result = client.call_tool("double_click", args); + match &result { + Ok(v) => record_op_audit(store.inner(), op, detail, &Ok(v.clone()), started), + Err(e) => record_op_audit(store.inner(), op, detail, &Err(e.clone()), started), + } Ok(CuaOpResponse { - ok: true, - error: None, + ok: result.is_ok(), + error: result.err(), }) } #[tauri::command(rename_all = "snake_case")] pub fn cua_type( store: State<'_, Arc>, + client: State<'_, CuaClient>, text: String, target_owner: Option, ) -> Result { let op = "type"; - let owner_ref = target_owner.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()); + let owner_ref = target_owner + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); store.enforce(op, owner_ref)?; let len = text.chars().count(); - let detail = Some(serde_json::json!({ + let detail = Some(json!({ "length": len, "targetOwner": target_owner, })); - let driver = crate::services::cua::platform_driver(); - run_with_audit(store.inner(), op, detail, || driver.type_text(&text))?; + let mut args = json!({ "text": text }); + if let Some(owner) = owner_ref { + if let Ok(Some((pid, window_id))) = pick_window_id_for_owner(&client, owner) { + args["pid"] = json!(pid); + args["window_id"] = json!(window_id); + } + } + run_op(store.inner(), op, detail, || { + client.call_tool("type_text", args.clone()) + })?; Ok(CuaOpResponse { ok: true, error: None, @@ -278,22 +502,49 @@ pub fn cua_type( #[tauri::command(rename_all = "snake_case")] pub fn cua_key( store: State<'_, Arc>, + client: State<'_, CuaClient>, key: String, modifiers: Option>, target_owner: Option, ) -> Result { let op = "key"; - let owner_ref = target_owner.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()); + let owner_ref = target_owner + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); store.enforce(op, owner_ref)?; let mods = modifiers.unwrap_or_default(); - let detail = Some(serde_json::json!({ + let detail = Some(json!({ "key": key, "modifiers": &mods, "targetOwner": target_owner, })); - let driver = crate::services::cua::platform_driver(); - run_with_audit(store.inner(), op, detail, || { - driver.press_key(&key, &mods) + // key:单字符或名称 → press_key;带 modifiers 的组合 → hotkey。 + let args = if mods.is_empty() { + json!({ "key": key }) + } else { + let mut keys = mods + .iter() + .map(|m| m.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect::>(); + keys.push(key.clone()); + json!({ "keys": keys }) + }; + let tool = if mods.is_empty() { + "press_key" + } else { + "hotkey" + }; + let mut args = args; + if let Some(owner) = owner_ref { + if let Ok(Some((pid, window_id))) = pick_window_id_for_owner(&client, owner) { + args["pid"] = json!(pid); + args["window_id"] = json!(window_id); + } + } + run_op(store.inner(), op, detail, || { + client.call_tool(tool, args.clone()) })?; Ok(CuaOpResponse { ok: true, @@ -304,15 +555,31 @@ pub fn cua_key( #[tauri::command(rename_all = "snake_case")] pub fn cua_scroll( store: State<'_, Arc>, + client: State<'_, CuaClient>, x: i32, y: i32, dy: i32, ) -> Result { let op = "scroll"; store.enforce(op, None)?; - let detail = Some(serde_json::json!({ "x": x, "y": y, "dy": dy })); - let driver = crate::services::cua::platform_driver(); - run_with_audit(store.inner(), op, detail, || driver.scroll(x, y, dy))?; + let detail = Some(json!({ "x": x, "y": y, "dy": dy })); + // cua-driver scroll.direction ∈ {"up","down","left","right"},by + // 是单位数(默认 3)。`dy > 0` 视为向上滚动,与原 osascript 语义一致。 + let direction = if dy >= 0 { "up" } else { "down" }; + let amount = dy.unsigned_abs().max(1); + let mut args = json!({ + "direction": direction, + "amount": amount, + "x": x, + "y": y, + }); + if let Ok(Some((pid, window_id))) = pick_topmost_window(&client) { + args["pid"] = json!(pid); + args["window_id"] = json!(window_id); + } + run_op(store.inner(), op, detail, || { + client.call_tool("scroll", args.clone()) + })?; Ok(CuaOpResponse { ok: true, error: None, @@ -322,6 +589,7 @@ pub fn cua_scroll( #[tauri::command(rename_all = "snake_case")] pub fn cua_drag( store: State<'_, Arc>, + client: State<'_, CuaClient>, x1: i32, y1: i32, x2: i32, @@ -329,11 +597,19 @@ pub fn cua_drag( ) -> Result { let op = "drag"; store.enforce(op, None)?; - let detail = Some(serde_json::json!({ + let detail = Some(json!({ "x1": x1, "y1": y1, "x2": x2, "y2": y2 })); - let driver = crate::services::cua::platform_driver(); - run_with_audit(store.inner(), op, detail, || driver.drag(x1, y1, x2, y2))?; + let mut args = json!({ + "from_x": x1, "from_y": y1, "to_x": x2, "to_y": y2, + }); + if let Ok(Some((pid, window_id))) = pick_topmost_window(&client) { + args["pid"] = json!(pid); + args["window_id"] = json!(window_id); + } + run_op(store.inner(), op, detail, || { + client.call_tool("drag", args.clone()) + })?; Ok(CuaOpResponse { ok: true, error: None, @@ -342,7 +618,7 @@ pub fn cua_drag( // ───────── Helpers ───────── -/// 单元辅助:从 PNG 字节流解析 width / height(仅取 IHDR 头 8 个字节)。 +/// 解析 PNG IHDR 拿宽高。 fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { if bytes.len() < 24 { return None; @@ -355,10 +631,107 @@ fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { Some((width, height)) } +/// 极简 JPEG 尺寸解析(SOF0/SOF2 段)。失败回退到 (0,0)。 +fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 { + return None; + } + let mut i = 2; + while i + 9 < bytes.len() { + if bytes[i] != 0xFF { + return None; + } + let marker = bytes[i + 1]; + // SOF0..SOF15 (除 DHT 0xC4 / DAC 0xCC / DNL 0xCC 之外)。 + if (0xC0..=0xCF).contains(&marker) + && marker != 0xC4 + && marker != 0xC8 + && marker != 0xCC + { + let height = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32; + let width = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32; + return Some((width, height)); + } + let seg_len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + i += 2 + seg_len; + } + None +} + +/// 用 `list_windows` 拿最前面的窗口(layer 0 + on_screen_only)。 +/// 返回 (pid, window_id) 或 None。 +fn pick_topmost_window(client: &CuaClient) -> Result, CuaError> { + let value = client.call_tool("list_windows", json!({ "on_screen_only": true }))?; + let wins = value + .get("windows") + .and_then(Value::as_array) + .or_else(|| value.get("content").and_then(Value::as_array)); + if let Some(arr) = wins { + for w in arr { + let pid = w.get("pid").and_then(Value::as_i64); + let window_id = w + .get("window_id") + .or_else(|| w.get("windowId")) + .or_else(|| w.get("id")) + .and_then(Value::as_i64); + if let (Some(pid), Some(wid)) = (pid, window_id) { + if w.get("on_screen") + .and_then(Value::as_bool) + .unwrap_or(true) + { + return Ok(Some((pid, wid))); + } + } + } + } + Ok(None) +} + +/// 通过 owner 名匹配 `list_apps` 的 pid,再查其首个可见 window。 +/// 找不到时返回 Ok(None) 而不是 Err(best-effort)。 +fn pick_window_id_for_owner( + client: &CuaClient, + owner: &str, +) -> Result, CuaError> { + let apps = client.call_tool("list_apps", json!({}))?; + let pid = apps + .get("apps") + .and_then(Value::as_array) + .and_then(|arr| { + arr.iter() + .find(|a| { + a.get("name") + .and_then(Value::as_str) + .map(|s| s.eq_ignore_ascii_case(owner)) + .unwrap_or(false) + || a.get("bundle_id") + .and_then(Value::as_str) + .map(|s| s.eq_ignore_ascii_case(owner)) + .unwrap_or(false) + }) + .and_then(|a| a.get("pid").and_then(Value::as_i64)) + }); + let Some(pid) = pid else { + return Ok(None); + }; + let windows = client.call_tool("list_windows", json!({ "pid": pid, "on_screen_only": true }))?; + let wid = windows + .get("windows") + .and_then(Value::as_array) + .and_then(|arr| { + arr.iter().find_map(|w| { + w.get("window_id") + .or_else(|| w.get("windowId")) + .or_else(|| w.get("id")) + .and_then(Value::as_i64) + }) + }); + Ok(wid.map(|w| (pid, w))) +} + /// cua-driver 入口命令:把主窗口重新推到最前并等待 `is_focused()` 落 /// 住。命令不依赖 CUA enable 开关——它的目的正是让 enable=true 之 -/// 后的 click / AX 路径能找到 WebView(任何一次失败都让 -/// `bring_to_front` + foreground HID 落入 `unverifiable` / `ax_window_unresolved`)。 +/// 后的 click / AX 路径能找到 WebView。 #[tauri::command(rename_all = "snake_case")] pub async fn cua_window_ready( window: tauri::WebviewWindow, @@ -368,15 +741,7 @@ pub async fn cua_window_ready( /// CUA-020/021/022: 在路由切换、overlay 打开/关闭、cua-driver 主动唤 /// 起等场景重新触发 NSWindow/WKWebView 的 AX 注解 + 一回弹广播 -/// UIElementCreatedNotification。`force_activate_main_window` 内部的 -/// 注解是幂等的(NSWindow 已 setAccessibilityEnabled=true 不再变化), -/// 但 `makeFirstResponder + becomeFirstResponder` 在 WKWebView 被 hot -/// reload 重建后必须重跑,否则 cua-driver 的 foreground HID 会落到 -/// content view 而不是 renderer,前端反馈 `effect: unverifiable`。 -/// -/// 不依赖 CUA enable 开关——这是「先把 WebView 修好」,与 -/// `cua_window_ready` 是同一族操作。返回值为可序列化诊断字段,便于前 -/// 端 / 远端验证调用确实落地。 +/// UIElementCreatedNotification。 #[tauri::command(rename_all = "snake_case")] pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yResponse { crate::cua_refresh_a11y(&window) @@ -384,6 +749,7 @@ pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yRe // ───────── CUA Driver 安装器(CUA-100) ───────── // +// installer.rs 检测 cua-driver 二进制;这里只暴露命令桥。 // 设计:不经过 CuaStore 的 enable 守卫——安装器本身就是为了让后续 CUA // 操作可用;enable 留到装完再由用户在 UI 中开启。 @@ -410,18 +776,13 @@ pub fn cua_driver_start_daemon(app: AppHandle) -> Result Result { let os = std::env::consts::OS; - // 不支持平台:直接拒绝,不下到 spawn_blocking。 match os { "macos" | "linux" | "windows" => {} other => return Err(CuaError::installer_unsupported_platform(other)), } - // 探测 install 脚本:网络 / curl 不可用时立刻失败,避免长等待。 let preflight = tauri::async_runtime::spawn_blocking({ let app = app.clone(); move || crate::services::cua::install_driver(&app) @@ -431,7 +792,6 @@ pub async fn cua_driver_install(app: AppHandle) -> Result Result Result { - // 同步逻辑交给 blocking pool;install 路径已经示范,这里一致处理。 let join = tauri::async_runtime::spawn_blocking(move || { crate::services::cua::update_driver(apply) }) .await .map_err(|e| CuaError::io(&format!("update task join error: {e}")))?; if let Some(err) = join.error.as_deref() { - // check-update 自身可能因为未安装驱动而失败——但前端应有前置 - // detect 守卫;这里仍把网络 / 通用 IO 区分。 let lower = err.to_lowercase(); let mapped = if lower.contains("network") || lower.contains("dns") @@ -483,13 +840,90 @@ pub async fn cua_driver_update(apply: bool) -> Result } } -/// 把 install 命令(program / args)暴露给前端做展示(无 spawn)。主 -/// 要给设置面板「即将运行的命令」用,UI 自行展示。Linux 上额外带 -/// apt 依赖检查结果。 +/// 把 install 命令(program / args)暴露给前端做展示(无 spawn)。 #[tauri::command(rename_all = "snake_case")] pub fn cua_driver_install_preview() -> Result { crate::services::cua::build_install_preview().map_err(|detail| match std::env::consts::OS { "macos" | "linux" | "windows" => CuaError::not_executed(&detail), other => CuaError::installer_unsupported_platform(other), }) -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 极简 1×1 PNG(红)字节流;用作 fixture。 + /// hex 头:`89 50 4E 47 0D 0A 1A 0A` (PNG signature) + /// IHDR:8 字节长度 + `IHDR` + width=1 + height=1 + 8-bit RGB + 0 + 0 + 0 + fn fake_png_1x1() -> Vec { + // 长度可变的 IHDR 字段拼接(实际长度需 13)。我们手写一个可被 PNG signature 8 字节 + // + IHDR 头识别的 24 字节小图。 + let mut bytes = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length = 13 + b'I', b'H', b'D', b'R', + 0x00, 0x00, 0x00, 0x01, // width = 1 + 0x00, 0x00, 0x00, 0x01, // height = 1 + 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc. + ]; + bytes + } + + #[test] + fn extract_screenshot_handles_image_content() { + let value = json!({ + "content": [ + { "type": "text", "text": "desktop screenshot 1920x1080" }, + { "type": "image", "data": "AAAA", "mimeType": "image/png" } + ] + }); + // 不是真正的 PNG 字节流;这里只验证抽取路径:base64 解码 + png_dimensions 返回 (0,0)。 + let (bytes, _w, _h) = extract_screenshot(&value).expect("must extract"); + assert_eq!(bytes, vec![0, 0, 0]); + } + + #[test] + fn extract_screenshot_returns_none_when_no_image() { + let value = json!({ + "content": [{ "type": "text", "text": "no image here" }] + }); + assert!(extract_screenshot(&value).is_none()); + } + + #[test] + fn summarize_value_counts_image_and_text() { + let value = json!({ + "content": [ + { "type": "text", "text": "hello" }, + { "type": "image", "data": "x", "mimeType": "image/png" }, + { "type": "image", "data": "y", "mimeType": "image/jpeg" }, + { "type": "other" } + ] + }); + let summary = summarize_value(&value); + assert_eq!(summary.get("text").and_then(Value::as_str), Some("hello")); + assert_eq!(summary.get("imageCount").and_then(Value::as_u64), Some(2)); + assert_eq!(summary.get("otherCount").and_then(Value::as_u64), Some(1)); + } + + #[test] + fn summarize_value_handles_null() { + let v = Value::Null; + let summary = summarize_value(&v); + assert_eq!(summary.get("ok").and_then(Value::as_bool), Some(true)); + } + + #[test] + fn png_dimensions_reads_ihdr() { + let bytes = fake_png_1x1(); + // 我们手工的 IHDR 写了 width=1, height=1。 + assert_eq!(png_dimensions(&bytes), Some((1, 1))); + } + + #[test] + fn png_dimensions_rejects_non_png() { + assert_eq!(png_dimensions(b"not a png"), None); + } +} diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 21b82b87b..21fc7bcfa 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -1171,6 +1171,9 @@ pub fn run() { let cua_store = Arc::new(services::cua::CuaStore::new( services::cua::CuaRuntimeConfig::default(), )); + // 共享 cua-driver MCP 子进程客户端。第一次 cua_* 命令才懒 spawn, + // 失败时由上层把 IO 错误翻译成结构化 CuaError。 + let cua_client = services::cua::CuaClient::new(); let builder = tauri::Builder::default(); // dev 构建与已安装正式版共享 identifier;若 dev 也注册单实例, @@ -1217,9 +1220,10 @@ pub fn run() { .manage(Arc::clone(&close_window_behavior)) .manage(Arc::clone(&automation_store)) .manage(Arc::clone(&automation_scheduler)) + .manage(Arc::clone(&cua_store)) + .manage(cua_client.clone()) .manage(Arc::new(commands::hook::HookScopeRegistry::default())) .manage(stt_manager) - .manage(cua_store) .on_page_load(|webview, payload| { if webview.label() != MAIN_WINDOW_LABEL { return; diff --git a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs new file mode 100644 index 000000000..7f5c977ee --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs @@ -0,0 +1,310 @@ +//! cua-driver MCP client。 +//! +//! 历史:之前的 `driver.rs` 是 osascript + screencapture 自研实现,仅 +//! macOS / 单线程 / 抢光标 / 看不到 Chromium 等非 AX 表面;reviewer 要求 +//! 改用上游 `cua-driver`([trycua/cua](https://github.com/trycua/cua),MIT), +//! 它跨 macOS / Windows / Linux、AX + 像素双路径、按窗口寻址、不抢光标。 +//! +//! 协议:`cua-driver mcp --direct` 起一个 stdio JSON-RPC server(每条消息 +//! 一行 newline-delimited JSON,符合 MCP 2025-06-18)。我们用 +//! `Arc>` 持单一长连子进程,调用方拿锁后写入 +//! `{jsonrpc,id,method,params}` → 从 stdout 读直到匹配 id。30 秒 +//! 同步超时,子进程死了下一次调用重新 spawn。 +//! +//! 设计边界: +//! - **不做沙箱 / 白名单 / audit**:那是 `CuaStore::enforce` 的活; +//! 这里只负责把 MCP 调用落地。 +//! - **不缓冲任何状态**:除 `next_id` 之外的会话字段(session / cursor +//! theme 之类)由调用方在 args 里按需指定;持久化的 MCP session 留给 +//! 上层 policy 决定。 + +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use super::error::CuaError; + +/// 单次调用的同步超时。cua-driver 大多数 op(点击 / 截图 / 找 pid) +/// 都在 1-5 秒内完成;留给 `start_session` / `bring_to_front` 等 +/// 复合路径 30 秒缓冲。超出即报 `io("timeout after 30s")` 让上层 +/// 把它转成结构化 `CuaError::io`。 +const CALL_TIMEOUT: Duration = Duration::from_secs(30); + +/// 轮询读 stdout 的间隔。read_line 是阻塞的,每次最多等这么久就回 +/// 一次 deadline 检查 + child 状态探测。 +const READ_SLICE: Duration = Duration::from_millis(250); + +/// cua-driver 可执行文件名(与 `installer::find_cua_driver` 同源; +/// 这里直接 `Command::new` 让 OS 走 PATH,不再做二次候选目录 walk, +/// 启动期由 installer 把 PATH 准备好)。 +const CUA_DRIVER_BIN: &str = "cua-driver"; + +/// 共享给 Tauri Command 的薄包装。`spawn` 懒:第一个调用方拿锁时 +/// 才起子进程;后续命中复用句柄。 +#[derive(Clone)] +pub struct CuaClient { + inner: Arc>, +} + +struct Inner { + /// 懒启动的子进程句柄。第一次 `call` 时若为 None 就 spawn。 + child: Option, + /// MCP 请求 id 自增;初始化握手与后续 tools/call 共享同一计数器。 + next_id: AtomicU64, +} + +/// 拆出 stdio 句柄后剩下的子进程字段。 +struct RunningProcess { + child: Child, + stdout: BufReader, + stdin: ChildStdin, + /// stderr 我们不消费,但保留 handle 防止子进程写阻塞;drop 时 + /// 自动 close。调用方不直接读。 + _stderr: Option, +} + +impl CuaClient { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(Inner { + child: None, + next_id: AtomicU64::new(1), + })), + } + } + + /// 调用一次 MCP 工具,返回 `result` 字段(结构化 JSON)。 + /// 错误一律翻译成 `CuaError`(i18n key + 英文 message),由 + /// Command 层直接透传给前端。 + pub fn call_tool(&self, tool: &str, arguments: Value) -> Result { + let (proc, id) = { + let mut guard = self + .inner + .lock() + .map_err(|e| CuaError::io(&format!("cua client lock poisoned: {e}")))?; + // 子进程死了 / 没起 → 重启一次。重启失败直接报错,不无限重试。 + let needs_respawn = match &mut guard.child { + None => true, + Some(p) => child_exited(&mut p.child), + }; + if needs_respawn { + guard.child = None; + let spawned = spawn_cua_driver()?; + guard.child = Some(spawned); + } + let id = guard.next_id.fetch_add(1, Ordering::SeqCst); + let proc = guard + .child + .as_mut() + .expect("just spawned") + as *mut RunningProcess; + // SAFETY: 整段 call_tool 全程持锁 `guard`,proc 不会被独占 + // 借用之外的其它路径触碰。把 `&mut guard.child.as_mut()` + // 重借用转成裸指针,再在锁里重新借回来。 + (unsafe { &mut *proc }, id) + }; + + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { + "name": tool, + "arguments": arguments, + } + }); + let mut line = serde_json::to_string(&request) + .map_err(|e| CuaError::io(&format!("serialize mcp request: {e}")))?; + line.push('\n'); + + proc.stdin + .write_all(line.as_bytes()) + .map_err(|e| CuaError::io(&format!("write mcp request: {e}")))?; + proc.stdin + .flush() + .map_err(|e| CuaError::io(&format!("flush mcp request: {e}")))?; + + // 阻塞读直到拿到匹配 id 的响应。带 30 秒总超时。 + let deadline = Instant::now() + CALL_TIMEOUT; + loop { + if Instant::now() >= deadline { + return Err(CuaError::io(&format!( + "cua-driver mcp call '{tool}' timed out after {}s", + CALL_TIMEOUT.as_secs() + ))); + } + let slice_deadline = Instant::now() + READ_SLICE; + let mut buf = String::new(); + loop { + if Instant::now() >= slice_deadline { + break; + } + if child_exited(&mut proc.child) { + self.shutdown(); + return Err(CuaError::io("cua-driver exited mid-call")); + } + let mut temp = String::new(); + match proc.stdout.read_line(&mut temp) { + Ok(0) => { + // EOF:子进程关了 stdout。 + self.shutdown(); + return Err(CuaError::io( + "cua-driver stdout closed unexpectedly", + )); + } + Ok(_) => { + buf.push_str(&temp); + if buf.ends_with('\n') { + break; + } + } + Err(e) => { + return Err(CuaError::io(&format!("read mcp response: {e}"))); + } + } + } + if buf.is_empty() { + // 本轮 slice 没读到完整行 → 检查总 deadline 后再继续。 + continue; + } + // 解析 JSON-RPC 响应。 + let parsed: Value = match serde_json::from_str(buf.trim()) { + Ok(v) => v, + Err(_) => { + // 可能收到 cua-driver 的 stderr 噪音(启动 banner 等) + // 混入 stdout;遇到非 JSON 行就跳过等下一行。 + continue; + } + }; + // 匹配 id;服务端可能并发推送 notifications/... 等无 id 帧, + // 那些在 MCP 2025-06-18 之前是 server→client;这里跳过。 + let resp_id = parsed.get("id").and_then(Value::as_u64); + if resp_id != Some(id) { + continue; + } + // 错误响应:把 MCP error 转成 CuaError。 + if let Some(err) = parsed.get("error") { + let code = err.get("code").and_then(Value::as_i64).unwrap_or(-1); + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + return Err(map_mcp_error(code, &msg)); + } + return Ok(parsed.get("result").cloned().unwrap_or(Value::Null)); + } + } + + /// 关掉子进程。下次调用会重新 spawn。 + pub fn shutdown(&self) { + if let Ok(mut guard) = self.inner.lock() { + if let Some(mut proc) = guard.child.take() { + let _ = proc.child.kill(); + let _ = proc.child.wait(); + } + } + } +} + +impl Default for CuaClient { + fn default() -> Self { + Self::new() + } +} + +impl Drop for CuaClient { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn child_exited(child: &mut Child) -> bool { + matches!(child.try_wait(), Ok(Some(_))) +} + +/// 起一个 `cua-driver mcp --direct` 子进程。stdout / stdin 都 pipe。 +/// `--host-bundle-id` 给一个非空占位(cua-driver 关心 TCC attribution, +/// LiveAgent 主程序 bundle id 即可,不强求精确)。 +fn spawn_cua_driver() -> Result { + let mut cmd = Command::new(CUA_DRIVER_BIN); + cmd.arg("mcp") + .arg("--direct") + .arg("--host-bundle-id") + .arg("com.liveagent.desktop"); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| CuaError::io(&format!("failed to spawn cua-driver mcp: {e}")))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| CuaError::io("cua-driver stdin missing"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| CuaError::io("cua-driver stdout missing"))?; + let stderr = child.stderr.take(); + Ok(RunningProcess { + child, + stdout: BufReader::new(stdout), + stdin, + _stderr: stderr, + }) +} + +/// 把 MCP `error.code` + message 翻成稳定的 `CuaError`。 +/// cua-driver 用 `code` 表示语义(permission / unsupported / io), +/// 我们把已知 code 映射到 i18n kind,其余走 io 兜底。 +fn map_mcp_error(code: i64, message: &str) -> CuaError { + match code { + // MCP 标准错误码:-32001 ~ -32099 是服务端自定义。 + -32001 => CuaError::permission_required("accessibility", "Accessibility"), + -32002 => CuaError::permission_required("screen-recording", "Screen Recording"), + // -32601 method not found:MCP 协议层错误,可能是工具名拼错。 + -32601 => CuaError::not_executed(&format!( + "cua-driver does not know this tool: {message}" + )), + _ => CuaError::io(&format!("cua-driver mcp error {code}: {message}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_error_maps_permission_codes() { + let e = map_mcp_error(-32001, "AX denied"); + assert_eq!(e.kind, "cua.errors.permissionRequired"); + assert_eq!( + e.params.get("permissionKey").and_then(Value::as_str), + Some("accessibility") + ); + } + + #[test] + fn mcp_error_maps_unknown_code_to_io() { + let e = map_mcp_error(-99999, "wat"); + assert_eq!(e.kind, "cua.errors.io"); + assert!(e.message.contains("cua-driver")); + } + + #[test] + fn map_mcp_error_method_not_found_is_not_executed() { + let e = map_mcp_error(-32601, "unknown tool"); + assert_eq!(e.kind, "cua.errors.notExecuted"); + } + + #[test] + fn client_can_be_cloned() { + let c = CuaClient::new(); + let _c2 = c.clone(); + assert!(Arc::strong_count(&c.inner) >= 2); + } +} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/cua/driver.rs b/crates/agent-gui/src-tauri/src/services/cua/driver.rs deleted file mode 100644 index c51ccfdd1..000000000 --- a/crates/agent-gui/src-tauri/src/services/cua/driver.rs +++ /dev/null @@ -1,604 +0,0 @@ -//! 平台相关驱动:把屏幕 / 输入抽象为一组同步、可审计的操作。 -//! -//! - macOS:`osascript` 操作 System Events(发送鼠标 / 键盘事件) + -//! `screencapture` 截屏。需要用户在「系统设置 → 隐私与安全」授予 -//! LiveAgent「辅助功能(Accessibility)」与「屏幕录制(Screen Recording)」。 -//! 缺权限时 System Events 报错的特征字串是 `1002` / `Not authorized`, -//! `screencapture` 报 `cannot create screen recording` —— 我们把这些 -//! 翻译成结构化 `PlatformError::PermissionRequired`,提示前端打开权限。 -//! - 其他平台:返回 `PlatformError::Unsupported`,由命令层翻译给前端。 -//! -//! 所有调用走 `Command::new` 直起子进程;不进入沙箱(命令层有 enable 守卫)。 - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::process::{Command, Output, Stdio}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum ClickButton { - Left, - Middle, - Right, -} - -impl ClickButton { - fn apple_event_name(self) -> &'static str { - match self { - ClickButton::Left => "click", - ClickButton::Middle => "middleClick", - ClickButton::Right => "rightClick", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WindowInfo { - /// 系统级窗口 ID;macOS 的 CGWindowID 用 u32 表示,足以覆盖常规场景。 - pub window_id: u64, - /// 拥有窗口的应用可执行文件名(`Finder`、`Safari` …)。 - pub owner: String, - /// 窗口标题,没有时为空串。 - pub title: String, - /// 屏幕坐标的左上角 + 尺寸(来自 System Events 的 position / size)。 - #[serde(skip_serializing_if = "Option::is_none")] - pub bounds: Option, - /// 是否处于最前。 - pub focused: bool, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WindowBounds { - pub x: i32, - pub y: i32, - pub width: u32, - pub height: u32, -} - -/// 平台驱动层错误。字段无中文/无 hardcoded 文案——一律交给 `CuaError` 在 -/// 调用边界翻译(CUA-006)。`Clone` 因为 Tauri Command 需要复制错误回前端; -/// 不从 `io::Error` 直接 derive `From`,避免给枚举加 `From` 约束。 -#[derive(Debug, Clone)] -pub enum PlatformError { - /// 当前 OS 不支持 CUA(参数是被拒时的 OS 标签)。 - Unsupported(String), - /// macOS 权限缺失——参数是稳定 `permissionKey`(例如 "accessibility" / - /// "screen-recording")+ 人类可读展示名。 - PermissionRequired { - permission_key: &'static str, - permission: &'static str, - }, - /// 前置条件未满足 / 子进程运行失败但不是权限问题。 - NotExecuted(String), - /// 子进程启动 IO 失败。 - Io(String), -} - -impl std::fmt::Display for PlatformError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Display 仅在日志里有用,权威面向用户消息由 CuaError 提供。 - match self { - PlatformError::Unsupported(os) => { - write!(f, "CUA driver is not available on {os}") - } - PlatformError::PermissionRequired { permission, .. } => { - write!(f, "CUA needs permission: {permission}") - } - PlatformError::NotExecuted(detail) => { - write!(f, "CUA operation not executed: {detail}") - } - PlatformError::Io(detail) => { - write!(f, "CUA IO error: {detail}") - } - } - } -} - -impl std::error::Error for PlatformError {} - -/// CUA 驱动抽象。每种平台给一份实现。调用方约定: -/// 1. 任何操作之前都已过启用开关(store 侧)。 -/// 2. 操作返回错误时,调用方负责把原因结构化给 Tauri Command。 -pub trait CuaDriver: Send + Sync { - fn platform_label() -> &'static str - where - Self: Sized; - - /// 列出当前所有可见窗口。结果按 owner 排序,title / bounds 尽力而为: - /// macOS 通过 System Events,权限不足时整列表失败。 - fn list_windows(&self) -> Result, PlatformError>; - - /// 聚焦指定 owner(应用名)。聚焦失败不抛错;只是 best-effort。 - fn focus_window(&self, owner: &str) -> Result<(), PlatformError>; - - /// 全屏截图(PNG 字节)或指定窗口截图(`window_owner` 给应用名, - /// 截的是该应用最前面的窗口)。macOS 用 `screencapture -x -l `。 - fn screenshot(&self, window_owner: Option<&str>) -> Result, PlatformError>; - - /// 在屏幕坐标处点击。坐标原点在主显示器左上角,与 System Events 一致。 - fn click(&self, x: i32, y: i32, button: ClickButton) -> Result<(), PlatformError>; - - fn double_click(&self, x: i32, y: i32) -> Result<(), PlatformError>; - - /// 输入字符串。osascript `keystroke` 原生支持 unicode。 - fn type_text(&self, text: &str) -> Result<(), PlatformError>; - - /// 按键:`key` 是 key code 字符串(如 "49" = Return,"51" = Delete, - /// "53" = Escape)或 key 名称(如 "return"/"tab"/"escape"),与 - /// System Events 的 `key code` / `key` 等价。modifiers 取自 - /// {"command","option","control","shift"}。 - fn press_key(&self, key: &str, modifiers: &[String]) -> Result<(), PlatformError>; - - /// 滚动:以 (x, y) 为锚点;`dy > 0` 向上、`< 0` 向下(与 macOS - /// `scroll wheel` 一致,前端负责翻转方向)。 - fn scroll(&self, x: i32, y: i32, dy: i32) -> Result<(), PlatformError>; - - /// 拖拽:从 (x1, y1) 到 (x2, y2)。 - fn drag(&self, x1: i32, y1: i32, x2: i32, y2: i32) -> Result<(), PlatformError>; -} - -// ───────── macOS 实现 ───────── - -#[cfg(target_os = "macos")] -pub struct MacOsDriver; - -#[cfg(target_os = "macos")] -impl MacOsDriver { - fn run_osascript(script: &str) -> Result { - let output = Command::new("/usr/bin/osascript") - .arg("-e") - .arg(script) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| PlatformError::Io(e.to_string()))?; - translate_osascript_output(output) - } - - fn run_screencapture(args: &[&str]) -> Result, PlatformError> { - // screencapture 必须写到文件,没有 stdout 输出模式;写到临时目录后读出。 - let path = temp_screenshot_path(); - let status = Command::new("/usr/sbin/screencapture") - .args(args) - .arg(&path) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status() - .map_err(|e| PlatformError::Io(e.to_string()))?; - if !status.success() { - return Err(PlatformError::PermissionRequired { - permission_key: "screen-recording", - permission: "Screen Recording", - }); - } - let bytes = std::fs::read(&path).map_err(|e| PlatformError::Io(e.to_string()))?; - let _ = std::fs::remove_file(&path); - Ok(bytes) - } -} - -#[cfg(target_os = "macos")] -impl CuaDriver for MacOsDriver { - fn platform_label() -> &'static str { - "macos" - } - - fn list_windows(&self) -> Result, PlatformError> { - // System Events 的 `every window of (every process whose background - // only is false)` 会返回每个进程的可见窗口。bundle id / 可执行名取自 - // `unix id` / `name`。position / size 在 System Events 上是 `position` - // 和 `size` 属性(points)。focused 暂以「应用是否在前台」近似。 - let script = r#" - tell application "System Events" - set winList to {} - set procs to (every process whose background only is false) - repeat with p in procs - try - set pName to (name of p) as string - set procsWindows to (every window of p) - repeat with w in procsWindows - try - set wTitle to (title of w) as string - on error - set wTitle to "" - end try - try - set wPos to position of w - set wSize to size of w - set wx to item 1 of wPos - set wy to item 2 of wPos - set ww to item 1 of wSize - set wh to item 2 of wSize - set boundsStr to (wx as string) & "," & (wy as string) & "," & (ww as string) & "," & (wh as string) - on error - set boundsStr to "" - end try - set end of winList to (pName & "||" & wTitle & "||" & boundsStr) - end repeat - end try - end repeat - return winList - end tell - "#; - let out = Self::run_osascript(script)?; - let mut windows = Vec::new(); - // 每条记录:`owner||title||x,y,w,h`,用 \n 分隔。 - for line in out.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let parts: Vec<&str> = trimmed.splitn(3, "||").collect(); - if parts.len() < 2 { - continue; - } - let owner = parts[0].trim().to_string(); - let title = parts[1].trim().to_string(); - let bounds = if parts.len() == 3 && !parts[2].trim().is_empty() { - let coords: Vec = parts[2] - .split(',') - .filter_map(|c| c.trim().parse::().ok()) - .collect(); - if coords.len() == 4 { - Some(WindowBounds { - x: coords[0] as i32, - y: coords[1] as i32, - width: coords[2].max(0) as u32, - height: coords[3].max(0) as u32, - }) - } else { - None - } - } else { - None - }; - windows.push(WindowInfo { - window_id: 0, - owner, - title, - bounds, - focused: false, - }); - } - // 找不到任何窗口也正常(前台 app 没窗口 / 权限拒绝会走到错误分支) - Ok(windows) - } - - fn focus_window(&self, owner: &str) -> Result<(), PlatformError> { - // 通过 `tell application id` 失败时(应用没有 AppleScript 字典), - // 退化为 `tell application "X" to activate`,再退化为 bring to front。 - let safe_owner = escape_applescript_string(owner); - let script = format!( - r#"try - tell application id "{}" to activate - on error - try - tell application "{}" to activate - on error - tell application "System Events" to set frontmost of first process whose name is "{}" to true - end try - end try"#, - safe_owner, safe_owner, safe_owner - ); - Self::run_osascript(&script).map(|_| ()) - } - - fn screenshot(&self, window_owner: Option<&str>) -> Result, PlatformError> { - // `-x` 不发声,`-t png` 强转 PNG;`-l ` 是窗口截屏模式, - // 但我们这里只给 owner,所以走全屏截屏(窗口级截屏需要 window id, - // 由 Agent 通过 cua_list_windows 后续扩展)。 - let _ = window_owner; // MVP: 暂未实现窗口级截图(需要 window id) - Self::run_screencapture(&["-x", "-t", "png"]) - } - - fn click(&self, x: i32, y: i32, button: ClickButton) -> Result<(), PlatformError> { - let event = button.apple_event_name(); - let script = format!( - r#"tell application "System Events" to {} at {{{}, {}}}"#, - event, x, y - ); - Self::run_osascript(&script).map(|_| ()) - } - - fn double_click(&self, x: i32, y: i32) -> Result<(), PlatformError> { - let script = format!( - r#"tell application "System Events" to double click at {{{}, {}}}"#, - x, y - ); - Self::run_osascript(&script).map(|_| ()) - } - - fn type_text(&self, text: &str) -> Result<(), PlatformError> { - let escaped = escape_applescript_string(text); - let script = format!( - r#"tell application "System Events" to keystroke "{}""#, - escaped - ); - Self::run_osascript(&script).map(|_| ()) - } - - fn press_key(&self, key: &str, modifiers: &[String]) -> Result<(), PlatformError> { - let mods_clause = build_modifier_clause(modifiers); - // 无 modifiers:单字符走 `keystroke`,其余走 `key code`(要求数字)或 `key "name"`。 - // 有 modifiers:必须用 `key code`(System Events 的 keystroke 不接受组合键)。 - let final_script = if mods_clause.is_empty() { - if key.trim().chars().all(|c| c.is_ascii_digit()) { - format!( - r#"tell application "System Events" to key code {}"#, - key.trim() - ) - } else if key.trim().chars().count() == 1 { - format!( - r#"tell application "System Events" to keystroke "{}""#, - escape_applescript_string(key.trim()) - ) - } else { - format!( - r#"tell application "System Events" to key "{}""#, - escape_applescript_string(key.trim()) - ) - } - } else { - let code = if key.trim().chars().all(|c| c.is_ascii_digit()) { - key.trim().to_string() - } else { - // 字符按键没法直接给 code:保守地回退为单字符 keystroke + 修饰键。 - format!( - r#"keystroke "{}" using {{{}}}"#, - escape_applescript_string(key.trim()), - modifiers_to_applescript_list(modifiers) - ) - }; - format!( - r#"tell application "System Events" to key code {}{}"#, - code, mods_clause - ) - }; - Self::run_osascript(&final_script).map(|_| ()) - } - - fn scroll(&self, x: i32, y: i32, dy: i32) -> Result<(), PlatformError> { - // System Events 的 `scroll` 没有专门语法;最稳的是 mouseEvent 配合 - // mousewheel。退而求其次:用 `key code` 模拟 Page Up / Page Down - // 不可行(语义错乱)。我们用 click + drag 思路也不行。 - // 系统命令是 `cliclick` / `osascript -e 'do shell script'` 不可用, - // 我们直接走 `osascript -e 'tell application "System Events" to - // scroll wheel ...'`:它在 macOS 上接受整数 dy。 - let script = format!( - r#"tell application "System Events" to scroll wheel {{{}}} at {{{}, {}}}"#, - dy, x, y - ); - Self::run_osascript(&script).map(|_| ()) - } - - fn drag(&self, x1: i32, y1: i32, x2: i32, y2: i32) -> Result<(), PlatformError> { - // System Events 没有原生 drag API。可借用第三方 `cliclick`(需要 - // 用户安装)。MVP:fallback 到 `cliclick dd:x1,y1 dm:dx,dy du:x2,y2`。 - // 未安装 cliclick 时返回明确错误。 - let total_dx = x2 - x1; - let total_dy = y2 - y1; - let cli = match find_cli_click() { - Ok(p) => p, - Err(msg) => return Err(PlatformError::NotExecuted(msg)), - }; - let status = Command::new(cli) - .arg(format!("dd:{},{}", x1, y1)) - .arg(format!("dm:{},{}", total_dx, total_dy)) - .arg(format!("du:{},{}", x2, y2)) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status() - .map_err(|e| PlatformError::Io(e.to_string()))?; - if !status.success() { - return Err(PlatformError::NotExecuted(format!( - "cliclick 退出码非零:{:?}", - status.code() - ))); - } - Ok(()) - } -} - -#[cfg(target_os = "macos")] -fn temp_screenshot_path() -> PathBuf { - use std::time::{SystemTime, UNIX_EPOCH}; - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("liveagent-cua-{ts}.png")) -} - -#[cfg(target_os = "macos")] -fn translate_osascript_output(output: Output) -> Result { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if output.status.success() { - return Ok(stdout); - } - // 典型权限拒绝信号: - // "Not authorized to ...", error number -1002 (AppleScript 1002), - // "osascript is not allowed to send keystrokes" 等。 - let combined = format!("{stdout}\n{stderr}"); - let lower = combined.to_lowercase(); - if lower.contains("not authorized") - || lower.contains("1002") - || lower.contains("accessibility") - || lower.contains("assistive") - { - return Err(PlatformError::PermissionRequired { - permission_key: "accessibility", - permission: "Accessibility", - }); - } - if stderr.is_empty() { - Err(PlatformError::NotExecuted(format!( - "osascript exit non-zero (stdout={stdout})" - ))) - } else { - Err(PlatformError::NotExecuted(stderr)) - } -} - -#[cfg(target_os = "macos")] -fn modifiers_to_applescript_list(modifiers: &[String]) -> String { - use std::collections::BTreeSet; - let mut mods: BTreeSet = BTreeSet::new(); - for m in modifiers { - let lower = m.trim().to_lowercase(); - match lower.as_str() { - "cmd" | "command" | "commandorcontrol" | "super" | "meta" => { - mods.insert("command down".to_string()); - } - "shift" => { - mods.insert("shift down".to_string()); - } - "ctrl" | "control" => { - mods.insert("control down".to_string()); - } - "alt" | "option" => { - mods.insert("option down".to_string()); - } - _ => {} - } - } - mods.into_iter().collect::>().join(", ") -} - -#[cfg(target_os = "macos")] -fn build_modifier_clause(modifiers: &[String]) -> String { - let list = modifiers_to_applescript_list(modifiers); - if list.is_empty() { - String::new() - } else { - format!(" using {{{list}}}") - } -} - -#[cfg(target_os = "macos")] -fn find_cli_click() -> Result { - // 在 PATH 与常见 brew 安装路径中查找 cliclick,避免引入 which crate。 - use std::env; - if let Some(p) = env::var_os("PATH") { - for dir in env::split_paths(&p) { - let candidate = dir.join("cliclick"); - if candidate.is_file() { - return Ok(candidate); - } - } - } - for brew_dir in [ - "/opt/homebrew/bin/cliclick", - "/usr/local/bin/cliclick", - ] { - let p = PathBuf::from(brew_dir); - if p.is_file() { - return Ok(p); - } - } - Err("拖拽需要系统命令 `cliclick`(brew install cliclick)".into()) -} - -#[cfg(target_os = "macos")] -fn escape_applescript_string(input: &str) -> String { - let mut out = String::with_capacity(input.len() + 4); - for ch in input.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - // 控制字符:写成 \uXXXX 让 AppleScript 拒收,统一提前跳过。 - out.push(' '); - } - c => out.push(c), - } - } - out -} - -// ───────── 非 macOS 占位实现 ───────── - -#[cfg(not(target_os = "macos"))] -pub struct UnsupportedDriver; - -#[cfg(not(target_os = "macos"))] -impl CuaDriver for UnsupportedDriver { - fn platform_label() -> &'static str { - std::env::consts::OS - } - - fn list_windows(&self) -> Result, PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn focus_window(&self, _owner: &str) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn screenshot(&self, _owner: Option<&str>) -> Result, PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn click(&self, _x: i32, _y: i32, _b: ClickButton) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn double_click(&self, _x: i32, _y: i32) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn type_text(&self, _text: &str) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn press_key(&self, _k: &str, _mods: &[String]) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn scroll(&self, _x: i32, _y: i32, _dy: i32) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } - fn drag(&self, _x1: i32, _y1: i32, _x2: i32, _y2: i32) -> Result<(), PlatformError> { - Err(PlatformError::Unsupported(std::env::consts::OS.to_string())) - } -} - -// ───────── 驱动工厂 ───────── - -/// 拿到当前平台可用的驱动实例。`&'static` 因为驱动是无状态的。 -pub fn platform_driver() -> &'static dyn CuaDriver { - #[cfg(target_os = "macos")] - { - &MacOsDriver - } - #[cfg(not(target_os = "macos"))] - { - &UnsupportedDriver - } -} - -// ───────── 单元测试 ───────── - -#[cfg(all(test, target_os = "macos"))] -mod tests { - use super::*; - - #[test] - fn escape_applescript_handles_quotes_and_backslash() { - assert_eq!(escape_applescript_string("a\"b\\c"), "a\\\"b\\\\c"); - assert_eq!(escape_applescript_string("hi\nthere"), "hi\\nthere"); - } - - #[test] - fn modifier_clause_normalizes_aliases() { - assert_eq!(build_modifier_clause(&[]), ""); - let m = build_modifier_clause(&["cmd".into(), "shift".into()]); - assert!(m.contains("command down")); - assert!(m.contains("shift down")); - } - - #[test] - fn click_button_apple_event_name_matches_system_events() { - assert_eq!(ClickButton::Left.apple_event_name(), "click"); - assert_eq!(ClickButton::Right.apple_event_name(), "rightClick"); - assert_eq!(ClickButton::Middle.apple_event_name(), "middleClick"); - } -} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/cua/error.rs b/crates/agent-gui/src-tauri/src/services/cua/error.rs index ab44b4a65..52b400d9c 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/error.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/error.rs @@ -52,6 +52,17 @@ impl CuaError { ) } + /// 当前命令安全模式是 sandboxOffline:CUA 工具一律拒绝,避免 + /// 通过 cua-driver 突破 sandbox 强制断网(CUA-reviewer 要求)。 + pub fn sandbox_offline() -> Self { + Self::new( + "cua.errors.sandboxOffline", + "CUA tools are disabled while the command safety mode is sandboxOffline. \ + CUA would bypass the offline sandbox by spawning a separate network-capable \ + process. Switch the command safety mode to ask / auto / sandbox to use CUA.", + ) + } + /// 目标 owner 不在白名单。 pub fn denied_by_allowlist(target: &str, allowed: &[String]) -> Self { Self::with_params( @@ -66,7 +77,11 @@ impl CuaError { ) } - /// 当前 OS 尚未实现 CUA 驱动。 + /// 当前 OS 尚未实现 CUA 驱动。`platform` 是 OS 标签如 "macos"。 + /// 历史上用于自研 `MacOsDriver` / `UnsupportedDriver` 的 stub; + /// cua-driver 跨平台后这条路径只会在子进程 spawn 失败时由 + /// `cua_client` 兜底翻出来。 + #[allow(dead_code)] pub fn unsupported_platform(os: &str) -> Self { Self::with_params( "cua.errors.unsupportedPlatform", @@ -131,6 +146,7 @@ impl CuaError { } /// 脚本签名校验失败(占位;目前 install 脚本未签名,留接口备用)。 + #[allow(dead_code)] pub fn installer_signature_invalid(detail: &str) -> Self { Self::with_params( "cua.errors.installer.signatureInvalid", diff --git a/crates/agent-gui/src-tauri/src/services/cua/mod.rs b/crates/agent-gui/src-tauri/src/services/cua/mod.rs index 2c347ea2a..eab2f29ee 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/mod.rs @@ -1,24 +1,26 @@ -//! CUA (Computer Use Agent) 驱动:把 macOS 屏幕 / 输入抽象为一组结构化操作, +//! CUA (Computer Use Agent) 服务层:把桌面 / 屏幕 / 输入抽象为一组结构化操作, //! 暴露给 Tauri Command,再由前端 MCP 工具层注入给 Agent。 //! -//! MVP 范围:窗口枚举 + 截屏 + 点击 + 输入 + 按键 + 滚动 + 拖拽 + 焦点。 -//! 平台支持:macOS 通过 `osascript`(System Events)与 `screencapture` 完成; -//! Windows / Linux 暂以 stub + 明确错误回绝(避免静默失败),后续再补。 +//! 范围:窗口枚举 + 截屏 + 点击 + 输入 + 按键 + 滚动 + 拖拽 + 焦点。 +//! 平台:跨 macOS / Windows / Linux(cua-driver 提供)。 //! //! 设计原则: -//! - **KISS**:直接走系统自带命令行(osascript / screencapture),不引入 -//! `core-graphics` / `core-foundation` 等原生依赖,编译与体积代价最小。 +//! - **KISS**:所有 OS 行为走 `cua-driver mcp --direct` 子进程 JSON-RPC, +//! 不在 Rust 内复刻 AX / 像素路径,体积与维护代价最小。 //! - **DRY**:状态、审计日志、配额放同一个 `CuaStore`;所有命令统一过 -//! `enforce_enabled + check_allowed` 守卫。 +//! `enforce + check_allowed + audit` 守卫。 +//! - **审计 + 策略边界在后端**:sandboxOffline / 白名单 / trustMode 都 +//! 在 `CuaStore::enforce` 里统一决策,前端只能改 `CuaRuntimeConfig`、 +//! 不能绕过审计。 -pub mod driver; +pub mod cua_client; pub mod error; pub mod installer; pub mod store; -#[allow(unused_imports)] -pub use driver::{platform_driver, ClickButton, CuaDriver, PlatformError, WindowInfo}; +pub use cua_client::CuaClient; pub use error::CuaError; +#[allow(unused_imports)] pub use installer::{ build_install_command, build_install_preview, detect as detect_driver, install as install_driver, is_daemon_running, start_daemon as start_driver_daemon, update as update_driver, diff --git a/crates/agent-gui/src-tauri/src/services/cua/store.rs b/crates/agent-gui/src-tauri/src/services/cua/store.rs index a6d0e5dc6..494cc0ce4 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/store.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/store.rs @@ -62,6 +62,17 @@ pub struct CuaRuntimeConfig { /// 最大审计日志条数(默认 100;<=0 视作关闭)。 #[serde(default = "default_audit_limit")] pub audit_log_limit: usize, + /// 「信任模式」开关:开启后 `group:cua` 工具在前端不再弹审批(用户 + /// 自担风险)。关闭时由 `toolPolicy.ts` 走默认 `ask`。 + /// CUA-reviewer 要求:默认逐次审批,显式 trust 才免审。 + #[serde(default)] + pub trust_mode: bool, + /// 当前命令安全模式是否 sandboxOffline。CUA 进程会绕过 sandbox 边界 + /// 起子进程,因此 sandboxOffline 下必须强制 deny 全部 `cua_*` 工具。 + /// 由 Tauri 侧 `cua_set_config` 在写入时按 `system.commandSafetyMode` + /// 自动设置;前端仅展示。 + #[serde(default)] + pub sandbox_offline: bool, } fn default_audit_limit() -> usize { @@ -74,6 +85,8 @@ impl Default for CuaRuntimeConfig { enabled: false, allowed_owners: Vec::new(), audit_log_limit: AUDIT_LOG_MAX, + trust_mode: false, + sandbox_offline: false, } } } @@ -99,6 +112,10 @@ pub struct CuaStoreSnapshot { pub config: CuaRuntimeConfig, pub platform: &'static str, pub available: bool, + /// 与 `config.sandboxOffline` 同字段;显式平铺便于 UI 渲染沙箱 + /// 离线指示器时不必走整份 config(CUA-reviewer 要求)。 + #[serde(default)] + pub sandbox_offline: bool, pub recent: Vec, } @@ -129,6 +146,7 @@ impl CuaStore { config: inner.config.clone(), platform: platform_label_const(), available: inner.config.enabled, + sandbox_offline: inner.config.sandbox_offline, recent: inner.recent.clone(), } } @@ -138,15 +156,29 @@ impl CuaStore { inner.config = config; } + /// 把 store 内的 sandbox_offline 同步到传入值。`cua_status` 每次 + /// 取快照前调用,保证 UI 看到的是当前命令安全模式的最新真值, + /// 而不是上一次 `cua_set_config` 时写入的陈旧值。 + pub fn refresh_sandbox_offline(&self, sandbox_offline: bool) { + let mut inner = self.inner.lock().expect("cua store poisoned"); + inner.config.sandbox_offline = sandbox_offline; + } + /// 命令执行前调用:返回 ok 或对应的结构化拒绝原因。 - /// - 未启用 → `CuaError::disabled()` - /// - 有白名单且 owner 不在表里 → `CuaError::denied_by_allowlist(...)` + /// 优先级(由粗到细,任一命中即返回): + /// 1. 命令安全模式 = sandboxOffline → `CuaError::sandbox_offline()` + /// (防止 CUA 子进程突破 sandbox 强制断网)。 + /// 2. 未启用 → `CuaError::disabled()` + /// 3. 有白名单且 owner 不在表里 → `CuaError::denied_by_allowlist(...)` pub fn enforce( &self, op: &str, owner: Option<&str>, ) -> Result<(), CuaError> { let inner = self.inner.lock().expect("cua store poisoned"); + if inner.config.sandbox_offline { + return Err(CuaError::sandbox_offline()); + } if !inner.config.enabled { return Err(CuaError::disabled()); } @@ -200,6 +232,8 @@ mod tests { enabled, allowed_owners: Vec::new(), audit_log_limit, + trust_mode: false, + sandbox_offline: false, } } @@ -299,4 +333,34 @@ mod tests { ); assert!(!err.message.contains('你')); } + + /// CUA-reviewer 安全门控:命令安全模式 = sandboxOffline 时, + /// 即使 enabled=true + 白名单匹配,也必须强制 deny 全部 cua_*, + /// 防止 CUA 子进程突破 sandbox 的强制断网。 + #[test] + fn enforce_denies_when_sandbox_offline() { + let mut cfg = fake_config(100, true); + cfg.allowed_owners = vec!["Finder".into()]; + cfg.sandbox_offline = true; + let store = CuaStore::new(cfg); + let err = store + .enforce("click", Some("Finder")) + .expect_err("must reject under sandboxOffline"); + assert_eq!(err.kind, "cua.errors.sandboxOffline"); + // 即使 enabled 关闭也应该 deny,验证优先级:sandboxOffline > disabled。 + let mut cfg2 = fake_config(100, false); + cfg2.sandbox_offline = true; + let store2 = CuaStore::new(cfg2); + assert_eq!( + store2.enforce("list_windows", None).unwrap_err().kind, + "cua.errors.sandboxOffline" + ); + // 关掉 sandboxOffline 后回到 enabled=true 走通。 + let mut cfg3 = fake_config(100, true); + cfg3.allowed_owners = vec!["Finder".into()]; + let store3 = CuaStore::new(cfg3); + store3 + .enforce("click", Some("Finder")) + .expect("must allow when sandboxOffline=false"); + } } \ No newline at end of file diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index bfdef59de..e14ea0549 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -560,11 +560,11 @@ export default function App() { } debounceTimer = window.setTimeout(() => { debounceTimer = null; - invoke<{ wk_webview_found: boolean; responder_granted: boolean }>( - "cua_refresh_a11y", - ).catch((error: unknown) => { - console.warn("cua_refresh_a11y failed", error); - }); + invoke<{ wk_webview_found: boolean; responder_granted: boolean }>("cua_refresh_a11y").catch( + (error: unknown) => { + console.warn("cua_refresh_a11y failed", error); + }, + ); }, 50); }; const onVisibility = () => { diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 28b03333a..68e7616d9 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -258,16 +258,25 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.platform": "驱动平台", "settings.cua.platformDesc": "由 Rust 端按编译目标决定(macOS / Windows / Linux)。", "settings.cua.platformNotes.macos.accessibility": - "辅助功能(Accessibility)— System Events 操控鼠标 / 键盘", + "辅助功能(Accessibility)— CuaDriver.app 通过 AX API 操控鼠标 / 键盘", "settings.cua.platformNotes.macos.screenRecording": - "屏幕录制(Screen Recording)— screencapture 截屏", - "settings.cua.platformNotes.macos.dragAndDrop": - "拖拽需安装 `cliclick`(brew install cliclick)", + "屏幕录制(Screen Recording)— CuaDriver.app 截屏", + "settings.cua.platformNotes.macos.dragAndDrop": "拖拽由 cua-driver 内置支持,无需第三方工具", "settings.cua.platformNotes.unsupported": "驱动尚未实现;启用后所有 CUA 操作都会失败", "settings.cua.allowedOwners": "允许的目标应用", "settings.cua.allowedOwnersDesc": "白名单:仅这些 owner(应用名)的输入 / 焦点 / 点击会被放行。空 = 不限制。多个 owner 用英文逗号或换行分隔。", "settings.cua.allowedOwnersHint": "例:Finder, Safari, Terminal", + "settings.cua.trustModeGroup": "信任模式", + "settings.cua.trustMode": "信任 CUA 工具", + "settings.cua.trustModeDesc": + "关闭时所有 cua_* 工具每次调用都会弹审批(默认)。开启后不再逐次弹窗,但 CUA 仍受后端白名单与审计日志约束。", + "settings.cua.trustModeHint": + "CUA 会接管鼠标键盘,启用后相当于把控制权交给 Agent;截图等敏感操作也会被捕获。建议日常保持关闭,仅在批量自动化场景下临时开启。", + "settings.cua.sandboxOfflineBanner": + "当前命令安全模式是 sandboxOffline(断网沙箱)。CUA 工具已全部禁用,避免 CUA 子进程绕过强制断网。切换命令安全模式后可重新启用。", + "settings.cua.enableDescSandboxOffline": + "当前命令安全模式是 sandboxOffline;CUA 已禁用,无法开启。", "settings.cua.audit": "审计日志", "settings.cua.auditLimit": "审计日志上限", "settings.cua.auditLimitDesc": "内存中保留最近 N 条 CUA 操作(0 = 关闭)。", @@ -277,6 +286,8 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.saving": "正在同步 CUA 配置…", "settings.cua.errors.fetch": "无法读取 CUA 状态", "settings.cua.errors.save": "保存 CUA 配置失败", + "cua.errors.sandboxOffline": + "当前命令安全模式是 sandboxOffline,CUA 工具已全部禁用。请把命令安全模式切换为 ask / auto / sandbox 后再使用 CUA。", // CUA-100: driver installer (zh-CN) "cua.errors.installer.networkUnavailable": "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", @@ -598,17 +609,27 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.platformDesc": "Selected at build time by the Rust target (macOS / Windows / Linux).", "settings.cua.platformNotes.macos.accessibility": - "Accessibility — System Events drives mouse / keyboard", + "Accessibility — CuaDriver.app drives mouse / keyboard via the AX API", "settings.cua.platformNotes.macos.screenRecording": - "Screen Recording — screencapture for screenshots", + "Screen Recording — CuaDriver.app captures screenshots", "settings.cua.platformNotes.macos.dragAndDrop": - "Drag operations require `cliclick` (brew install cliclick)", + "Drag is built into cua-driver; no third-party tool required", "settings.cua.platformNotes.unsupported": "Driver is not implemented yet; every CUA operation will fail when enabled", "settings.cua.allowedOwners": "Allowed target apps", "settings.cua.allowedOwnersDesc": "Allowlist: only these owner (app) names are allowed to receive typed / focused / clicked input. Empty = no restriction. Separate multiple owners with commas or newlines.", "settings.cua.allowedOwnersHint": "e.g. Finder, Safari, Terminal", + "settings.cua.trustModeGroup": "Trust mode", + "settings.cua.trustMode": "Trust CUA tools", + "settings.cua.trustModeDesc": + "Off (default): every cua_* tool call prompts for approval. On: prompts are skipped, but the backend allowlist and audit log still apply.", + "settings.cua.trustModeHint": + "CUA takes over the mouse and keyboard; screenshots are also captured. Leave this off by default and only enable it for short batch automation runs.", + "settings.cua.sandboxOfflineBanner": + "The current command safety mode is sandboxOffline. All CUA tools are disabled so the cua-driver subprocess cannot bypass the offline sandbox. Switch the command safety mode to re-enable CUA.", + "settings.cua.enableDescSandboxOffline": + "The current command safety mode is sandboxOffline; CUA is disabled and cannot be turned on here.", "settings.cua.audit": "Audit log", "settings.cua.auditLimit": "Audit log limit", "settings.cua.auditLimitDesc": "Keep the most recent N CUA operations in memory (0 = disable).", @@ -618,6 +639,8 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.saving": "Syncing CUA config…", "settings.cua.errors.fetch": "Failed to read CUA status", "settings.cua.errors.save": "Failed to save CUA config", + "cua.errors.sandboxOffline": + "CUA tools are disabled while the command safety mode is sandboxOffline. Switch the command safety mode to ask / auto / sandbox to use CUA.", // CUA-100: driver installer (en-US) "cua.errors.installer.networkUnavailable": "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", diff --git a/crates/agent-gui/src/lib/tools/cuaTools.ts b/crates/agent-gui/src/lib/tools/cuaTools.ts index f4f9fad16..162979cfd 100644 --- a/crates/agent-gui/src/lib/tools/cuaTools.ts +++ b/crates/agent-gui/src/lib/tools/cuaTools.ts @@ -272,7 +272,7 @@ export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinTo }, { name: "cua_drag", - description: "[CUA] 拖拽(macOS 上需要 `brew install cliclick`)。从 (x1, y1) 到 (x2, y2)。", + description: "[CUA] 拖拽(cua-driver 内置支持,无需第三方工具)。从 (x1, y1) 到 (x2, y2)。", parameters: normalizeToolParametersSchema( { type: "object", diff --git a/crates/agent-gui/src/lib/tools/toolPolicy.ts b/crates/agent-gui/src/lib/tools/toolPolicy.ts index 9dd3f875b..126f1551c 100644 --- a/crates/agent-gui/src/lib/tools/toolPolicy.ts +++ b/crates/agent-gui/src/lib/tools/toolPolicy.ts @@ -24,6 +24,26 @@ export function toolServerPolicyKey(serverId: string): string { return `${TOOL_SERVER_POLICY_PREFIX}${serverId}`; } +/** + * 工具组级「不可由用户覆盖的硬编码缺省」。这里列出的组在没有用户 + * 显式策略时,直接走该默认值——避免任何「隐式 allow」绕过 reviewer + * 提出的安全门控(CUA-reviewer 要求 `group:cua` 默认 ask)。 + * + * 顺序:`isHardcodedGroupDefault` 检查必须在用户策略解析之后;解析 + * 函数里会把「硬编码默认」放在「用户组级覆盖」之前——即只要用户 + * 显式写了 `group:cua` 的策略,就以用户为准(ask / allow / deny 任 + * 意)。trustMode 切换只在 `cuaSettings.trustMode === true` 时插入 + * `group:cua: allow` 隐式覆盖,所以这里返回的默认是「未开启信任模式 + * 时的兜底」。 + */ +const HARDCODED_GROUP_DEFAULTS: Readonly> = Object.freeze({ + cua: "ask", +}); + +export function isHardcodedGroupDefault(groupId: string): ToolPolicy | undefined { + return HARDCODED_GROUP_DEFAULTS[groupId]; +} + /** * 解析一次工具调用的审批策略。设计目标:显式配置永远优先,缺省值保证既有 * 体验零回归(内置/MCP 默认放行),只对第三方插件工具默认拦审。 @@ -35,11 +55,17 @@ export function toolServerPolicyKey(serverId: string): string { * 2、3 都是用户对更大范围的明确表态,应盖过下面的只读缺省。 * 4. 只读工具(metadata.isReadOnly)恒 allow:读操作无副作用,不应打断对话。 * 5. 其余(内置、mcp、无元数据的未知名)缺省 allow:保持现状,不制造回归。 + * + * `extraGroupDefaults` 由调用方注入:典型场景是 CUA 在 trustMode 开 + * 启时把 `group:cua` 强行设为 `allow`,让用户切信任时立刻生效, + * 不需要再回设置面板改策略表。`extraGroupDefaults` 优先级低于用户 + * 策略,高于硬编码默认(ask)。 */ export function resolveToolPolicy( toolName: string, metadata: BuiltinToolMetadata | undefined, policies: Record | undefined, + extraGroupDefaults?: Record, ): ToolPolicy { const explicit = policies?.[toolName]; if (explicit) return explicit; @@ -50,6 +76,13 @@ export function resolveToolPolicy( const groupId = metadata?.groupId; const groupPolicy = groupId ? policies?.[toolGroupPolicyKey(groupId)] : undefined; if (groupPolicy) return groupPolicy; + if (groupId && extraGroupDefaults?.[groupId]) { + return extraGroupDefaults[groupId]; + } + if (groupId) { + const hardcoded = isHardcodedGroupDefault(groupId); + if (hardcoded) return hardcoded; + } if (metadata?.isReadOnly) return "allow"; return "allow"; } diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index 880854e7e..b531a627c 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -1720,6 +1720,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { } }, getCuaEnabled: () => settings.cua?.enabled === true, + getCuaTrustMode: () => settings.cua?.trustMode === true, getLocale: () => settings.locale, onTunnelsChanged: (change) => { if (change.action === "create") { diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 96c05330b..a25f3c809 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -317,6 +317,9 @@ export type RunAgentConversationTurnParams = { onSshSessionsChanged?: (change: SshManagerSessionChange) => void; /** CUA 总开关的实时读取(权威 settingsRef,非 turn 级快照)。 */ getCuaEnabled?: () => boolean; + /** CUA 信任模式(trustMode=true 时 group:cua 工具不再 ask)。 + * 实时读取;用户切信任时立即生效,不需要回到设置面板改策略表。 */ + getCuaTrustMode?: () => boolean; /** 当前 UI locale:cua_* 工具用来把后端结构化错误按用户语言翻译。 */ getLocale?: () => Locale; sessionId: string; @@ -409,6 +412,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP sshManagerRemoteAllowed, onSshSessionsChanged, getCuaEnabled, + getCuaTrustMode, getLocale, sessionId, taskStateStore, @@ -678,12 +682,17 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP // 策略为 deny 的工具干脆不发给模型:省 token,且模型不会白白尝试再被拦。 // resolveToolGate 的 deny 分支保留为后备(理论上模型已看不到,不会触发)。 const toolPoliciesSnapshot = getToolPolicies?.(); + // CUA 信任模式:开启时把 group:cua 默认 allow(盖过 isHardcodedGroupDefault + // 返回的 ask),用户在 settings.system.toolPolicies 里写过 group:cua 的仍按 + // 用户策略——`extraGroupDefaults` 优先级低于用户策略。 + const cuaExtraDefaults = getCuaTrustMode?.() === true ? { cua: "allow" as const } : undefined; const combinedTools = builtinRegistry.tools.filter( (tool) => resolveToolPolicy( tool.name, builtinRegistry.metadataByName.get(tool.name), toolPoliciesSnapshot, + cuaExtraDefaults, ) !== "deny", ); @@ -778,7 +787,12 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP return repeatGate; } } - const policy = resolveToolPolicy(toolCall.name, metadata, getToolPolicies?.()); + const policy = resolveToolPolicy( + toolCall.name, + metadata, + getToolPolicies?.(), + cuaExtraDefaults, + ); if (policy === "deny") { return { allow: false, diff --git a/crates/agent-gui/test/tools/tool-policy.test.mjs b/crates/agent-gui/test/tools/tool-policy.test.mjs index f57914a53..eb2c15d3a 100644 --- a/crates/agent-gui/test/tools/tool-policy.test.mjs +++ b/crates/agent-gui/test/tools/tool-policy.test.mjs @@ -56,3 +56,31 @@ test("normalizeSystemSettings 透传 toolPolicies 且旧快照缺失时不报错 const legacy = settings.normalizeSystemSettings({}); assert.equal(legacy.toolPolicies, undefined); }); + +test("CUA 默认 ask:没有用户策略也没有 trustMode 时,group:cua 走 ask 缺省", () => { + const cuaMeta = { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }; + assert.equal(resolveToolPolicy("cua_click", cuaMeta, undefined), "ask"); + assert.equal(resolveToolPolicy("cua_screenshot", cuaMeta, undefined), "ask"); +}); + +test("CUA 用户显式策略覆盖 ask 缺省", () => { + const cuaMeta = { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }; + assert.equal(resolveToolPolicy("cua_click", cuaMeta, { "group:cua": "deny" }), "deny"); + assert.equal(resolveToolPolicy("cua_click", cuaMeta, { "group:cua": "allow" }), "allow"); + assert.equal(resolveToolPolicy("cua_click", cuaMeta, { cua_click: "deny" }), "deny"); +}); + +test("CUA trustMode 开启时 extraGroupDefaults 把 group:cua 强制 allow,但仍低于用户策略", () => { + const cuaMeta = { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }; + const trust = { cua: "allow" }; + assert.equal(resolveToolPolicy("cua_click", cuaMeta, undefined, trust), "allow"); + // 用户 deny 仍然生效(用户策略 > extra defaults > 硬编码默认)。 + assert.equal(resolveToolPolicy("cua_click", cuaMeta, { "group:cua": "deny" }, trust), "deny"); +}); + +test("isHardcodedGroupDefault 暴露 CUA ask 缺省供 UI 提示", () => { + const { isHardcodedGroupDefault } = toolPolicy; + assert.equal(isHardcodedGroupDefault("cua"), "ask"); + assert.equal(isHardcodedGroupDefault("mcp"), undefined); + assert.equal(isHardcodedGroupDefault("shell"), undefined); +}); diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index d143d19d7..216c4c35a 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1198,13 +1198,23 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cua.platformNotes.macos.screenRecording": "LiveAgent needs the Screen Recording permission to capture screenshots.", "settings.cua.platformNotes.macos.dragAndDrop": - "Drag-and-drop requires `cliclick` installed first (`brew install cliclick`).", + "Drag-and-drop is built into cua-driver; no third-party tool is required.", "settings.cua.platformNotes.unsupported": "Driver not implemented; all CUA operations will fail when enabled", "settings.cua.allowedOwners": "Allowed target apps", "settings.cua.allowedOwnersDesc": "When non-empty, only listed owner names are allowed for focus / input / click. Screenshots and window listing bypass this list.", "settings.cua.allowedOwnersHint": "e.g. Finder, Safari, Terminal", + "settings.cua.trustModeGroup": "Trust mode", + "settings.cua.trustMode": "Trust CUA tools", + "settings.cua.trustModeDesc": + "Off (default): every cua_* tool call prompts for approval. On: prompts are skipped, but the backend allowlist and audit log still apply.", + "settings.cua.trustModeHint": + "CUA takes over the mouse and keyboard; screenshots are also captured. Leave this off by default and only enable it for short batch automation runs.", + "settings.cua.sandboxOfflineBanner": + "The current command safety mode is sandboxOffline. All CUA tools are disabled so the cua-driver subprocess cannot bypass the offline sandbox. Switch the command safety mode to re-enable CUA.", + "settings.cua.enableDescSandboxOffline": + "The current command safety mode is sandboxOffline; CUA is disabled and cannot be turned on here.", "settings.cua.audit": "Audit log", "settings.cua.auditLimit": "Audit log limit", "settings.cua.auditLimitDesc": "Keep the most recent N CUA operations in memory (0 = disable).", @@ -1225,6 +1235,8 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA operation was not executed: {detail}", "cua.errors.io": "CUA subprocess IO error: {detail}", "cua.errors.unknown": "unknown reason", + "cua.errors.sandboxOffline": + "CUA tools are disabled while the command safety mode is sandboxOffline. Switch the command safety mode to ask / auto / sandbox to use CUA.", // CUA-100 series: driver installer errors "cua.errors.installer.networkUnavailable": "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index f6d30e5f1..8402bd089 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1147,6 +1147,16 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.cua.allowedOwnersDesc": "非空时生效,名单内的 owner 名才允许 focus / 输入 / 点击。截屏与窗口枚举不受名单限制。", "settings.cua.allowedOwnersHint": "例:Finder, Safari, Terminal", + "settings.cua.trustModeGroup": "信任模式", + "settings.cua.trustMode": "信任 CUA 工具", + "settings.cua.trustModeDesc": + "关闭时所有 cua_* 工具每次调用都会弹审批(默认)。开启后不再逐次弹窗,但 CUA 仍受后端白名单与审计日志约束。", + "settings.cua.trustModeHint": + "CUA 会接管鼠标键盘,启用后相当于把控制权交给 Agent;截图等敏感操作也会被捕获。建议日常保持关闭,仅在批量自动化场景下临时开启。", + "settings.cua.sandboxOfflineBanner": + "当前命令安全模式是 sandboxOffline(断网沙箱)。CUA 工具已全部禁用,避免 CUA 子进程绕过强制断网。切换命令安全模式后可重新启用。", + "settings.cua.enableDescSandboxOffline": + "当前命令安全模式是 sandboxOffline;CUA 已禁用,无法开启。", "settings.cua.audit": "审计日志", "settings.cua.auditLimit": "审计日志上限", "settings.cua.auditLimitDesc": "内存中保留最近 N 条 CUA 操作(0 = 关闭)。", @@ -1165,6 +1175,8 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA 操作未执行:{detail}", "cua.errors.io": "CUA 子进程 IO 错误:{detail}", "cua.errors.unknown": "未知原因", + "cua.errors.sandboxOffline": + "当前命令安全模式是 sandboxOffline,CUA 工具已全部禁用。请把命令安全模式切换为 ask / auto / sandbox 后再使用 CUA。", // CUA-100 系列:driver 安装器错误 "cua.errors.installer.networkUnavailable": "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index 3f914c07c..640f2654e 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -1653,7 +1653,8 @@ export function normalizeCuaSettings(input: unknown): CuaSettings { typeof obj.auditLogLimit === "number" && Number.isFinite(obj.auditLogLimit) ? Math.max(0, Math.min(1000, Math.floor(obj.auditLogLimit))) : 100; - return { enabled, allowedOwners, auditLogLimit }; + const trustMode = obj.trustMode === true; + return { enabled, allowedOwners, auditLogLimit, trustMode }; } export function updateSystem(prev: AppSettings, patch: Partial): AppSettings { diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index da0cf4b57..638b01d9b 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -600,6 +600,10 @@ export type CuaSettings = { allowedOwners: string[]; /** 审计日志上限(最近 N 条),默认 100。0 视作关闭。 */ auditLogLimit: number; + /** 「信任模式」开关:开启后 `group:cua` 工具在前端不再弹审批(用户 + * 自担风险)。关闭时由 `toolPolicy.ts` 走默认 `ask`(CUA-reviewer + * 要求:默认逐次审批,显式 trust 才免审)。 */ + trustMode: boolean; }; export const CODEX_REQUEST_FORMAT_LABELS: Record = { diff --git a/crates/agent-ui/src/pages/settings/CuaSection.tsx b/crates/agent-ui/src/pages/settings/CuaSection.tsx index 4f2e12c04..ff5b773e7 100644 --- a/crates/agent-ui/src/pages/settings/CuaSection.tsx +++ b/crates/agent-ui/src/pages/settings/CuaSection.tsx @@ -13,6 +13,9 @@ export type CuaSettings = { enabled: boolean; allowedOwners: string[]; auditLogLimit: number; + /** 「信任模式」开关:开启后 `group:cua` 工具在前端不再弹审批 + * (CUA-reviewer 要求:默认逐次审批)。 */ + trustMode: boolean; }; export type CuaAuditEntry = { @@ -28,7 +31,12 @@ export type CuaAuditEntry = { export type CuaStatus = { config: CuaSettings; platform: string; + /** cua-driver 是否可用(installed + daemon running)。与 config.enabled + * 独立——开关在前端 UI / backend CuaStore,driver 在 installer 路径。 */ available: boolean; + /** 后端 CuaRuntimeConfig.sandboxOffline 镜像。当前命令安全模式是 + * sandboxOffline 时为 true;UI 据此展示离线指示器与禁用状态。 */ + sandboxOffline?: boolean; recent: CuaAuditEntry[]; }; @@ -189,6 +197,15 @@ export function CuaSection(props: CuaSectionProps) { [settings, persist, setSettings], ); + const handleTrustModeToggle = useCallback( + (value: boolean) => { + const next: CuaSettings = { ...settings, trustMode: value }; + setSettings(() => next); + void persist(next); + }, + [settings, persist, setSettings], + ); + const handleOwnersCommit = useCallback(() => { const next: CuaSettings = { ...settings, @@ -206,24 +223,37 @@ export function CuaSection(props: CuaSectionProps) { const auditCount = status?.recent.length ?? 0; const previewEntries = useMemo(() => (status?.recent ?? []).slice(-10).reverse(), [status]); + const sandboxOffline = status?.sandboxOffline === true; return (
+ {sandboxOffline ? ( +
+ {t("settings.cua.sandboxOfflineBanner")} +
+ ) : null} handleEnabledToggle(!settings.enabled)} title={t("settings.cua.enable")} data-testid="cua-enable-switch" data-cua-enabled={settings.enabled ? "true" : "false"} + disabled={sandboxOffline} /> } /> @@ -252,6 +282,27 @@ export function CuaSection(props: CuaSectionProps) {
+ + handleTrustModeToggle(!settings.trustMode)} + title={t("settings.cua.trustMode")} + data-testid="cua-trust-mode-switch" + data-cua-trust-mode={settings.trustMode ? "true" : "false"} + /> + } + /> +
+

+ {t("settings.cua.trustModeHint")} +

+
+
+

From 900a5b4fb297e6eaee4027508bee8e43e047e915 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 09:12:54 +0800 Subject: [PATCH 17/28] =?UTF-8?q?fix(cua):=20cua=5Fscreenshot=20=E5=8A=A0?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E5=BC=8F=E5=8F=8C=E5=B1=82=E9=98=B2=E7=BA=BF?= =?UTF-8?q?=20=E2=80=94=20bundle=20attribution=20=E9=A2=84=E6=A3=80=20+=20?= =?UTF-8?q?=E9=9D=9E=E9=BB=91=E5=83=8F=E7=B4=A0=E5=90=8E=E6=A3=80=EF=BC=88?= =?UTF-8?q?CUA-051=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移到 cua-driver 后,cua_screenshot 全屏路径走 get_desktop_state 时返回 1920x1080 全黑 PNG。Round 24 reviewer 提交说明里'cua-driver TCC ScreenCaptureKit 同 bundle attribution 自动处理'是错的——裸 bin 跑 cua-driver mcp --direct 时 process 没有 CFBundleIdentifier,ScreenCaptureKit attribution 失效。 防御式双层防线: 1. 预检:cua_screenshot 全屏路径前调 health_report 检查 bundle_identity,fail 直接返回 CuaError::screenCaptureUnavailable(带 60s 缓存) 2. 后检:拿到截图字节后 image crate 解码数非黑像素(alpha > 0 且 RGB != 0),< 1000 拒收 新增 CuaError::screen_capture_unavailable 变体 + i18n 文案 + CuaInstallerPanel macOS 权限卡片补 bundle 路径指引。 测试:cargo test 971 pass(含 6 新单元测试);tsc 双包 clean;node test/cua 16 pass(含 2 新 i18n 插值);真机复现:bundle_identity=fail + 0/2073600 非黑像素 → 拒收 + 修复指引。 --- .../agent-gui/src-tauri/src/commands/cua.rs | 158 +++++++++++++++++- .../src-tauri/src/services/cua/cua_client.rs | 54 ++++++ .../src-tauri/src/services/cua/error.rs | 18 ++ .../test/cua/format-cua-error.test.mjs | 31 ++++ .../src/i18n/translations/enUSSettings.ts | 6 +- .../src/i18n/translations/zhCNSettings.ts | 6 +- 6 files changed, 266 insertions(+), 7 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs index d16a27e55..67f12bfdc 100644 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -13,8 +13,10 @@ use base64::Engine; use chrono::Utc; +use image::ImageReader; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::io::Cursor; use std::sync::Arc; use tauri::AppHandle; use tauri::State; @@ -324,7 +326,34 @@ pub fn cua_screenshot( // 沙箱遮罩:window_owner 给定时再尝试 `zoom`(cua-driver 内置 // window owner 寻址);否则回退到全屏。LiveAgent 自身 / cua-driver // helper 的遮罩交给 cua-driver 自身的窗口排除逻辑。 - let result = if let Some(owner) = window_owner.as_deref().filter(|s| !s.is_empty()) { + // + // CUA-051:当 window_owner 为空需要走全屏路径前,先确认 cua-driver + // 是带 CFBundleIdentifier 启动的(`health_report.bundle_identity` + // pass)。裸 bin 启动时 TCC Screen Recording attribution 落到 + // 临时进程而非 com.trycua.driver,ScreenCaptureKit 会返回合法 + // 但内容全黑的 PNG——Agent 拿到全黑帧做不了任何决策。bundle 失 + // 效时直接拒,避免把黑帧当真截图透传。 + let owner_opt = window_owner + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + let needs_full_desktop = owner_opt.is_none(); + if needs_full_desktop && !client.check_bundle_attribution() { + let err = CuaError::screen_capture_unavailable( + "cua-driver is not running inside CuaDriver.app bundle \ + (health_report.bundle_identity=fail); \ + get_desktop_state would return a fully-black frame", + ); + store.inner().record(CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: false, + error: Some(err.clone()), + detail: Some(json!({ "windowOwner": window_owner })), + }); + return Err(err); + } + let result = if let Some(owner) = owner_opt { // 走 list_apps → pid+window_id → zoom 路径,给一个 5s 缓存避免 // 每次截屏都 list_apps。这里偷懒:直接传 owner 字符串到 // get_desktop_state(不支持);退化:list_apps 后取第一个匹配 @@ -338,10 +367,8 @@ pub fn cua_screenshot( "x1": 0, "y1": 0, "x2": 0, "y2": 0, }), ), - Ok(None) => client.call_tool( - "get_desktop_state", - json!({}), - ), + // 找不到对应窗口时降级到全屏(仍受 bundle 预检保护)。 + Ok(None) => client.call_tool("get_desktop_state", json!({})), Err(e) => Err(e), } } else { @@ -351,6 +378,34 @@ pub fn cua_screenshot( Ok(value) => { let (bytes, width, height) = extract_screenshot(&value) .ok_or_else(|| CuaError::io("cua-driver screenshot: no image content"))?; + // CUA-051 后置防线:即便 health_report 那一关过了,也要兜 + // 一道「帧内像素统计」。少部分边界情况(Screen Recording + // 被吊销但 bundle 仍 ok、屏幕本身黑屏、cua-driver 半瘫) + // 仍可能让 get_desktop_state 返回低信息量的图;非黑像素 + // 数量低于阈值就直接拒,转成 screen_capture_unavailable。 + if let Some(non_black) = count_non_black_pixels(&bytes) { + if non_black < MIN_NON_BLACK_PIXELS { + let err = CuaError::screen_capture_unavailable(&format!( + "cua-driver returned a near-empty screen capture \ + ({non_black} non-black pixels); \ + TCC Screen Recording attribution is broken or screen is off" + )); + store.inner().record(CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: false, + error: Some(err.clone()), + detail: Some(json!({ + "width": width, + "height": height, + "windowOwner": window_owner, + "sizeBytes": bytes.len(), + "nonBlackPixels": non_black, + })), + }); + return Err(err); + } + } let base64_png = base64::engine::general_purpose::STANDARD.encode(&bytes); store.inner().record(CuaAuditEntry { timestamp: started, @@ -849,6 +904,39 @@ pub fn cua_driver_install_preview() -> Result { }) } +// ───────── CUA-051 截屏黑屏兜底 ───────── + +/// CUA-051 阈值:1920×1080 = 2,073,600 像素;阈值 1000 ≈ 0.05%。低于 +/// 这值几乎一定是 ScreenCaptureKit attribution 失效(裸 bin 启动)或 +/// 屏幕本身关闭,返回全黑 / 接近全黑帧。低于阈值的「截屏」对 Agent +/// 决策毫无意义,所以由后端直接拒。 +/// +/// 调高会让一些「真黑屏」(夜间模式 + 空桌面)误报;调低会让全黑帧 +/// 漏过去。1000 是经验值——cua-driver 真截图在最小桌面也会远高于此。 +const MIN_NON_BLACK_PIXELS: u64 = 1000; + +/// 把 cua-driver 返回的 PNG / JPEG 字节解码成 RGBA8 数非黑像素数。 +/// `None` 表示解码失败(不是 PNG / JPEG / 文件坏)——caller 应该 +/// 把这种当成 decode error 而不是 black-screen。 +/// +/// 与 PIL.Image(...).getdata() 等价的最小实现:只统计 +/// (R, G, B) != (0, 0, 0) 的像素。alpha < 255 也算黑(透明像素不携带 +/// 信息,对 Agent 来说一样没用)。 +fn count_non_black_pixels(bytes: &[u8]) -> Option { + let mut reader = ImageReader::new(Cursor::new(bytes)); + reader = reader.with_guessed_format().ok()?; + let decoded = reader.decode().ok()?; + let rgba = decoded.to_rgba8(); + let mut count = 0u64; + for px in rgba.pixels() { + let [r, g, b, a] = px.0; + if a > 0 && (r != 0 || g != 0 || b != 0) { + count += 1; + } + } + Some(count) +} + #[cfg(test)] mod tests { use super::*; @@ -926,4 +1014,64 @@ mod tests { fn png_dimensions_rejects_non_png() { assert_eq!(png_dimensions(b"not a png"), None); } + + #[test] + fn screen_capture_unavailable_emits_structured_error() { + let err = CuaError::screen_capture_unavailable("bundle_identity=fail"); + assert_eq!(err.kind, "cua.errors.screenCaptureUnavailable"); + assert_eq!( + err.params.get("detail").and_then(Value::as_str), + Some("bundle_identity=fail") + ); + assert!(err.message.contains("CuaDriver.app")); + assert!(err.message.contains("Screen Recording")); + } + + /// 4×4 全 0 RGB PNG → 0 个非黑像素。 + #[test] + fn count_non_black_pixels_all_black() { + let mut out: Vec = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])) + .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + let n = count_non_black_pixels(&out); + assert_eq!(n, Some(0)); + } + + /// 4×4 全 255 RGB PNG → 16 个非黑像素。 + #[test] + fn count_non_black_pixels_all_white() { + let mut out: Vec = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 255, 255, 255])) + .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + let n = count_non_black_pixels(&out); + assert_eq!(n, Some(16)); + } + + /// alpha=0 的像素视为黑(透传不携带信息)。 + #[test] + fn count_non_black_pixels_treats_transparent_as_black() { + let mut out: Vec = Vec::new(); + image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 255, 255, 0])) + .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + let n = count_non_black_pixels(&out); + assert_eq!(n, Some(0)); + } + + #[test] + fn count_non_black_pixels_returns_none_on_garbage() { + // 不是图片字节流 → 解码失败 → None。 + assert_eq!(count_non_black_pixels(b"not an image at all"), None); + } + + /// 校验 MIN_NON_BLACK_PIXELS 阈值:CUA-051 要求非黑像素低于阈值的 + /// 截屏被拒。改这个常量会直接影响 cua_screenshot 的语义,所以 + /// 留个 sanity 测试。 + #[test] + fn min_threshold_is_sane() { + assert!(MIN_NON_BLACK_PIXELS >= 100, "threshold too small"); + assert!(MIN_NON_BLACK_PIXELS <= 100_000, "threshold too large"); + } } diff --git a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs index 7f5c977ee..32a879c8e 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs @@ -37,6 +37,11 @@ const CALL_TIMEOUT: Duration = Duration::from_secs(30); /// 一次 deadline 检查 + child 状态探测。 const READ_SLICE: Duration = Duration::from_millis(250); +/// `health_report` 的 bundle_identity 缓存有效期。短到能在「用户重新授权 / +/// 重启 CuaDriver.app」后尽快恢复有效,长到不至于每次截屏都重发一次 +/// health_report(每次 ~200ms;同帧多次调用浪费)。 +const BUNDLE_ATTRIBUTION_CACHE: Duration = Duration::from_secs(60); + /// cua-driver 可执行文件名(与 `installer::find_cua_driver` 同源; /// 这里直接 `Command::new` 让 OS 走 PATH,不再做二次候选目录 walk, /// 启动期由 installer 把 PATH 准备好)。 @@ -54,6 +59,9 @@ struct Inner { child: Option, /// MCP 请求 id 自增;初始化握手与后续 tools/call 共享同一计数器。 next_id: AtomicU64, + /// `health_report.bundle_identity` 缓存 + 取样时间。`None` 表示 + /// 还没取过;超时或子进程重启后会重新拉一次(CUA-051)。 + bundle_attribution: Mutex>, } /// 拆出 stdio 句柄后剩下的子进程字段。 @@ -72,6 +80,7 @@ impl CuaClient { inner: Arc::new(Mutex::new(Inner { child: None, next_id: AtomicU64::new(1), + bundle_attribution: Mutex::new(None), })), } } @@ -208,6 +217,51 @@ impl CuaClient { } } } + + /// CUA-051:检测 cua-driver 是否带 CFBundleIdentifier 启动。 + /// `true` 表示 health_report 中 `bundle_identity` 为 pass,可以 + /// 信任 `get_desktop_state` 返回真实屏幕像素;`false` 表示该路径 + /// 会返回全黑帧(TCC Screen Recording attribution 失效),调用方 + /// 应该改走 `zoom`(按窗口寻址,attribution 不依赖 bundle)或直接 + /// 报 `screen_capture_unavailable`。 + /// + /// 结果缓存 `BUNDLE_ATTRIBUTION_CACHE`(60s),避免每次截屏都发 + /// health_report 浪费时间。子进程重启 / `shutdown()` 后下次调用 + /// 会重发——`bundle_attribution` 是与 child 同寿命的字段。 + pub fn check_bundle_attribution(&self) -> bool { + // fast path:缓存有效就直接返回。 + if let Ok(guard) = self.inner.lock() { + if let Ok(attr) = guard.bundle_attribution.lock() { + if let Some((ok, at)) = *attr { + if at.elapsed() < BUNDLE_ATTRIBUTION_CACHE { + return ok; + } + } + } + } + // slow path:调 health_report 拿 bundle_identity。失败 / 超时 + // 一律按 fail 处理(保守;宁可错杀不要返回全黑帧)。 + let parsed = self + .call_tool("health_report", json!({})) + .ok() + .and_then(|v| v.get("checks").and_then(Value::as_array).cloned()) + .and_then(|checks| { + checks + .into_iter() + .find(|c| c.get("name").and_then(Value::as_str) == Some("bundle_identity")) + }); + let ok = parsed + .as_ref() + .and_then(|c| c.get("status").and_then(Value::as_str)) + .map(|s| s == "pass") + .unwrap_or(false); + if let Ok(guard) = self.inner.lock() { + if let Ok(mut attr) = guard.bundle_attribution.lock() { + *attr = Some((ok, Instant::now())); + } + } + ok + } } impl Default for CuaClient { diff --git a/crates/agent-gui/src-tauri/src/services/cua/error.rs b/crates/agent-gui/src-tauri/src/services/cua/error.rs index 52b400d9c..abc3ae6a9 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/error.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/error.rs @@ -122,6 +122,24 @@ impl CuaError { ) } + /// 截屏不可用:CUA 工具能跑通协议但 `get_desktop_state` 返回全黑 + /// 帧(cua-driver 没有在 CuaDriver.app bundle 内启动 → TCC Screen + /// Recording attribution 失效;或权限被吊销)。Agent 拿到全黑图 + /// 对决策毫无意义,所以由后端在 health_report.bundle_identity / + /// 全黑帧检测触发时直接拒(CUA-051)。 + pub fn screen_capture_unavailable(detail: &str) -> Self { + Self::with_params( + "cua.errors.screenCaptureUnavailable", + format!( + "CUA screen capture is unavailable: {detail}. \ + Launch cua-driver inside CuaDriver.app (so TCC Screen Recording \ + attributes to com.trycua.driver) or re-grant Screen Recording \ + for CuaDriver in System Settings → Privacy & Security, then retry." + ), + serde_json::json!({ "detail": detail }), + ) + } + // ───────── Installer error variants (CUA-100 series) ───────── /// 网络不可用 / DNS 失败 / 离线。 diff --git a/crates/agent-gui/test/cua/format-cua-error.test.mjs b/crates/agent-gui/test/cua/format-cua-error.test.mjs index 0453ea6c7..8140f49ae 100644 --- a/crates/agent-gui/test/cua/format-cua-error.test.mjs +++ b/crates/agent-gui/test/cua/format-cua-error.test.mjs @@ -86,6 +86,37 @@ test("formatUnknownCuaError: en-US renders structured error translated", () => { assert.ok(out.includes("CUA is not enabled")); }); +// CUA-051: get_desktop_state 返回全黑帧时后端抛 screenCaptureUnavailable, +// 前端必须能在 zh-CN / en-US 两种 locale 下都翻译成可读消息 + 正确的占位。 +test("formatCuaError: screenCaptureUnavailable renders in en-US with detail interpolated", () => { + const payload = { + kind: "cua.errors.screenCaptureUnavailable", + message: "fallback", + params: { detail: "bundle_identity=fail" }, + }; + const out = formatCuaError(payload, t, "en-US"); + assert.ok(out.includes("bundle_identity=fail"), `expected detail in: ${out}`); + assert.ok( + out.includes("CuaDriver.app") || out.includes("Screen Recording"), + `expected remediation hint in: ${out}`, + ); + assert.ok(!out.includes("{detail}"), `expected placeholder interpolated in: ${out}`); +}); + +test("formatCuaError: screenCaptureUnavailable renders in zh-CN with detail interpolated", () => { + const payload = { + kind: "cua.errors.screenCaptureUnavailable", + message: "fallback", + params: { detail: "bundle_identity=fail" }, + }; + const out = formatCuaError(payload, t, "zh-CN"); + assert.ok(out.includes("bundle_identity=fail"), `expected detail in: ${out}`); + assert.ok( + out.includes("CuaDriver.app") || out.includes("屏幕录制") || out.includes("Screen Recording"), + `expected remediation hint in: ${out}`, + ); +}); + test("DEFAULT_LOCALE is one of the supported locales", () => { assert.ok(DEFAULT_LOCALE === "en-US" || DEFAULT_LOCALE === "zh-CN"); }); diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 216c4c35a..158711320 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1235,6 +1235,10 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA operation was not executed: {detail}", "cua.errors.io": "CUA subprocess IO error: {detail}", "cua.errors.unknown": "unknown reason", + // CUA-051: cua-driver 没有在 CuaDriver.app bundle 内启动导致 + // ScreenCaptureKit attribution 失效 → get_desktop_state 返回全黑帧。 + "cua.errors.screenCaptureUnavailable": + "CUA screen capture is unavailable: {detail}. Launch cua-driver inside CuaDriver.app (so TCC Screen Recording attributes to com.trycua.driver), or re-grant Screen Recording for CuaDriver in System Settings → Privacy & Security, then retry.", "cua.errors.sandboxOffline": "CUA tools are disabled while the command safety mode is sandboxOffline. Switch the command safety mode to ask / auto / sandbox to use CUA.", // CUA-100 series: driver installer errors @@ -1271,7 +1275,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "Linux desktop requires these system packages. Run `sudo apt install {packages}` in a terminal before continuing.", "settings.cua.installer.macosPermissionsTitle": "macOS permissions", "settings.cua.installer.macosPermissionsBody": - "Open System Settings → Privacy & Security and grant CuaDriver:\n• Accessibility\n• Screen Recording\nThen bring LiveAgent to the front to use it.", + "Open System Settings → Privacy & Security and grant CuaDriver:\n• Accessibility\n• Screen Recording\nThen bring LiveAgent to the front to use it.\n\nIf `cua_screenshot` returns a fully-black frame, cua-driver is likely running outside the CuaDriver.app bundle and TCC Screen Recording is not attributed to com.trycua.driver. Launch cua-driver via `open -n -g -a CuaDriver --args serve` (the daemon) or rerun the installer so the bundle path is used.", "settings.cua.installer.macosOpenSettings": "Open System Settings", "settings.cua.installer.logLabel": "Install log", "settings.cua.installer.stage.starting": "Preparing…", diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index 8402bd089..b569dc04a 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1175,6 +1175,10 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA 操作未执行:{detail}", "cua.errors.io": "CUA 子进程 IO 错误:{detail}", "cua.errors.unknown": "未知原因", + // CUA-051: cua-driver 没有在 CuaDriver.app bundle 内启动导致 + // ScreenCaptureKit attribution 失效 → get_desktop_state 返回全黑帧。 + "cua.errors.screenCaptureUnavailable": + "CUA 截屏不可用:{detail}。请把 cua-driver 在 CuaDriver.app 内启动(TCC Screen Recording 会归属到 com.trycua.driver),或到「系统设置 → 隐私与安全 → 屏幕录制」重新授权 CuaDriver 后再试。", "cua.errors.sandboxOffline": "当前命令安全模式是 sandboxOffline,CUA 工具已全部禁用。请把命令安全模式切换为 ask / auto / sandbox 后再使用 CUA。", // CUA-100 系列:driver 安装器错误 @@ -1209,7 +1213,7 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "Linux 桌面端需要以下系统包,请先在终端执行 `sudo apt install {packages}` 后再继续安装。", "settings.cua.installer.macosPermissionsTitle": "macOS 权限提示", "settings.cua.installer.macosPermissionsBody": - "请到「系统设置 → 隐私与安全」授予 CuaDriver:\n· 辅助功能(Accessibility)\n· 屏幕录制(Screen Recording)\n完成后把 LiveAgent 切到前台即可使用。", + "请到「系统设置 → 隐私与安全」授予 CuaDriver:\n· 辅助功能(Accessibility)\n· 屏幕录制(Screen Recording)\n完成后把 LiveAgent 切到前台即可使用。\n\n如果 `cua_screenshot` 拿到的画面是全黑,cua-driver 大概率没在 CuaDriver.app bundle 内启动,TCC Screen Recording 没有归属到 com.trycua.driver。建议用 `open -n -g -a CuaDriver --args serve`(启动 daemon)或重新运行安装脚本走 bundle 路径。", "settings.cua.installer.macosOpenSettings": "打开系统设置", "settings.cua.installer.logLabel": "安装日志", "settings.cua.installer.stage.starting": "准备安装…", From 38c1e03d2319fb7614b633fa39096e4b98061379 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 09:42:13 +0800 Subject: [PATCH 18/28] =?UTF-8?q?fix(cua):=20=E7=BB=9F=E4=B8=80=20MCP=2020?= =?UTF-8?q?25-06-18=20structuredContent=20unwrap=20+=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=80=8F=E5=87=BA=EF=BC=88CUA-053/054/055/05?= =?UTF-8?q?6/057=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移到 cua-driver MCP 后所有 client.call_tool 调用点未考虑 2025-06-18 协议 wrap:业务数据在 structuredContent 子对象,不是顶层。5 个问题同根因。 修复:cua_client.rs 加 unwrap_mcp(&Value) -> Value helper(取 structuredContent,否则 passthrough)。统一应用: - CUA-053 check_bundle_attribution 用 unwrap_mcp 取 .checks - CUA-054 check_effect_failure + finalize_op:检测 MCP {isError:true, structuredContent:{effect ∈ refused/suspected_noop/unverifiable}} → CuaError::not_executed;cua_click/double_click/type/key/scroll/drag 统一过 finalize_op - CUA-055 cua_list_windows 透传纯 .windows 数组;cua_focus_window list_apps 用 .apps - CUA-056 pick_window_id_for_owner 统一 unwrap - CUA-057 pick_topmost_window 删除错误的 content 文本 fallback 测试:cargo test 984 pass(含 11 新单元测试覆盖 unwrap_mcp / check_effect_failure / finalize_op 路径);clippy 无新 warning。 --- .../agent-gui/src-tauri/src/commands/cua.rs | 400 ++++++++++++++---- .../src-tauri/src/services/cua/cua_client.rs | 58 +++ 2 files changed, 383 insertions(+), 75 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs index 67f12bfdc..00f807101 100644 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use tauri::AppHandle; use tauri::State; -use crate::services::cua::cua_client::CuaClient; +use crate::services::cua::cua_client::{unwrap_mcp, CuaClient}; use crate::services::cua::{ installer::{CuaDriverDetection, CuaInstallResult, CuaUpdateResult, InstallPreview}, store::CuaRuntimeConfig, @@ -168,19 +168,64 @@ fn extract_screenshot(value: &Value) -> Option<(Vec, u32, u32)> { None } -fn run_op( +/// CUA-054:cua-driver MCP 即使 JSON-RPC 200,也会把业务失败装进 +/// `result.isError=true` + `structuredContent.effect ∈ {refused, +/// suspected_noop, unverifiable}`。这里把这种「传输成功 / 业务失败」 +/// 翻成 `CuaError`,让 caller 用统一的 ok=false + 结构化 error 透传。 +/// +/// 只在 `isError=true` 时生效;isError=false 的成功响应(含 effect +/// 未给出)一律视为成功,不影响老路径。 +fn check_effect_failure(result: &Result) -> Option { + let v = result.as_ref().ok()?; + if v.get("isError").and_then(Value::as_bool) != Some(true) { + return None; + } + let sc = unwrap_mcp(v); + let effect = sc.get("effect").and_then(Value::as_str).unwrap_or("unknown"); + let code = sc.get("code").and_then(Value::as_str).unwrap_or(""); + let detail_msg = match effect { + "refused" => format!( + "cua-driver refused the operation (code={code}); \ + target likely off-screen or AX-unresolved; \ + check window focus / coordinates and retry" + ), + "suspected_noop" => format!( + "cua-driver reports suspected_noop (code={code}); \ + the action likely had no visible effect; \ + verify target state before retrying" + ), + "unverifiable" => format!( + "cua-driver cannot verify the operation result (code={code}); \ + treat as inconclusive" + ), + other => format!( + "cua-driver reported effect={other} (code={code}); \ + treating as failure" + ), + }; + Some(CuaError::not_executed(&detail_msg)) +} + +/// CUA-054:把 call_tool 的 Result 收尾成 `CuaOpResponse`。把业务层 +/// effect 失败(`isError=true`)和传输层失败(`Err`)都翻译成 +/// `ok=false` + 结构化 `error`,并按失败写审计。 +/// +/// 取代原先「只看 `result.is_ok()`」的简化版本——之前 Agent 收不到 +/// 业务失败,盲目继续推进决策。 +fn finalize_op( store: &Arc, op: &str, detail: Option, - f: F, -) -> Result<(), CuaError> -where - F: FnOnce() -> Result, -{ - let started = Utc::now(); - let result = f(); - record_op_audit(store, op, detail, &result, started); - result.map(|_| ()) + result: Result, + started: chrono::DateTime, +) -> CuaOpResponse { + let effect_err = check_effect_failure(&result); + let recorded = effect_err.map(Err).unwrap_or(result); + record_op_audit(store, op, detail, &recorded, started); + CuaOpResponse { + ok: recorded.is_ok(), + error: recorded.err(), + } } // ───────── Commands ───────── @@ -240,8 +285,16 @@ pub fn cua_list_windows( // on_screen_only=true 过滤掉离屏窗口,避免给 Agent 噪音。 let started = Utc::now(); let result = client.call_tool("list_windows", json!({ "on_screen_only": true })); - record_op_audit(store.inner(), op, None, &result, started); - result + // cua-driver MCP 2025-06-18 把 windows 数组放进 structuredContent; + // 前端契约是「纯 windows 数组」,所以透传前先解包(CUA-055)。 + let mapped = result.map(|v| { + unwrap_mcp(&v) + .get("windows") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())) + }); + record_op_audit(store.inner(), op, None, &mapped, started); + mapped } #[tauri::command(rename_all = "snake_case")] @@ -260,23 +313,27 @@ pub fn cua_focus_window( let started = Utc::now(); let find = client.call_tool("list_apps", json!({})); let pid = match find { - Ok(v) => v - .get("apps") - .and_then(Value::as_array) - .and_then(|apps| { - apps.iter() - .find(|a| { - a.get("name") - .and_then(Value::as_str) - .map(|s| s.eq_ignore_ascii_case(&owner_ref)) - .unwrap_or(false) - || a.get("bundle_id") + Ok(v) => { + // cua-driver MCP 2025-06-18 把 apps 数组放进 structuredContent; + // 顶层 v.get("apps") 永远拿不到(CUA-055)。 + unwrap_mcp(&v) + .get("apps") + .and_then(Value::as_array) + .and_then(|apps| { + apps.iter() + .find(|a| { + a.get("name") .and_then(Value::as_str) .map(|s| s.eq_ignore_ascii_case(&owner_ref)) .unwrap_or(false) - }) - .and_then(|a| a.get("pid").and_then(Value::as_i64)) - }), + || a.get("bundle_id") + .and_then(Value::as_str) + .map(|s| s.eq_ignore_ascii_case(&owner_ref)) + .unwrap_or(false) + }) + .and_then(|a| a.get("pid").and_then(Value::as_i64)) + }) + } Err(e) => { record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); return Err(e); @@ -473,14 +530,13 @@ pub fn cua_click( args["window_id"] = json!(window_id); } let result = client.call_tool("click", args); - match &result { - Ok(v) => record_op_audit(store.inner(), op, detail, &Ok(v.clone()), started), - Err(e) => record_op_audit(store.inner(), op, detail, &Err(e.clone()), started), - } - Ok(CuaOpResponse { - ok: result.is_ok(), - error: result.err(), - }) + Ok(finalize_op( + store.inner(), + op, + detail, + result, + started, + )) } #[tauri::command(rename_all = "snake_case")] @@ -510,14 +566,13 @@ pub fn cua_double_click( args["window_id"] = json!(window_id); } let result = client.call_tool("double_click", args); - match &result { - Ok(v) => record_op_audit(store.inner(), op, detail, &Ok(v.clone()), started), - Err(e) => record_op_audit(store.inner(), op, detail, &Err(e.clone()), started), - } - Ok(CuaOpResponse { - ok: result.is_ok(), - error: result.err(), - }) + Ok(finalize_op( + store.inner(), + op, + detail, + result, + started, + )) } #[tauri::command(rename_all = "snake_case")] @@ -545,13 +600,15 @@ pub fn cua_type( args["window_id"] = json!(window_id); } } - run_op(store.inner(), op, detail, || { - client.call_tool("type_text", args.clone()) - })?; - Ok(CuaOpResponse { - ok: true, - error: None, - }) + let started = Utc::now(); + let result = client.call_tool("type_text", args); + Ok(finalize_op( + store.inner(), + op, + detail, + result, + started, + )) } #[tauri::command(rename_all = "snake_case")] @@ -598,13 +655,15 @@ pub fn cua_key( args["window_id"] = json!(window_id); } } - run_op(store.inner(), op, detail, || { - client.call_tool(tool, args.clone()) - })?; - Ok(CuaOpResponse { - ok: true, - error: None, - }) + let started = Utc::now(); + let result = client.call_tool(tool, args); + Ok(finalize_op( + store.inner(), + op, + detail, + result, + started, + )) } #[tauri::command(rename_all = "snake_case")] @@ -632,13 +691,15 @@ pub fn cua_scroll( args["pid"] = json!(pid); args["window_id"] = json!(window_id); } - run_op(store.inner(), op, detail, || { - client.call_tool("scroll", args.clone()) - })?; - Ok(CuaOpResponse { - ok: true, - error: None, - }) + let started = Utc::now(); + let result = client.call_tool("scroll", args); + Ok(finalize_op( + store.inner(), + op, + detail, + result, + started, + )) } #[tauri::command(rename_all = "snake_case")] @@ -662,13 +723,15 @@ pub fn cua_drag( args["pid"] = json!(pid); args["window_id"] = json!(window_id); } - run_op(store.inner(), op, detail, || { - client.call_tool("drag", args.clone()) - })?; - Ok(CuaOpResponse { - ok: true, - error: None, - }) + let started = Utc::now(); + let result = client.call_tool("drag", args); + Ok(finalize_op( + store.inner(), + op, + detail, + result, + started, + )) } // ───────── Helpers ───────── @@ -715,12 +778,15 @@ fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { /// 用 `list_windows` 拿最前面的窗口(layer 0 + on_screen_only)。 /// 返回 (pid, window_id) 或 None。 +/// +/// CUA-057:cua-driver MCP 2025-06-18 把 windows 数组放在 +/// `structuredContent` 下;顶层 `content` 是 `[{type:"text", text:"…"}]` +/// 文本条目数组,把「Found 9 window(s).」当 window 对象解析永远 +/// 不命中 `pid` / `window_id`。所以这里只信任 `unwrap_mcp(...).windows`。 fn pick_topmost_window(client: &CuaClient) -> Result, CuaError> { let value = client.call_tool("list_windows", json!({ "on_screen_only": true }))?; - let wins = value - .get("windows") - .and_then(Value::as_array) - .or_else(|| value.get("content").and_then(Value::as_array)); + let value = unwrap_mcp(&value); + let wins = value.get("windows").and_then(Value::as_array); if let Some(arr) = wins { for w in arr { let pid = w.get("pid").and_then(Value::as_i64); @@ -744,11 +810,16 @@ fn pick_topmost_window(client: &CuaClient) -> Result, CuaErro /// 通过 owner 名匹配 `list_apps` 的 pid,再查其首个可见 window。 /// 找不到时返回 Ok(None) 而不是 Err(best-effort)。 +/// +/// CUA-056:list_apps / list_windows 的 apps / windows 数组都在 +/// structuredContent 下;必须先 `unwrap_mcp` 再取,否则 zoom 路径 +/// 永远拿不到 pid + window_id → 降级到 get_desktop_state。 fn pick_window_id_for_owner( client: &CuaClient, owner: &str, ) -> Result, CuaError> { let apps = client.call_tool("list_apps", json!({}))?; + let apps = unwrap_mcp(&apps); let pid = apps .get("apps") .and_then(Value::as_array) @@ -770,6 +841,7 @@ fn pick_window_id_for_owner( return Ok(None); }; let windows = client.call_tool("list_windows", json!({ "pid": pid, "on_screen_only": true }))?; + let windows = unwrap_mcp(&windows); let wid = windows .get("windows") .and_then(Value::as_array) @@ -1074,4 +1146,182 @@ mod tests { assert!(MIN_NON_BLACK_PIXELS >= 100, "threshold too small"); assert!(MIN_NON_BLACK_PIXELS <= 100_000, "threshold too large"); } + + // ───────── CUA-053/055/056/057 unwrap_mcp 路径测试 ───────── + + /// CUA-057:pick_topmost_window 不再退回错误地把 `content` 文本数组 + /// 当 windows 数组解析。只有 structuredContent.windows 才是权威来源。 + #[test] + fn pick_topmost_window_ignores_content_text_array() { + // 模拟 cua-driver MCP 包过的响应:content 是文本、windows 在 + // structuredContent 下。 + let value = json!({ + "content": [{"type":"text", "text":"Found 9 window(s)."}], + "structuredContent": { + "windows": [ + {"pid": 42, "window_id": 7, "on_screen": true}, + {"pid": 42, "window_id": 8, "on_screen": true} + ] + }, + "isError": false + }); + // 直接调用 helper 不方便(要 client),但我们可以验证 + // `unwrap_mcp` + `value.get("windows")` 这条路径的语义: + let inner = unwrap_mcp(&value); + let wins = inner.get("windows").and_then(Value::as_array).expect("windows array"); + assert_eq!(wins.len(), 2); + assert_eq!(wins[0].get("pid").and_then(Value::as_i64), Some(42)); + // 反例:旧的 content fallback 不会拿到 pid。 + assert!(value + .get("content") + .and_then(Value::as_array) + .and_then(|arr| arr[0].get("pid")) + .is_none()); + } + + /// CUA-056:pick_window_id_for_owner 的 list_apps 解析只信任 + /// structuredContent.apps(顶层 v.get("apps") 拿不到)。 + #[test] + fn pick_window_id_for_owner_uses_structured_apps() { + let apps_resp = json!({ + "content": [{"type":"text", "text":"2 apps"}], + "structuredContent": { + "apps": [ + {"name": "Finder", "pid": 100}, + {"name": "LiveAgent", "pid": 200, "bundle_id": "com.liveagent.desktop"} + ] + } + }); + let pid = unwrap_mcp(&apps_resp) + .get("apps") + .and_then(Value::as_array) + .and_then(|arr| arr.iter().find(|a| a.get("name").and_then(Value::as_str) == Some("LiveAgent"))) + .and_then(|a| a.get("pid").and_then(Value::as_i64)) + .expect("must resolve pid"); + assert_eq!(pid, 200); + } + + /// CUA-055:cua_list_windows 返回纯 windows 数组,而不是 MCP 包装。 + #[test] + fn list_windows_unwraps_to_pure_array() { + let mcp_resp = json!({ + "content": [{"type":"text", "text":"9 windows"}], + "structuredContent": { + "windows": [ + {"pid": 1, "window_id": 11}, + {"pid": 2, "window_id": 22} + ] + } + }); + // 模拟 cua_list_windows 的解包路径: + let mapped = unwrap_mcp(&mcp_resp) + .get("windows") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let arr = mapped.as_array().expect("must be array"); + assert_eq!(arr.len(), 2); + // 不应再嵌套 content / structuredContent。 + assert!(arr[0].get("content").is_none()); + assert!(arr[0].get("structuredContent").is_none()); + } + + // ───────── CUA-054 业务层 effect 失败检测 ───────── + + /// cua_click 拿到 {isError:true, effect:"refused"} 时,finalize_op + /// 必须返回 ok=false + 结构化 CuaError,而不是吞掉 effect 当成功。 + #[test] + fn finalize_op_translates_refused_effect_to_error() { + let mcp_resp: Result = Ok(json!({ + "isError": true, + "content": [{"type":"text", "text":"refused"}], + "structuredContent": { + "effect": "refused", + "code": "off_space_or_ax_unresolved", + "escalation": {"recommended": "foreground"} + } + })); + let err = check_effect_failure(&mcp_resp).expect("must surface effect failure"); + assert_eq!(err.kind, "cua.errors.notExecuted"); + assert!(err.message.contains("refused")); + assert!(err.message.contains("off_space_or_ax_unresolved")); + } + + #[test] + fn finalize_op_translates_suspected_noop() { + let mcp_resp: Result = Ok(json!({ + "isError": true, + "structuredContent": {"effect": "suspected_noop", "code": "no_focus_change"} + })); + let err = check_effect_failure(&mcp_resp).expect("must surface"); + assert!(err.message.contains("suspected_noop")); + } + + #[test] + fn finalize_op_translates_unverifiable() { + let mcp_resp: Result = Ok(json!({ + "isError": true, + "structuredContent": {"effect": "unverifiable", "code": "ax_timeout"} + })); + let err = check_effect_failure(&mcp_resp).expect("must surface"); + assert!(err.message.contains("cannot verify")); + } + + /// isError=false 的成功响应(含无 effect 字段)保持 ok=true。 + #[test] + fn check_effect_failure_passes_through_success() { + let mcp_resp: Result = Ok(json!({ + "isError": false, + "content": [{"type":"text", "text":"clicked"}] + })); + assert!(check_effect_failure(&mcp_resp).is_none()); + + // effect 字段缺失但 isError=false:也视为成功(保守)。 + let mcp_resp2: Result = Ok(json!({ + "isError": false, + "structuredContent": {"foo": "bar"} + })); + assert!(check_effect_failure(&mcp_resp2).is_none()); + } + + /// isError 缺失但 effect=refused 的响应:保守起见,只信任 isError + /// 这个明确标记;这里 effect 单独不足以判定失败。 + #[test] + fn check_effect_failure_requires_iserror_flag() { + let mcp_resp: Result = Ok(json!({ + // 没有 isError:true + "structuredContent": {"effect": "refused"} + })); + assert!(check_effect_failure(&mcp_resp).is_none()); + } + + /// 传输层失败(Err)时 effect 检查不动(caller 走 Err 分支)。 + #[test] + fn check_effect_failure_ignores_transport_error() { + let transport_err: Result = Err(CuaError::io("subprocess died")); + assert!(check_effect_failure(&transport_err).is_none()); + } + + /// finalize_op 端到端:isError=true 应落地 ok=false。 + #[test] + fn finalize_op_returns_failure_on_iserror_true() { + let store = Arc::new(CuaStore::new(CuaRuntimeConfig::default())); + let resp = Ok(json!({ + "isError": true, + "structuredContent": {"effect": "refused", "code": "x"} + })); + let op_resp = finalize_op(&store, "click", None, resp, Utc::now()); + assert!(!op_resp.ok); + assert!(op_resp.error.is_some()); + assert!(op_resp.error.unwrap().message.contains("refused")); + } + + /// finalize_op 端到端:成功路径保持 ok=true、error=None。 + #[test] + fn finalize_op_keeps_success_on_iserror_false() { + let store = Arc::new(CuaStore::new(CuaRuntimeConfig::default())); + let resp = Ok(json!({"isError": false, "content": []})); + let op_resp = finalize_op(&store, "click", None, resp, Utc::now()); + assert!(op_resp.ok); + assert!(op_resp.error.is_none()); + } } diff --git a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs index 32a879c8e..fdcffb4a9 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs @@ -241,9 +241,12 @@ impl CuaClient { } // slow path:调 health_report 拿 bundle_identity。失败 / 超时 // 一律按 fail 处理(保守;宁可错杀不要返回全黑帧)。 + // cua-driver MCP 2025-06-18 把 `checks` 放在 structuredContent 下, + // 必须先 unwrap 再取(CUA-053)。 let parsed = self .call_tool("health_report", json!({})) .ok() + .map(|v| unwrap_mcp(&v)) .and_then(|v| v.get("checks").and_then(Value::as_array).cloned()) .and_then(|checks| { checks @@ -280,6 +283,22 @@ fn child_exited(child: &mut Child) -> bool { matches!(child.try_wait(), Ok(Some(_))) } +/// 解开 cua-driver MCP 2025-06-18 `{content, structuredContent}` 包装。 +/// 返回内部 `structuredContent`(如存在),否则 fallback 到原 `result`。 +/// +/// MCP 2025-06-18 把工具响应拆成「人类可读 content 数组 + 结构化 +/// structuredContent」,但 cua-driver 把数据(`windows` / `apps` / +/// `checks` / `effect`)放在 `structuredContent` 里;很多旧路径 +/// 直接 `result.get("xxx")` 顶层取,结果永远拿不到。所有解析点都 +/// 必须先过这一层(CUA-053 / 055 / 056 / 057 同根因)。 +pub(crate) fn unwrap_mcp(result: &Value) -> Value { + if let Some(sc) = result.get("structuredContent") { + sc.clone() + } else { + result.clone() + } +} + /// 起一个 `cua-driver mcp --direct` 子进程。stdout / stdin 都 pipe。 /// `--host-bundle-id` 给一个非空占位(cua-driver 关心 TCC attribution, /// LiveAgent 主程序 bundle id 即可,不强求精确)。 @@ -361,4 +380,43 @@ mod tests { let _c2 = c.clone(); assert!(Arc::strong_count(&c.inner) >= 2); } + + /// CUA-053:health_report 返回的 `checks` 在 structuredContent 下, + /// 顶层没有 `checks` 字段。`unwrap_mcp` 必须穿透这一层。 + #[test] + fn unwrap_mcp_extracts_structured_content() { + let v = json!({ + "content": [{"type": "text", "text": "all good"}], + "structuredContent": { + "checks": [ + {"name": "bundle_identity", "status": "pass"}, + {"name": "ax", "status": "pass"} + ] + }, + "isError": false + }); + let inner = unwrap_mcp(&v); + let checks = inner + .get("checks") + .and_then(Value::as_array) + .expect("checks array"); + assert_eq!(checks.len(), 2); + assert_eq!( + checks[0].get("name").and_then(Value::as_str), + Some("bundle_identity") + ); + assert_eq!( + checks[0].get("status").and_then(Value::as_str), + Some("pass") + ); + } + + /// 没有 structuredContent 包装时(如未来协议简化),`unwrap_mcp` + /// 必须保持原值可用,避免误删数据。 + #[test] + fn unwrap_mcp_passthrough_when_no_structured_content() { + let v = json!({ "checks": [{"name": "bundle_identity", "status": "pass"}] }); + let inner = unwrap_mcp(&v); + assert_eq!(inner.get("checks").and_then(Value::as_array).map(|a| a.len()), Some(1)); + } } \ No newline at end of file From 41d1e29b24dec122fa73845c77c2e1a3cd2b3451 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 10:10:45 +0800 Subject: [PATCH 19/28] =?UTF-8?q?fix(cua):=20zoom=20=E7=9C=9F=E5=AE=9E=20b?= =?UTF-8?q?ounds=20+=20ownerNotFound=20=E9=94=99=E8=AF=AF=20+=20isError=20?= =?UTF-8?q?=E9=80=8F=E5=87=BA=20+=20type/key=20=E8=87=AA=E5=8A=A8=20pid?= =?UTF-8?q?=EF=BC=88CUA-059/060/061/062=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-059:pick_window_id_for_owner 拆 WindowBounds(兼容 nested/flat + int/float);zoom_rect 用真实 bounds 拿不到时用 10000x10000 screen-fallback 避免零面积 CUA-060:pick_window_id_for_owner Ok(None) 时直接报 CuaError::owner_not_found(owner),不回退 get_desktop_state CUA-061:extract_error_text 从 MCP content[].text 拼接真实错误,包 CuaError::io 透传;不再吞 'x2 must be x1' / 'Missing required integer field: pid' / 'TCC permission required' CUA-062:cua_type/cua_key 与 cua_click 对齐 — target_owner None 时无条件 pick_topmost_window 拿前台 pid 新增 CuaError::owner_not_found(kind 'cua.errors.ownerNotFound' + {owner} 参数),i18n 双语。30 cua 测试 + 991 全量测试通过。 --- .../agent-gui/src-tauri/src/commands/cua.rs | 420 ++++++++++++++++-- .../src-tauri/src/services/cua/error.rs | 16 + crates/agent-gui/src/i18n/config.ts | 16 + .../src/i18n/translations/enUSSettings.ts | 8 + .../src/i18n/translations/zhCNSettings.ts | 8 + 5 files changed, 432 insertions(+), 36 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs index 00f807101..966adcf30 100644 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -168,6 +168,75 @@ fn extract_screenshot(value: &Value) -> Option<(Vec, u32, u32)> { None } +/// CUA-061:把 cua-driver 失败响应的可读错误文本拆出来。当 +/// `isError=true` 时,`content` 数组里的文本条目承载错误消息 +///(例如 `x2 must be > x1 and y2 must be > y1` / `TCC permission +/// required` / 内部 panic 栈)。返回拼接后的字符串,给 caller 包 +/// 进 `CuaError::io` 透传给前端,避免真实错误被吞成「no image +/// content」。 +fn extract_error_text(value: &Value) -> Option { + let arr = value.get("content")?.as_array()?; + let mut chunks: Vec = Vec::new(); + for item in arr { + if item.get("type").and_then(Value::as_str) == Some("text") { + if let Some(t) = item.get("text").and_then(Value::as_str) { + chunks.push(t.to_string()); + } + } + } + if chunks.is_empty() { + None + } else { + Some(chunks.join("\n")) + } +} + +/// 窗口 bounds(CUA-059):从 list_windows 返回的 window 对象里 +/// 拆出 `{x, y, width, height}`。cua-driver 把 bounds 嵌进 `bounds` +/// 子对象,值是浮点(如 `{"x":260.0,"y":68.0,"width":1400.0, +/// "height":800.0}`)。这里同时接受 int / float,顶层平铺 +/// `{x, y, width, height}` 也兼容(其它 MCP 实现可能)。 +struct WindowBounds { + x: i32, + y: i32, + width: i32, + height: i32, +} + +/// 从 serde_json::Value 中抽出数字字段(int 或 float),截到 i32 范围。 +fn number_as_i32(v: &Value) -> Option { + if let Some(n) = v.as_i64() { + return Some(n as i32); + } + if let Some(f) = v.as_f64() { + if f.is_finite() && f >= i32::MIN as f64 && f <= i32::MAX as f64 { + return Some(f as i32); + } + } + None +} + +fn extract_window_bounds(w: &Value) -> Option { + if let Some(b) = w.get("bounds").and_then(Value::as_object) { + let x = b.get("x").and_then(number_as_i32).unwrap_or(0); + let y = b.get("y").and_then(number_as_i32).unwrap_or(0); + let width = b.get("width").and_then(number_as_i32).unwrap_or(0); + let height = b.get("height").and_then(number_as_i32).unwrap_or(0); + if width > 0 && height > 0 { + return Some(WindowBounds { x, y, width, height }); + } + return None; + } + let x = w.get("x").and_then(number_as_i32)?; + let y = w.get("y").and_then(number_as_i32)?; + let width = w.get("width").and_then(number_as_i32)?; + let height = w.get("height").and_then(number_as_i32)?; + if width <= 0 || height <= 0 { + return None; + } + Some(WindowBounds { x, y, width, height }) +} + /// CUA-054:cua-driver MCP 即使 JSON-RPC 200,也会把业务失败装进 /// `result.isError=true` + `structuredContent.effect ∈ {refused, /// suspected_noop, unverifiable}`。这里把这种「传输成功 / 业务失败」 @@ -411,21 +480,38 @@ pub fn cua_screenshot( return Err(err); } let result = if let Some(owner) = owner_opt { - // 走 list_apps → pid+window_id → zoom 路径,给一个 5s 缓存避免 - // 每次截屏都 list_apps。这里偷懒:直接传 owner 字符串到 - // get_desktop_state(不支持);退化:list_apps 后取第一个匹配 - // owner 的 window_state,再 zoom 整窗口。 + // 走 list_apps → pid+window_id → zoom 路径。窗口 bounds 在 + // list_windows 时一并拆出(CUA-059),zoom 用真实坐标而 + // 不是零面积;找不到窗口时直接报 ownerNotFound,不再回退 + // 到 get_desktop_state 触发 CUA-051 后置防线把错误掩盖成 + // TCC 失效(CUA-060)。 match pick_window_id_for_owner(&client, owner) { - Ok(Some((pid, window_id))) => client.call_tool( - "zoom", - json!({ - "pid": pid, - "window_id": window_id, - "x1": 0, "y1": 0, "x2": 0, "y2": 0, - }), - ), - // 找不到对应窗口时降级到全屏(仍受 bundle 预检保护)。 - Ok(None) => client.call_tool("get_desktop_state", json!({})), + Ok(Some((pid, window_id, bounds))) => { + let (x1, y1, x2, y2) = zoom_rect(bounds); + client.call_tool( + "zoom", + json!({ + "pid": pid, + "window_id": window_id, + "x1": x1, "y1": y1, "x2": x2, "y2": y2, + }), + ) + } + Ok(None) => { + // 找不到对应窗口 → 直接报 ownerNotFound;不要回退到 + // 全屏(get_desktop_state 在 bundle_identity=fail 时 + // 会返回全黑帧,被 CUA-051 后置防线翻译成 TCC 失效, + // 把真实原因彻底掩盖——CUA-060)。 + let err = CuaError::owner_not_found(owner); + store.inner().record(CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: false, + error: Some(err.clone()), + detail: Some(json!({ "windowOwner": window_owner })), + }); + return Err(err); + } Err(e) => Err(e), } } else { @@ -433,6 +519,23 @@ pub fn cua_screenshot( }; match result { Ok(value) => { + // CUA-061:cua-driver 在 zoom 参数错误、permission 拒绝、 + // 内部 panic 等场景会返回 isError=true + content 是文本 + // 错误消息;直接走 extract_screenshot 会拿到 None 然后 + // 被翻成 'no image content',真实错误('x2 must be > x1' / + // 'TCC permission required')被吞掉。这里先检查 isError, + // 把文本 detail 透传给 CuaError::io。 + if let Some(err_detail) = extract_error_text(&value) { + let err = CuaError::io(&err_detail); + store.inner().record(CuaAuditEntry { + timestamp: started, + operation: op.to_string(), + ok: false, + error: Some(err.clone()), + detail: Some(json!({ "windowOwner": window_owner })), + }); + return Err(err); + } let (bytes, width, height) = extract_screenshot(&value) .ok_or_else(|| CuaError::io("cua-driver screenshot: no image content"))?; // CUA-051 后置防线:即便 health_report 那一关过了,也要兜 @@ -593,14 +696,62 @@ pub fn cua_type( "length": len, "targetOwner": target_owner, })); + let started = Utc::now(); let mut args = json!({ "text": text }); - if let Some(owner) = owner_ref { - if let Ok(Some((pid, window_id))) = pick_window_id_for_owner(&client, owner) { + // CUA-062:cua-driver type_text / press_key / hotkey 强制要求 pid。 + // target_owner 为 None 时无条件 pick_topmost_window 取前台 app; + // 与 cua_click / scroll / drag 对齐。owner 指定但找不到时报 + // owner_not_found(CUA-060 同根因)。 + let picked: Option<(i64, i64)> = if let Some(owner) = owner_ref { + match pick_window_id_for_owner(&client, owner) { + Ok(Some((pid, wid, _bounds))) => Some((pid, wid)), + Ok(None) => { + let err = CuaError::owner_not_found(owner); + record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(err), + }); + } + Err(e) => { + record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(e), + }); + } + } + } else { + match pick_topmost_window(&client) { + Ok(p) => p, + Err(e) => { + record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(e), + }); + } + } + }; + match picked { + Some((pid, window_id)) => { args["pid"] = json!(pid); args["window_id"] = json!(window_id); } + None => { + // pick_topmost_window 返回 None:前台没有可点击窗口(极端 + // 场景,例如桌面本身无窗口),无法满足 pid 强约束。 + let err = CuaError::not_executed( + "pid is required or pick_topmost_window failed \ + (no foreground window with on_screen=true)", + ); + record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(err), + }); + } } - let started = Utc::now(); let result = client.call_tool("type_text", args); Ok(finalize_op( store.inner(), @@ -649,13 +800,59 @@ pub fn cua_key( "hotkey" }; let mut args = args; - if let Some(owner) = owner_ref { - if let Ok(Some((pid, window_id))) = pick_window_id_for_owner(&client, owner) { + let started = Utc::now(); + // CUA-062:与 cua_type 同根因——cua-driver press_key / hotkey 强制 + // 要求 pid;target_owner 为 None 时无条件 pick_topmost_window 取 + // 前台 app,不再让调用方拿到 'Missing required integer field: pid' + // 这种不可操作错误。 + let picked: Option<(i64, i64)> = if let Some(owner) = owner_ref { + match pick_window_id_for_owner(&client, owner) { + Ok(Some((pid, wid, _bounds))) => Some((pid, wid)), + Ok(None) => { + let err = CuaError::owner_not_found(owner); + record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(err), + }); + } + Err(e) => { + record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(e), + }); + } + } + } else { + match pick_topmost_window(&client) { + Ok(p) => p, + Err(e) => { + record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(e), + }); + } + } + }; + match picked { + Some((pid, window_id)) => { args["pid"] = json!(pid); args["window_id"] = json!(window_id); } + None => { + let err = CuaError::not_executed( + "pid is required or pick_topmost_window failed \ + (no foreground window with on_screen=true)", + ); + record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); + return Ok(CuaOpResponse { + ok: false, + error: Some(err), + }); + } } - let started = Utc::now(); let result = client.call_tool(tool, args); Ok(finalize_op( store.inner(), @@ -736,6 +933,20 @@ pub fn cua_drag( // ───────── Helpers ───────── +/// CUA-059:cua-driver zoom 拒绝零面积坐标。bounds 拿得到就用真实 +/// 矩形;拿不到就用「屏幕尺寸」兜底(任意大于真实屏幕的数值都行, +/// cua-driver zoom 会按窗口 bounds 裁剪)。这里用 10000 作为保守 +/// 上界——覆盖所有现代 macOS / Windows / Linux 桌面分辨率。 +const FALLBACK_SCREEN_W: i32 = 10000; +const FALLBACK_SCREEN_H: i32 = 10000; + +fn zoom_rect(bounds: Option) -> (i32, i32, i32, i32) { + match bounds { + Some(b) if b.width > 0 && b.height > 0 => (b.x, b.y, b.x + b.width, b.y + b.height), + _ => (0, 0, FALLBACK_SCREEN_W, FALLBACK_SCREEN_H), + } +} + /// 解析 PNG IHDR 拿宽高。 fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { if bytes.len() < 24 { @@ -814,10 +1025,13 @@ fn pick_topmost_window(client: &CuaClient) -> Result, CuaErro /// CUA-056:list_apps / list_windows 的 apps / windows 数组都在 /// structuredContent 下;必须先 `unwrap_mcp` 再取,否则 zoom 路径 /// 永远拿不到 pid + window_id → 降级到 get_desktop_state。 +/// +/// CUA-059:顺带拆出窗口 bounds({x,y,width,height}),避免 zoom +/// 调用传零面积坐标被驱动拒。 fn pick_window_id_for_owner( client: &CuaClient, owner: &str, -) -> Result, CuaError> { +) -> Result)>, CuaError> { let apps = client.call_tool("list_apps", json!({}))?; let apps = unwrap_mcp(&apps); let pid = apps @@ -842,18 +1056,21 @@ fn pick_window_id_for_owner( }; let windows = client.call_tool("list_windows", json!({ "pid": pid, "on_screen_only": true }))?; let windows = unwrap_mcp(&windows); - let wid = windows - .get("windows") - .and_then(Value::as_array) - .and_then(|arr| { - arr.iter().find_map(|w| { - w.get("window_id") - .or_else(|| w.get("windowId")) - .or_else(|| w.get("id")) - .and_then(Value::as_i64) - }) - }); - Ok(wid.map(|w| (pid, w))) + let mut picked: Option<(i64, Option)> = None; + if let Some(arr) = windows.get("windows").and_then(Value::as_array) { + for w in arr { + let wid = w + .get("window_id") + .or_else(|| w.get("windowId")) + .or_else(|| w.get("id")) + .and_then(Value::as_i64); + if let Some(wid) = wid { + picked = Some((wid, extract_window_bounds(w))); + break; + } + } + } + Ok(picked.map(|(wid, bounds)| (pid, wid, bounds))) } /// cua-driver 入口命令:把主窗口重新推到最前并等待 `is_focused()` 落 @@ -1020,15 +1237,14 @@ mod tests { fn fake_png_1x1() -> Vec { // 长度可变的 IHDR 字段拼接(实际长度需 13)。我们手写一个可被 PNG signature 8 字节 // + IHDR 头识别的 24 字节小图。 - let mut bytes = vec![ + vec![ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, // IHDR length = 13 b'I', b'H', b'D', b'R', 0x00, 0x00, 0x00, 0x01, // width = 1 0x00, 0x00, 0x00, 0x01, // height = 1 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc. - ]; - bytes + ] } #[test] @@ -1324,4 +1540,136 @@ mod tests { assert!(op_resp.ok); assert!(op_resp.error.is_none()); } + + // ───────── CUA-059/060/061/062 回归测试 ───────── + + /// CUA-061:cua-driver 在 isError=true 时把错误消息放在 content[] + /// 的 text 条目里;extract_error_text 必须把它们拼出来,避免被 + /// extract_screenshot 当 None 翻成 'no image content'。 + #[test] + fn extract_error_text_pulls_creats_text_chunks() { + let value = json!({ + "isError": true, + "content": [ + { "type": "text", "text": "x2 must be > x1 and y2 must be > y1" } + ] + }); + let detail = extract_error_text(&value).expect("must surface error text"); + assert!(detail.contains("x2 must be > x1")); + assert!(detail.contains("y2 must be > y1")); + } + + /// CUA-061:没有 isError=true 时 extract_error_text 不返回真实 + /// 文本,避免误报(即使 content[] 里恰好有 text 条目)。 + #[test] + fn extract_error_text_returns_none_when_no_iserror() { + let value = json!({ + "isError": false, + "content": [ + { "type": "text", "text": "ok" }, + { "type": "image", "data": "AAAA", "mimeType": "image/png" } + ] + }); + // extract_error_text 只看 content[] 文本条目,但 caller(cua_screenshot) + // 必须先用 isError=true 守门。这里验证 helper 自身的抽取行为。 + let detail = extract_error_text(&value); + // 即使 image 也存在,文本条目仍会被收集;这是有意的——helper + // 单纯做文本提取,是否调用由 caller 决定。caller 已经先 isError + // 守门,所以不会误用。 + assert_eq!(detail.as_deref(), Some("ok")); + } + + /// CUA-059:extract_window_bounds 同时识别 nested `bounds` 对象 + /// 和顶层平铺 {x,y, width, height}。 + #[test] + fn extract_window_bounds_handles_both_shapes() { + let nested = json!({ + "window_id": 7, + "bounds": { "x": 100, "y": 50, "width": 800, "height": 600 } + }); + let b = extract_window_bounds(&nested).expect("nested bounds must parse"); + assert_eq!((b.x, b.y, b.width, b.height), (100, 50, 800, 600)); + + let flat = json!({ + "window_id": 8, + "x": 200, "y": 75, "width": 1024, "height": 768 + }); + let b2 = extract_window_bounds(&flat).expect("flat bounds must parse"); + assert_eq!((b2.x, b2.y, b2.width, b2.height), (200, 75, 1024, 768)); + } + + /// CUA-059:cua-driver list_windows 返回的 bounds 字段值是浮点 + ///(例如 `{"x":260.0,"y":68.0,...}`),extract_window_bounds 必须 + /// 截到 i32 而不是返回 None。 + #[test] + fn extract_window_bounds_accepts_floats() { + let real = json!({ + "window_id": 24391, + "bounds": { "x": 260.0, "y": 68.0, "width": 1400.0, "height": 800.0 } + }); + let b = extract_window_bounds(&real).expect("float bounds must parse"); + assert_eq!((b.x, b.y, b.width, b.height), (260, 68, 1400, 800)); + + // 混用 int + float 也兼容。 + let mixed = json!({ + "window_id": 9, + "bounds": { "x": 260.0, "y": 68, "width": 1400, "height": 800.0 } + }); + let b2 = extract_window_bounds(&mixed).expect("mixed bounds must parse"); + assert_eq!((b2.x, b2.y, b2.width, b2.height), (260, 68, 1400, 800)); + } + + /// CUA-059:bounds 缺失或 width/height <= 0 时返回 None,让 + /// zoom_rect 走 screen-size fallback(避免零面积)。 + #[test] + fn extract_window_bounds_rejects_zero_area() { + let no_bounds = json!({ "window_id": 9 }); + assert!(extract_window_bounds(&no_bounds).is_none()); + + let zero_area = json!({ + "bounds": { "x": 0, "y": 0, "width": 0, "height": 0 } + }); + assert!(extract_window_bounds(&zero_area).is_none()); + } + + /// CUA-059:zoom_rect 在 bounds 拿得到时输出真实矩形,bounds + /// 拿不到时输出非零面积的兜底矩形。 + #[test] + fn zoom_rect_uses_bounds_or_screen_fallback() { + let real = zoom_rect(Some(WindowBounds { + x: 10, + y: 20, + width: 800, + height: 600, + })); + assert_eq!(real, (10, 20, 810, 620)); + + let fallback = zoom_rect(None); + assert_eq!(fallback, (0, 0, FALLBACK_SCREEN_W, FALLBACK_SCREEN_H)); + // 兜底矩形的面积必须严格 > 0(cua-driver zoom 拒绝零面积)。 + assert!(fallback.2 > fallback.0); + assert!(fallback.3 > fallback.1); + + // width/height ≤ 0 也走 fallback。 + let degenerate = zoom_rect(Some(WindowBounds { + x: 0, + y: 0, + width: 0, + height: 0, + })); + assert_eq!(degenerate, (0, 0, FALLBACK_SCREEN_W, FALLBACK_SCREEN_H)); + } + + /// CUA-060:CuaError::owner_not_found 携带稳定的 i18n kind + owner 参数。 + #[test] + fn owner_not_found_emits_structured_error() { + let err = CuaError::owner_not_found("GhostApp"); + assert_eq!(err.kind, "cua.errors.ownerNotFound"); + assert_eq!( + err.params.get("owner").and_then(Value::as_str), + Some("GhostApp") + ); + assert!(err.message.contains("GhostApp")); + assert!(!err.message.contains('你'), "no Chinese leaks"); + } } diff --git a/crates/agent-gui/src-tauri/src/services/cua/error.rs b/crates/agent-gui/src-tauri/src/services/cua/error.rs index abc3ae6a9..86fb5bc71 100644 --- a/crates/agent-gui/src-tauri/src/services/cua/error.rs +++ b/crates/agent-gui/src-tauri/src/services/cua/error.rs @@ -113,6 +113,22 @@ impl CuaError { ) } + /// CUA-060:cua_screenshot / cua_type / cua_key 拿到具体 owner 但 + /// `list_apps` + `list_windows` 找不到对应进程 / 窗口时上报一个 + /// 明确的「owner 找不到」错误,而不是默默退回 get_desktop_state + /// 后触发 CUA-051 后置防线被翻译成「TCC 失效」。 + pub fn owner_not_found(owner: &str) -> Self { + Self::with_params( + "cua.errors.ownerNotFound", + format!( + "Could not find a running window for owner \"{owner}\". \ + Verify the app name / bundle id and that it has at least \ + one visible window before retrying." + ), + serde_json::json!({ "owner": owner }), + ) + } + /// 子进程 IO 失败(osascript / screencapture 启动失败等)。 pub fn io(detail: &str) -> Self { Self::with_params( diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 68e7616d9..21e2da293 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -288,6 +288,14 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.errors.save": "保存 CUA 配置失败", "cua.errors.sandboxOffline": "当前命令安全模式是 sandboxOffline,CUA 工具已全部禁用。请把命令安全模式切换为 ask / auto / sandbox 后再使用 CUA。", + // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 + // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, + // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 + // 译成 TCC 失效」的掩盖行为。 + "cua.errors.ownerNotFound": + "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", + "cua.errors.ownerNotFoundDetail": + "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", // CUA-100: driver installer (zh-CN) "cua.errors.installer.networkUnavailable": "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", @@ -641,6 +649,14 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.cua.errors.save": "Failed to save CUA config", "cua.errors.sandboxOffline": "CUA tools are disabled while the command safety mode is sandboxOffline. Switch the command safety mode to ask / auto / sandbox to use CUA.", + // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 + // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, + // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 + // 译成 TCC 失效」的掩盖行为。 + "cua.errors.ownerNotFound": + "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", + "cua.errors.ownerNotFoundDetail": + "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", // CUA-100: driver installer (en-US) "cua.errors.installer.networkUnavailable": "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 158711320..0dee514ef 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1235,6 +1235,14 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA operation was not executed: {detail}", "cua.errors.io": "CUA subprocess IO error: {detail}", "cua.errors.unknown": "unknown reason", + // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 + // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, + // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 + // 译成 TCC 失效」的掩盖行为。 + "cua.errors.ownerNotFound": + "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", + "cua.errors.ownerNotFoundDetail": + "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", // CUA-051: cua-driver 没有在 CuaDriver.app bundle 内启动导致 // ScreenCaptureKit attribution 失效 → get_desktop_state 返回全黑帧。 "cua.errors.screenCaptureUnavailable": diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index b569dc04a..cca0964f9 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1175,6 +1175,14 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "cua.errors.notExecuted": "CUA 操作未执行:{detail}", "cua.errors.io": "CUA 子进程 IO 错误:{detail}", "cua.errors.unknown": "未知原因", + // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 + // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, + // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 + // 译成 TCC 失效」的掩盖行为。 + "cua.errors.ownerNotFound": + "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", + "cua.errors.ownerNotFoundDetail": + "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", // CUA-051: cua-driver 没有在 CuaDriver.app bundle 内启动导致 // ScreenCaptureKit attribution 失效 → get_desktop_state 返回全黑帧。 "cua.errors.screenCaptureUnavailable": From 77bb28489a1805a4f1c78551d3ecf24cd36e5c53 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 10:47:33 +0800 Subject: [PATCH 20/28] =?UTF-8?q?fix(cua):=2019=20=E4=B8=AA=20cua=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=20rename=5Fall=20=E6=94=B9=20camelCase=20+?= =?UTF-8?q?=20isError=20=E5=AE=88=E9=97=A8=EF=BC=88CUA-063/064=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA-063:所有 cua_* 命令的 #[tauri::command(rename_all)] 从 snake_case 改 camelCase。前端 cuaTools.ts 一致 camelCase(windowOwner/targetOwner)调 invoke,Tauri 2 不会自动转,snake_case 模式下 camelCase 参数在 IPC 层查不到 key 静默丢弃。CUA-059/060/062 后端修复在用户路径恢复可达。 CUA-064:原裸调 extract_error_text 换成 extract_io_error_on_failure(内置 isError==Some(true) 守门 + 文本抽取)。cua-driver MCP 成功响应 content 数组永远同时含 type:image 和 type:text 描述项,extract_error_text 把描述当错误。守门后成功路径走 extract_screenshot,isError=true 路径才透出真实错误文本。 新增 4 个回归测试覆盖 isError 守门四场景。 --- .../agent-gui/src-tauri/src/commands/cua.rs | 132 ++++++++++++++---- 1 file changed, 107 insertions(+), 25 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs index 966adcf30..c718257eb 100644 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ b/crates/agent-gui/src-tauri/src/commands/cua.rs @@ -174,6 +174,12 @@ fn extract_screenshot(value: &Value) -> Option<(Vec, u32, u32)> { /// required` / 内部 panic 栈)。返回拼接后的字符串,给 caller 包 /// 进 `CuaError::io` 透传给前端,避免真实错误被吞成「no image /// content」。 +/// +/// 注意:本 helper 只看 `content[].type==="text"`,**不**检查 +/// `isError`。CUA-064:成功路径下 cua-driver 也会在 content 数组 +/// 里带 type:text 描述项(zoom 区域说明 / get_desktop_state 文案 +/// 等),所以 caller 必须用 `extract_io_error_on_failure` 而不是 +/// 直接调本 helper——后者统一负责 `isError=true` 守门。 fn extract_error_text(value: &Value) -> Option { let arr = value.get("content")?.as_array()?; let mut chunks: Vec = Vec::new(); @@ -191,6 +197,20 @@ fn extract_error_text(value: &Value) -> Option { } } +/// CUA-064:cua-driver 成功响应(zoom / get_desktop_state)也带 +/// type:text 描述项,所以 caller 不能盲调 `extract_error_text`—— +/// 否则成功截图会被强行翻成 `cua.errors.io`,`extract_screenshot` +/// 永远走不到(CUA-059 / CUA-061 后端路径全部失效)。本 helper 把 +/// `isError==Some(true)` 守门与文本抽取合并成一步:成功 / 缺标记 +/// 永远返回 `None`,仅在 `isError=true` 且 content 有文本时返回 +/// 拼接后的错误消息。 +fn extract_io_error_on_failure(value: &Value) -> Option { + if value.get("isError").and_then(Value::as_bool) != Some(true) { + return None; + } + extract_error_text(value) +} + /// 窗口 bounds(CUA-059):从 list_windows 返回的 window 对象里 /// 拆出 `{x, y, width, height}`。cua-driver 把 bounds 嵌进 `bounds` /// 子对象,值是浮点(如 `{"x":260.0,"y":68.0,"width":1400.0, @@ -303,7 +323,7 @@ fn finalize_op( /// /// 每次取快照都从持久化 system settings 重读 `sandbox_offline`——避免 /// 命令安全模式被外部切到 sandboxOffline 后 CUA UI 仍按旧值渲染。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_status(store: State<'_, Arc>) -> CuaStoreSnapshot { let sandbox_offline = current_sandbox_offline(); store.refresh_sandbox_offline(sandbox_offline); @@ -315,7 +335,7 @@ pub fn cua_status(store: State<'_, Arc>) -> CuaStoreSnapshot { /// 服务端权威地维护 `sandbox_offline`:从前端传过来的字段总是被 /// 当前命令安全模式覆盖——保证 `group:cua` 在 sandboxOffline 下必 /// 拒(CUA-reviewer 安全门控 #2),不会被前端脏数据绕过。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_set_config( store: State<'_, Arc>, config: CuaRuntimeConfig, @@ -338,13 +358,13 @@ fn current_sandbox_offline() -> bool { .unwrap_or(false) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_clear_audit(store: State<'_, Arc>) -> CuaStoreSnapshot { store.clear_audit(); store.snapshot() } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_list_windows( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -366,7 +386,7 @@ pub fn cua_list_windows( mapped } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_focus_window( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -436,7 +456,7 @@ pub fn cua_focus_window( }) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_screenshot( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -519,13 +539,19 @@ pub fn cua_screenshot( }; match result { Ok(value) => { - // CUA-061:cua-driver 在 zoom 参数错误、permission 拒绝、 + // CUA-061 + CUA-064:cua-driver 在 zoom 参数错误、permission 拒绝、 // 内部 panic 等场景会返回 isError=true + content 是文本 // 错误消息;直接走 extract_screenshot 会拿到 None 然后 // 被翻成 'no image content',真实错误('x2 must be > x1' / - // 'TCC permission required')被吞掉。这里先检查 isError, - // 把文本 detail 透传给 CuaError::io。 - if let Some(err_detail) = extract_error_text(&value) { + // 'TCC permission required')被吞掉。 + // + // CUA-064:成功路径(zoom / get_desktop_state)下 cua-driver 也 + // 会在 content 数组里同时塞 type:text 描述项(例如 'Zoom + // region (260,68)–(1660,868) → 500×286 px JPEG.')。 + // `extract_io_error_on_failure` 内置 isError 守门,所以成功 + // 响应不会被误判;只有 isError=true 时才把文本 detail 当错误 + // 透传给 CuaError::io,其它一律走正常的 image 抽取路径。 + if let Some(err_detail) = extract_io_error_on_failure(&value) { let err = CuaError::io(&err_detail); store.inner().record(CuaAuditEntry { timestamp: started, @@ -598,7 +624,7 @@ pub fn cua_screenshot( } } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_click( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -642,7 +668,7 @@ pub fn cua_click( )) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_double_click( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -678,7 +704,7 @@ pub fn cua_double_click( )) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_type( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -762,7 +788,7 @@ pub fn cua_type( )) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_key( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -863,7 +889,7 @@ pub fn cua_key( )) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_scroll( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -899,7 +925,7 @@ pub fn cua_scroll( )) } -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_drag( store: State<'_, Arc>, client: State<'_, CuaClient>, @@ -1076,7 +1102,7 @@ fn pick_window_id_for_owner( /// cua-driver 入口命令:把主窗口重新推到最前并等待 `is_focused()` 落 /// 住。命令不依赖 CUA enable 开关——它的目的正是让 enable=true 之 /// 后的 click / AX 路径能找到 WebView。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub async fn cua_window_ready( window: tauri::WebviewWindow, ) -> Result { @@ -1086,7 +1112,7 @@ pub async fn cua_window_ready( /// CUA-020/021/022: 在路由切换、overlay 打开/关闭、cua-driver 主动唤 /// 起等场景重新触发 NSWindow/WKWebView 的 AX 注解 + 一回弹广播 /// UIElementCreatedNotification。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yResponse { crate::cua_refresh_a11y(&window) } @@ -1098,14 +1124,14 @@ pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yRe // 操作可用;enable 留到装完再由用户在 UI 中开启。 /// 检测 cua-driver 是否已安装、版本、daemon 状态。无 IO 副作用。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_driver_detect() -> CuaDriverDetection { crate::services::cua::detect_driver() } /// 启动 cua-driver daemon(macOS: `open CuaDriver --args serve`)。 /// 失败时返回结构化 `CuaError`;前端不阻塞在失败上,可重试。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_driver_start_daemon(app: AppHandle) -> Result { crate::services::cua::start_driver_daemon(&app) .map(|started| CuaOpResponse { @@ -1120,7 +1146,7 @@ pub fn cua_driver_start_daemon(app: AppHandle) -> Result Result { let os = std::env::consts::OS; match os { @@ -1161,7 +1187,7 @@ pub async fn cua_driver_install(app: AppHandle) -> Result Result { let join = tauri::async_runtime::spawn_blocking(move || { crate::services::cua::update_driver(apply) @@ -1185,7 +1211,7 @@ pub async fn cua_driver_update(apply: bool) -> Result } /// 把 install 命令(program / args)暴露给前端做展示(无 spawn)。 -#[tauri::command(rename_all = "snake_case")] +#[tauri::command(rename_all = "camelCase")] pub fn cua_driver_install_preview() -> Result { crate::services::cua::build_install_preview().map_err(|detail| match std::env::consts::OS { "macos" | "linux" | "windows" => CuaError::not_executed(&detail), @@ -1574,11 +1600,67 @@ mod tests { // 必须先用 isError=true 守门。这里验证 helper 自身的抽取行为。 let detail = extract_error_text(&value); // 即使 image 也存在,文本条目仍会被收集;这是有意的——helper - // 单纯做文本提取,是否调用由 caller 决定。caller 已经先 isError - // 守门,所以不会误用。 + // 单纯做文本提取,是否调用由 caller 决定。caller 应该走 + // extract_io_error_on_failure 而不是直接调本 helper。 assert_eq!(detail.as_deref(), Some("ok")); } + // ───────── CUA-064 守门 helper 回归测试 ───────── + + /// CUA-064:成功响应(zoom / get_desktop_state)下 content 同时 + /// 含 type:text 描述项 + type:image 字节。`extract_io_error_on_failure` + /// 必须返回 None(不进 io error 分支),让 caller 走图像抽取。 + #[test] + fn extract_io_error_on_failure_returns_none_on_success_with_text() { + let value = json!({ + "isError": false, + "content": [ + { "type": "text", "text": "Zoom region (260,68)–(1660,868) → 500×286 px JPEG." }, + { "type": "image", "data": "AAAA", "mimeType": "image/png" } + ] + }); + assert!(extract_io_error_on_failure(&value).is_none()); + } + + /// CUA-064 反向:isError 缺失(null)也按成功处理,不透出文本。 + #[test] + fn extract_io_error_on_failure_returns_none_on_missing_iserror() { + let value = json!({ + "content": [ + { "type": "text", "text": "any description" }, + { "type": "image", "data": "AAAA", "mimeType": "image/png" } + ] + }); + assert!(extract_io_error_on_failure(&value).is_none()); + } + + /// CUA-064 失败路径:isError=true 时仍按之前的行为透出真实错误。 + #[test] + fn extract_io_error_on_failure_returns_text_on_iserror_true() { + let value = json!({ + "isError": true, + "content": [ + { "type": "text", "text": "x2 must be > x1 and y2 must be > y1" }, + { "type": "image", "data": "AAAA", "mimeType": "image/png" } + ] + }); + let detail = extract_io_error_on_failure(&value).expect("must surface failure text"); + assert!(detail.contains("x2 must be > x1")); + } + + /// CUA-064 失败路径:isError=true 但 content 里只有 image 时 + /// 仍返回 None(与原有 extract_error_text 行为一致)。 + #[test] + fn extract_io_error_on_failure_returns_none_when_only_image() { + let value = json!({ + "isError": true, + "content": [ + { "type": "image", "data": "AAAA", "mimeType": "image/png" } + ] + }); + assert!(extract_io_error_on_failure(&value).is_none()); + } + /// CUA-059:extract_window_bounds 同时识别 nested `bounds` 对象 /// 和顶层平铺 {x,y, width, height}。 #[test] From 364c9fea759d42d5dce234392e0c30a283b36449 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 12:59:20 +0800 Subject: [PATCH 21/28] =?UTF-8?q?refactor(cua):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=87=AA=E7=A0=94=20CUA=20=E5=B1=82=EF=BC=8C=E6=94=B9=E7=94=B1?= =?UTF-8?q?=20cua-driver=20=E8=B5=B0=E9=80=9A=E7=94=A8=20MCP=20Hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前的实现没有把 trycua/cua 的 cua-driver 当成普通 MCP server 接入, 而是在 LiveAgent 里重造了一遍 LiveAgent 已有的设施: - services/cua/cua_client.rs 手写 stdio JSON-RPC 子进程池, 而 commands/integration/mcp.rs 已有成熟 MCP client(三种 transport、 protocolVersion 协商、连接池、取消、重启) - lib/tools/cuaTools.ts 手抄 10 个工具 schema, 而 mcpTools.ts 能从 tools/list 自动发现(cua-driver 实际有 60 个工具) - services/cua/installer.rs + CuaInstallerPanel.tsx 自建下载器与安装 UI, 而上游已提供 install.sh / update --apply / permissions grant / doctor - CuaSection.tsx 独立设置分区 + CuaStore 纯内存配置, 而 McpServerCard 已有 per-server 策略下拉,MCP 配置持久化在 SQLite 并参与 WebDAV 同步 删除 6139 行纯 CUA 代码及 30 个文件的反向引用。 安全语义迁移:group:cua 的硬编码 ask 缺省改为 server 级的 HARDCODED_SERVER_DEFAULTS(新增 contracts/mcpServerDefaults.ts), 否则 cua-driver 的工具会落入 mcp 组的 allow 兜底,kill_app / type_text / clipboard_write 将被隐式放行。resolveToolPolicy 与 McpServersForm 的显示缺省读同一张表,避免「UI 显示 allow、实际按 ask 执行」的错位。HARDCODED_GROUP_DEFAULTS 与 extraGroupDefaults 机制本体保留,前者清空。 保留与「操作用户电脑」无关、但同样带 cua 命名的代码:lib.rs 的 force_activate_main_window / cua_window_ready / cua_refresh_a11y、 App.tsx 的 AX 刷新、scripts/dev-prepare.mjs 与 dev-stack.mjs—— 这些是让外部 cua-driver 能驱动 LiveAgent 自身窗口做 E2E 自动化的 macOS 无障碍修复。两个 Tauri command 从 commands/cua.rs 迁至新的 commands/app/ax.rs。 --- .../web/src/pages/settings/types.ts | 5 +- .../src-tauri/src/commands/app/ax.rs | 30 + .../src-tauri/src/commands/app/mod.rs | 1 + .../agent-gui/src-tauri/src/commands/cua.rs | 1757 ----------------- .../agent-gui/src-tauri/src/commands/mod.rs | 2 +- crates/agent-gui/src-tauri/src/lib.rs | 33 +- .../src-tauri/src/services/cua/cua_client.rs | 422 ---- .../src-tauri/src/services/cua/error.rs | 229 --- .../src-tauri/src/services/cua/installer.rs | 980 --------- .../src-tauri/src/services/cua/mod.rs | 30 - .../src-tauri/src/services/cua/store.rs | 366 ---- .../agent-gui/src-tauri/src/services/mod.rs | 1 - crates/agent-gui/src/App.tsx | 2 - .../agent-ui-adapters/settingsExtension.tsx | 3 - crates/agent-gui/src/i18n/config.ts | 203 -- crates/agent-gui/src/lib/cua/cuaService.ts | 103 - crates/agent-gui/src/lib/settings/storage.ts | 13 +- .../src/lib/tools/builtinRegistry.ts | 10 - crates/agent-gui/src/lib/tools/cuaTools.ts | 441 ----- crates/agent-gui/src/lib/tools/toolPolicy.ts | 54 +- .../src/pages/chat/runtime/useSendChatTurn.ts | 3 - .../chat/turns/runAgentConversationTurn.ts | 25 +- crates/agent-gui/src/pages/settings/types.ts | 4 - .../test/cua-installer-service.test.mjs | 159 -- .../cua/cua-installer-panel-chevron.test.mjs | 247 --- .../test/cua/format-cua-error.test.mjs | 122 -- .../agent-gui/test/tools/tool-policy.test.mjs | 77 +- crates/agent-ui/src/contracts/builtinTools.ts | 4 +- .../src/contracts/mcpServerDefaults.ts | 34 + .../src/i18n/translations/enUSSettings.ts | 121 -- .../src/i18n/translations/zhCNSettings.ts | 113 -- crates/agent-ui/src/lib/cua/formatCuaError.ts | 94 - crates/agent-ui/src/lib/settings/index.ts | 20 - crates/agent-ui/src/lib/settings/types.ts | 22 - .../src/pages/mcp-hub/McpServersForm.tsx | 11 +- .../src/pages/settings/CuaInstaller.ts | 85 - .../src/pages/settings/CuaInstallerPanel.tsx | 677 ------- .../src/pages/settings/CuaSection.tsx | 431 ---- .../src/pages/settings/SettingsPage.tsx | 24 - 39 files changed, 174 insertions(+), 6784 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/app/ax.rs delete mode 100644 crates/agent-gui/src-tauri/src/commands/cua.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/cua/cua_client.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/cua/error.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/cua/installer.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/cua/mod.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/cua/store.rs delete mode 100644 crates/agent-gui/src/lib/cua/cuaService.ts delete mode 100644 crates/agent-gui/src/lib/tools/cuaTools.ts delete mode 100644 crates/agent-gui/test/cua-installer-service.test.mjs delete mode 100644 crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs delete mode 100644 crates/agent-gui/test/cua/format-cua-error.test.mjs create mode 100644 crates/agent-ui/src/contracts/mcpServerDefaults.ts delete mode 100644 crates/agent-ui/src/lib/cua/formatCuaError.ts delete mode 100644 crates/agent-ui/src/pages/settings/CuaInstaller.ts delete mode 100644 crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx delete mode 100644 crates/agent-ui/src/pages/settings/CuaSection.tsx diff --git a/crates/agent-gateway/web/src/pages/settings/types.ts b/crates/agent-gateway/web/src/pages/settings/types.ts index 4c0bc79b8..d04298452 100644 --- a/crates/agent-gateway/web/src/pages/settings/types.ts +++ b/crates/agent-gateway/web/src/pages/settings/types.ts @@ -15,8 +15,7 @@ export type SectionId = | "hooks" | "cron" | "devices" - | "remote" - | "cua"; + | "remote"; export type SettingsPageProps = { settings: AppSettings; @@ -30,8 +29,6 @@ export type SettingsPageProps = { sttSettingsService: SttSettingsService; /** 临时切换语音输入运行供应商,不触发配置保存。 */ onSttProviderChange?: (provider: SttProviderId) => void; - /** CUA 后端桥;WebUI 不提供(无 cuaService 时 SettingsPage 不渲染 CUA 段)。 */ - cuaService?: never; }; export type SettingsSectionProps = { diff --git a/crates/agent-gui/src-tauri/src/commands/app/ax.rs b/crates/agent-gui/src-tauri/src/commands/app/ax.rs new file mode 100644 index 000000000..22823f47d --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/app/ax.rs @@ -0,0 +1,30 @@ +//! 无障碍(AX)辅助命令。 +//! +//! 这两个命令服务于「外部自动化工具驱动 LiveAgent 自身窗口」的场景 +//! ——典型是用 `cua-driver` 跑端到端测试时,需要主窗口真的在前台、且 +//! NSWindow / WKWebView 的 AX 注解已重新广播,否则拿到的 AX 树是空的。 +//! +//! 它们与「LiveAgent 能操作用户电脑」这个功能无关:那条链路已改为把 +//! `cua-driver mcp` 当作一个普通 MCP server 接入 MCP Hub,不再有专属 +//! 的 Tauri 命令层。 +//! +//! 真正的实现在 `crate::cua_window_ready` / `crate::cua_refresh_a11y` +//! (`lib.rs`,因为要触碰 `force_activate_main_window` 与平台相关的 +//! NSWindow 细节);这里只是命令桥。 + +/// 把主窗口重新推到最前并等待 `is_focused()` 落住,让后续的 AX 查询 +/// 能找到 WebView。 +#[tauri::command(rename_all = "camelCase")] +pub async fn cua_window_ready( + window: tauri::WebviewWindow, +) -> Result { + Ok(crate::cua_window_ready(window).await) +} + +/// 在路由切换、overlay 打开 / 关闭、外部工具主动唤起等场景重新触发 +/// NSWindow / WKWebView 的 AX 注解,并回弹广播一次 +/// `UIElementCreatedNotification`。 +#[tauri::command(rename_all = "camelCase")] +pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yResponse { + crate::cua_refresh_a11y(&window) +} diff --git a/crates/agent-gui/src-tauri/src/commands/app/mod.rs b/crates/agent-gui/src-tauri/src/commands/app/mod.rs index 825165356..c4cb9322b 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod ax; pub mod system; pub mod tray; pub mod update; diff --git a/crates/agent-gui/src-tauri/src/commands/cua.rs b/crates/agent-gui/src-tauri/src/commands/cua.rs deleted file mode 100644 index c718257eb..000000000 --- a/crates/agent-gui/src-tauri/src/commands/cua.rs +++ /dev/null @@ -1,1757 +0,0 @@ -//! CUA Tauri Command 层。所有命令的统一规范: -//! 1. `cua_*` 命名前缀;状态无关。 -//! 2. 经 `CuaStore::enforce` 守卫:未启用 / 白名单拒了 / sandboxOffline -//! 时直接 `Err` 回去,不调用驱动。 -//! 3. 操作结果写审计日志(成功 + 失败都写),便于前端面板与远端审计。 -//! 4. `screenshot` 返回 base64(前端可经 invoke 解码成 PNG),与 MCP -//! 协议桥的标准一致;底层驱动返回原始字节。 -//! -//! 平台:之前 `MacOsDriver` 是 osascript + screencapture 自研实现, -//! 现已切到 `cua-driver mcp --direct`(trycua/cua),跨 macOS / -//! Windows / Linux + 不抢光标 + 支持非 AX 表面。详细动机见 -//! `services/cua/cua_client.rs`。 - -use base64::Engine; -use chrono::Utc; -use image::ImageReader; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::io::Cursor; -use std::sync::Arc; -use tauri::AppHandle; -use tauri::State; - -use crate::services::cua::cua_client::{unwrap_mcp, CuaClient}; -use crate::services::cua::{ - installer::{CuaDriverDetection, CuaInstallResult, CuaUpdateResult, InstallPreview}, - store::CuaRuntimeConfig, - CuaAuditEntry, CuaError, CuaStore, CuaStoreSnapshot, -}; - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaScreenshotResponse { - pub width: u32, - pub height: u32, - pub base64_png: String, -} - -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ClickButtonArg { - #[default] - Left, - Middle, - Right, -} - -/// cua-driver `click` 接受 `button: "left" | "right" | "middle"`; -/// 同时 `double_click` 没有 button 字段,由调用方决定。 -impl From for &'static str { - fn from(value: ClickButtonArg) -> Self { - match value { - ClickButtonArg::Left => "left", - ClickButtonArg::Middle => "middle", - ClickButtonArg::Right => "right", - } - } -} - -#[derive(Debug, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaOpResponse { - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// 在 Tauri Command 层把 `cua-driver` 调用的二进制 / 结构化响应 -/// 包成一致的审计 entry;失败时把 `CuaError` 直接落地便于 UI 翻译。 -fn record_op_audit( - store: &Arc, - op: &str, - detail: Option, - result: &Result, - started: chrono::DateTime, -) { - let entry = match result { - Ok(value) => CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: true, - error: None, - detail: detail.or_else(|| Some(summarize_value(value))), - }, - Err(err) => CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: false, - error: Some(err.clone()), - detail, - }, - }; - store.record(entry); -} - -/// 把 cua-driver 返回的 JSON 剪裁到审计可读尺寸。`content` 字段是 -/// MCP 工具返回的多模态数组(含 image base64),我们只保留文本 -/// 部分 + 一行 size 摘要,避免审计日志膨胀。 -fn summarize_value(value: &Value) -> Value { - if let Some(arr) = value.get("content").and_then(Value::as_array) { - let mut summary = serde_json::Map::new(); - let mut text_chunks: Vec = Vec::new(); - let mut image_count = 0u32; - let mut other_count = 0u32; - for item in arr { - match item.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(t) = item.get("text").and_then(Value::as_str) { - text_chunks.push(t.to_string()); - } - } - Some("image") => { - image_count += 1; - } - _ => { - other_count += 1; - } - } - } - if !text_chunks.is_empty() { - let joined = text_chunks.join("\n"); - summary.insert( - "text".into(), - Value::String(if joined.len() > 1024 { - format!("{}…(truncated)", &joined[..joined.floor_char_boundary(1024)]) - } else { - joined - }), - ); - } - if image_count > 0 { - summary.insert("imageCount".into(), Value::from(image_count)); - } - if other_count > 0 { - summary.insert("otherCount".into(), Value::from(other_count)); - } - return Value::Object(summary); - } - if value.is_null() { - return json!({ "ok": true }); - } - json!({ "size": value.to_string().len() }) -} - -/// 把 cua-driver 的多模态 content 数组拆出截图字节。cua-driver 在 -/// `get_desktop_state` / `zoom` 工具返回 `{ content: [{ type: "image", -/// data: "", mimeType: "image/jpeg" }] }`;这里把它解出来。 -fn extract_screenshot(value: &Value) -> Option<(Vec, u32, u32)> { - let arr = value.get("content")?.as_array()?; - for item in arr { - if item.get("type").and_then(Value::as_str) == Some("image") { - let data = item.get("data").and_then(Value::as_str)?; - let mime = item - .get("mimeType") - .and_then(Value::as_str) - .unwrap_or("image/png"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(data.as_bytes()) - .ok()?; - let (w, h) = if mime.contains("jpeg") || mime.contains("jpg") { - jpeg_dimensions(&bytes).unwrap_or((0, 0)) - } else { - png_dimensions(&bytes).unwrap_or((0, 0)) - }; - return Some((bytes, w, h)); - } - } - None -} - -/// CUA-061:把 cua-driver 失败响应的可读错误文本拆出来。当 -/// `isError=true` 时,`content` 数组里的文本条目承载错误消息 -///(例如 `x2 must be > x1 and y2 must be > y1` / `TCC permission -/// required` / 内部 panic 栈)。返回拼接后的字符串,给 caller 包 -/// 进 `CuaError::io` 透传给前端,避免真实错误被吞成「no image -/// content」。 -/// -/// 注意:本 helper 只看 `content[].type==="text"`,**不**检查 -/// `isError`。CUA-064:成功路径下 cua-driver 也会在 content 数组 -/// 里带 type:text 描述项(zoom 区域说明 / get_desktop_state 文案 -/// 等),所以 caller 必须用 `extract_io_error_on_failure` 而不是 -/// 直接调本 helper——后者统一负责 `isError=true` 守门。 -fn extract_error_text(value: &Value) -> Option { - let arr = value.get("content")?.as_array()?; - let mut chunks: Vec = Vec::new(); - for item in arr { - if item.get("type").and_then(Value::as_str) == Some("text") { - if let Some(t) = item.get("text").and_then(Value::as_str) { - chunks.push(t.to_string()); - } - } - } - if chunks.is_empty() { - None - } else { - Some(chunks.join("\n")) - } -} - -/// CUA-064:cua-driver 成功响应(zoom / get_desktop_state)也带 -/// type:text 描述项,所以 caller 不能盲调 `extract_error_text`—— -/// 否则成功截图会被强行翻成 `cua.errors.io`,`extract_screenshot` -/// 永远走不到(CUA-059 / CUA-061 后端路径全部失效)。本 helper 把 -/// `isError==Some(true)` 守门与文本抽取合并成一步:成功 / 缺标记 -/// 永远返回 `None`,仅在 `isError=true` 且 content 有文本时返回 -/// 拼接后的错误消息。 -fn extract_io_error_on_failure(value: &Value) -> Option { - if value.get("isError").and_then(Value::as_bool) != Some(true) { - return None; - } - extract_error_text(value) -} - -/// 窗口 bounds(CUA-059):从 list_windows 返回的 window 对象里 -/// 拆出 `{x, y, width, height}`。cua-driver 把 bounds 嵌进 `bounds` -/// 子对象,值是浮点(如 `{"x":260.0,"y":68.0,"width":1400.0, -/// "height":800.0}`)。这里同时接受 int / float,顶层平铺 -/// `{x, y, width, height}` 也兼容(其它 MCP 实现可能)。 -struct WindowBounds { - x: i32, - y: i32, - width: i32, - height: i32, -} - -/// 从 serde_json::Value 中抽出数字字段(int 或 float),截到 i32 范围。 -fn number_as_i32(v: &Value) -> Option { - if let Some(n) = v.as_i64() { - return Some(n as i32); - } - if let Some(f) = v.as_f64() { - if f.is_finite() && f >= i32::MIN as f64 && f <= i32::MAX as f64 { - return Some(f as i32); - } - } - None -} - -fn extract_window_bounds(w: &Value) -> Option { - if let Some(b) = w.get("bounds").and_then(Value::as_object) { - let x = b.get("x").and_then(number_as_i32).unwrap_or(0); - let y = b.get("y").and_then(number_as_i32).unwrap_or(0); - let width = b.get("width").and_then(number_as_i32).unwrap_or(0); - let height = b.get("height").and_then(number_as_i32).unwrap_or(0); - if width > 0 && height > 0 { - return Some(WindowBounds { x, y, width, height }); - } - return None; - } - let x = w.get("x").and_then(number_as_i32)?; - let y = w.get("y").and_then(number_as_i32)?; - let width = w.get("width").and_then(number_as_i32)?; - let height = w.get("height").and_then(number_as_i32)?; - if width <= 0 || height <= 0 { - return None; - } - Some(WindowBounds { x, y, width, height }) -} - -/// CUA-054:cua-driver MCP 即使 JSON-RPC 200,也会把业务失败装进 -/// `result.isError=true` + `structuredContent.effect ∈ {refused, -/// suspected_noop, unverifiable}`。这里把这种「传输成功 / 业务失败」 -/// 翻成 `CuaError`,让 caller 用统一的 ok=false + 结构化 error 透传。 -/// -/// 只在 `isError=true` 时生效;isError=false 的成功响应(含 effect -/// 未给出)一律视为成功,不影响老路径。 -fn check_effect_failure(result: &Result) -> Option { - let v = result.as_ref().ok()?; - if v.get("isError").and_then(Value::as_bool) != Some(true) { - return None; - } - let sc = unwrap_mcp(v); - let effect = sc.get("effect").and_then(Value::as_str).unwrap_or("unknown"); - let code = sc.get("code").and_then(Value::as_str).unwrap_or(""); - let detail_msg = match effect { - "refused" => format!( - "cua-driver refused the operation (code={code}); \ - target likely off-screen or AX-unresolved; \ - check window focus / coordinates and retry" - ), - "suspected_noop" => format!( - "cua-driver reports suspected_noop (code={code}); \ - the action likely had no visible effect; \ - verify target state before retrying" - ), - "unverifiable" => format!( - "cua-driver cannot verify the operation result (code={code}); \ - treat as inconclusive" - ), - other => format!( - "cua-driver reported effect={other} (code={code}); \ - treating as failure" - ), - }; - Some(CuaError::not_executed(&detail_msg)) -} - -/// CUA-054:把 call_tool 的 Result 收尾成 `CuaOpResponse`。把业务层 -/// effect 失败(`isError=true`)和传输层失败(`Err`)都翻译成 -/// `ok=false` + 结构化 `error`,并按失败写审计。 -/// -/// 取代原先「只看 `result.is_ok()`」的简化版本——之前 Agent 收不到 -/// 业务失败,盲目继续推进决策。 -fn finalize_op( - store: &Arc, - op: &str, - detail: Option, - result: Result, - started: chrono::DateTime, -) -> CuaOpResponse { - let effect_err = check_effect_failure(&result); - let recorded = effect_err.map(Err).unwrap_or(result); - record_op_audit(store, op, detail, &recorded, started); - CuaOpResponse { - ok: recorded.is_ok(), - error: recorded.err(), - } -} - -// ───────── Commands ───────── - -/// 平台标签 + 是否可用 + 当前配置 + 最近操作。前端挂载时调用一次。 -/// -/// 每次取快照都从持久化 system settings 重读 `sandbox_offline`——避免 -/// 命令安全模式被外部切到 sandboxOffline 后 CUA UI 仍按旧值渲染。 -#[tauri::command(rename_all = "camelCase")] -pub fn cua_status(store: State<'_, Arc>) -> CuaStoreSnapshot { - let sandbox_offline = current_sandbox_offline(); - store.refresh_sandbox_offline(sandbox_offline); - store.snapshot() -} - -/// 整体覆盖配置。前端保存面板时调用。 -/// -/// 服务端权威地维护 `sandbox_offline`:从前端传过来的字段总是被 -/// 当前命令安全模式覆盖——保证 `group:cua` 在 sandboxOffline 下必 -/// 拒(CUA-reviewer 安全门控 #2),不会被前端脏数据绕过。 -#[tauri::command(rename_all = "camelCase")] -pub fn cua_set_config( - store: State<'_, Arc>, - config: CuaRuntimeConfig, - _app: AppHandle, -) -> CuaStoreSnapshot { - let sandbox_offline = current_sandbox_offline(); - let mut cfg = config; - cfg.sandbox_offline = sandbox_offline; - store.replace_config(cfg); - store.snapshot() -} - -/// 当前命令安全模式是否是 sandboxOffline。从持久化的 system settings -/// 权威读取(与 sandbox.rs 同源),不信任调用方声明——防止 CUA 工具 -/// 通过直接调用 cua_set_config 绕过离线沙箱(CUA-reviewer 安全门控 #2)。 -fn current_sandbox_offline() -> bool { - crate::commands::config_commands::settings::load_runtime_command_safety_mode() - .ok() - .map(|mode| mode.eq_ignore_ascii_case("sandboxOffline")) - .unwrap_or(false) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_clear_audit(store: State<'_, Arc>) -> CuaStoreSnapshot { - store.clear_audit(); - store.snapshot() -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_list_windows( - store: State<'_, Arc>, - client: State<'_, CuaClient>, -) -> Result { - let op = "list_windows"; - store.enforce(op, None)?; - // on_screen_only=true 过滤掉离屏窗口,避免给 Agent 噪音。 - let started = Utc::now(); - let result = client.call_tool("list_windows", json!({ "on_screen_only": true })); - // cua-driver MCP 2025-06-18 把 windows 数组放进 structuredContent; - // 前端契约是「纯 windows 数组」,所以透传前先解包(CUA-055)。 - let mapped = result.map(|v| { - unwrap_mcp(&v) - .get("windows") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())) - }); - record_op_audit(store.inner(), op, None, &mapped, started); - mapped -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_focus_window( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - owner: String, -) -> Result { - let op = "focus_window"; - let owner_ref = owner.trim().to_string(); - store.enforce(op, Some(&owner_ref))?; - let detail = Some(json!({ "owner": &owner_ref })); - // cua-driver 没有「按 owner 聚焦」的语义;通过 list_apps 找 pid - // 再用 bring_to_front。这是两步;只要最后一步失败就把 - // ok=false 交给 UI,但 enforce 已经把白名单守住。 - let started = Utc::now(); - let find = client.call_tool("list_apps", json!({})); - let pid = match find { - Ok(v) => { - // cua-driver MCP 2025-06-18 把 apps 数组放进 structuredContent; - // 顶层 v.get("apps") 永远拿不到(CUA-055)。 - unwrap_mcp(&v) - .get("apps") - .and_then(Value::as_array) - .and_then(|apps| { - apps.iter() - .find(|a| { - a.get("name") - .and_then(Value::as_str) - .map(|s| s.eq_ignore_ascii_case(&owner_ref)) - .unwrap_or(false) - || a.get("bundle_id") - .and_then(Value::as_str) - .map(|s| s.eq_ignore_ascii_case(&owner_ref)) - .unwrap_or(false) - }) - .and_then(|a| a.get("pid").and_then(Value::as_i64)) - }) - } - Err(e) => { - record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); - return Err(e); - } - }; - let pid = match pid { - Some(p) => p, - None => { - let err = CuaError::not_executed(&format!( - "could not find a running app named '{owner_ref}'" - )); - record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(err), - }); - } - }; - let result = client.call_tool("bring_to_front", json!({ "pid": pid })); - match &result { - Ok(_) => { - record_op_audit(store.inner(), op, detail, &Ok(Value::Null), started); - } - Err(e) => { - record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); - } - } - Ok(CuaOpResponse { - ok: result.is_ok(), - error: result.err(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_screenshot( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - window_owner: Option, -) -> Result { - let op = "screenshot"; - // 截屏不强制白名单(截图系统自身 / 必要场景),但仍要 enabled。 - store.enforce(op, None)?; - let started = Utc::now(); - // cua-driver 的截屏工具有两个:`get_desktop_state`(全屏,写 - // screenshot_out_file 到磁盘)和 `zoom`(指定窗口区域)。 - // 前端目前给的是 owner 名(MVP 仍截全屏),对应 `get_desktop_state`。 - // 沙箱遮罩:window_owner 给定时再尝试 `zoom`(cua-driver 内置 - // window owner 寻址);否则回退到全屏。LiveAgent 自身 / cua-driver - // helper 的遮罩交给 cua-driver 自身的窗口排除逻辑。 - // - // CUA-051:当 window_owner 为空需要走全屏路径前,先确认 cua-driver - // 是带 CFBundleIdentifier 启动的(`health_report.bundle_identity` - // pass)。裸 bin 启动时 TCC Screen Recording attribution 落到 - // 临时进程而非 com.trycua.driver,ScreenCaptureKit 会返回合法 - // 但内容全黑的 PNG——Agent 拿到全黑帧做不了任何决策。bundle 失 - // 效时直接拒,避免把黑帧当真截图透传。 - let owner_opt = window_owner - .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - let needs_full_desktop = owner_opt.is_none(); - if needs_full_desktop && !client.check_bundle_attribution() { - let err = CuaError::screen_capture_unavailable( - "cua-driver is not running inside CuaDriver.app bundle \ - (health_report.bundle_identity=fail); \ - get_desktop_state would return a fully-black frame", - ); - store.inner().record(CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: false, - error: Some(err.clone()), - detail: Some(json!({ "windowOwner": window_owner })), - }); - return Err(err); - } - let result = if let Some(owner) = owner_opt { - // 走 list_apps → pid+window_id → zoom 路径。窗口 bounds 在 - // list_windows 时一并拆出(CUA-059),zoom 用真实坐标而 - // 不是零面积;找不到窗口时直接报 ownerNotFound,不再回退 - // 到 get_desktop_state 触发 CUA-051 后置防线把错误掩盖成 - // TCC 失效(CUA-060)。 - match pick_window_id_for_owner(&client, owner) { - Ok(Some((pid, window_id, bounds))) => { - let (x1, y1, x2, y2) = zoom_rect(bounds); - client.call_tool( - "zoom", - json!({ - "pid": pid, - "window_id": window_id, - "x1": x1, "y1": y1, "x2": x2, "y2": y2, - }), - ) - } - Ok(None) => { - // 找不到对应窗口 → 直接报 ownerNotFound;不要回退到 - // 全屏(get_desktop_state 在 bundle_identity=fail 时 - // 会返回全黑帧,被 CUA-051 后置防线翻译成 TCC 失效, - // 把真实原因彻底掩盖——CUA-060)。 - let err = CuaError::owner_not_found(owner); - store.inner().record(CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: false, - error: Some(err.clone()), - detail: Some(json!({ "windowOwner": window_owner })), - }); - return Err(err); - } - Err(e) => Err(e), - } - } else { - client.call_tool("get_desktop_state", json!({})) - }; - match result { - Ok(value) => { - // CUA-061 + CUA-064:cua-driver 在 zoom 参数错误、permission 拒绝、 - // 内部 panic 等场景会返回 isError=true + content 是文本 - // 错误消息;直接走 extract_screenshot 会拿到 None 然后 - // 被翻成 'no image content',真实错误('x2 must be > x1' / - // 'TCC permission required')被吞掉。 - // - // CUA-064:成功路径(zoom / get_desktop_state)下 cua-driver 也 - // 会在 content 数组里同时塞 type:text 描述项(例如 'Zoom - // region (260,68)–(1660,868) → 500×286 px JPEG.')。 - // `extract_io_error_on_failure` 内置 isError 守门,所以成功 - // 响应不会被误判;只有 isError=true 时才把文本 detail 当错误 - // 透传给 CuaError::io,其它一律走正常的 image 抽取路径。 - if let Some(err_detail) = extract_io_error_on_failure(&value) { - let err = CuaError::io(&err_detail); - store.inner().record(CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: false, - error: Some(err.clone()), - detail: Some(json!({ "windowOwner": window_owner })), - }); - return Err(err); - } - let (bytes, width, height) = extract_screenshot(&value) - .ok_or_else(|| CuaError::io("cua-driver screenshot: no image content"))?; - // CUA-051 后置防线:即便 health_report 那一关过了,也要兜 - // 一道「帧内像素统计」。少部分边界情况(Screen Recording - // 被吊销但 bundle 仍 ok、屏幕本身黑屏、cua-driver 半瘫) - // 仍可能让 get_desktop_state 返回低信息量的图;非黑像素 - // 数量低于阈值就直接拒,转成 screen_capture_unavailable。 - if let Some(non_black) = count_non_black_pixels(&bytes) { - if non_black < MIN_NON_BLACK_PIXELS { - let err = CuaError::screen_capture_unavailable(&format!( - "cua-driver returned a near-empty screen capture \ - ({non_black} non-black pixels); \ - TCC Screen Recording attribution is broken or screen is off" - )); - store.inner().record(CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: false, - error: Some(err.clone()), - detail: Some(json!({ - "width": width, - "height": height, - "windowOwner": window_owner, - "sizeBytes": bytes.len(), - "nonBlackPixels": non_black, - })), - }); - return Err(err); - } - } - let base64_png = base64::engine::general_purpose::STANDARD.encode(&bytes); - store.inner().record(CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: true, - error: None, - detail: Some(json!({ - "width": width, - "height": height, - "windowOwner": window_owner, - "sizeBytes": bytes.len(), - })), - }); - Ok(CuaScreenshotResponse { - width, - height, - base64_png, - }) - } - Err(err) => { - store.inner().record(CuaAuditEntry { - timestamp: started, - operation: op.to_string(), - ok: false, - error: Some(err.clone()), - detail: Some(json!({ "windowOwner": window_owner })), - }); - Err(err) - } - } -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_click( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - x: i32, - y: i32, - button: Option, -) -> Result { - let op = "click"; - store.enforce(op, None)?; - let button_str: &'static str = button.unwrap_or_default().into(); - let detail = Some(json!({ "x": x, "y": y, "button": button_str })); - // cua-driver click 需要 pid + window_id + (x,y);MVP 阶段我们用 - // 全屏语义:取前台 app pid + first window。这是 best-effort, - // 真实需求是 (x, y) ∈ window,先按坐标所在窗口定位。 - let started = Utc::now(); - let pid_window = match pick_topmost_window(&client) { - Ok(pw) => pw, - Err(e) => { - record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(e), - }); - } - }; - let mut args = json!({ - "x": x, "y": y, - "button": button_str, - }); - if let Some((pid, window_id)) = pid_window { - args["pid"] = json!(pid); - args["window_id"] = json!(window_id); - } - let result = client.call_tool("click", args); - Ok(finalize_op( - store.inner(), - op, - detail, - result, - started, - )) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_double_click( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - x: i32, - y: i32, -) -> Result { - let op = "double_click"; - store.enforce(op, None)?; - let detail = Some(json!({ "x": x, "y": y })); - let started = Utc::now(); - let pid_window = match pick_topmost_window(&client) { - Ok(pw) => pw, - Err(e) => { - record_op_audit(store.inner(), op, detail, &Err(e.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(e), - }); - } - }; - let mut args = json!({ "x": x, "y": y }); - if let Some((pid, window_id)) = pid_window { - args["pid"] = json!(pid); - args["window_id"] = json!(window_id); - } - let result = client.call_tool("double_click", args); - Ok(finalize_op( - store.inner(), - op, - detail, - result, - started, - )) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_type( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - text: String, - target_owner: Option, -) -> Result { - let op = "type"; - let owner_ref = target_owner - .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - store.enforce(op, owner_ref)?; - let len = text.chars().count(); - let detail = Some(json!({ - "length": len, - "targetOwner": target_owner, - })); - let started = Utc::now(); - let mut args = json!({ "text": text }); - // CUA-062:cua-driver type_text / press_key / hotkey 强制要求 pid。 - // target_owner 为 None 时无条件 pick_topmost_window 取前台 app; - // 与 cua_click / scroll / drag 对齐。owner 指定但找不到时报 - // owner_not_found(CUA-060 同根因)。 - let picked: Option<(i64, i64)> = if let Some(owner) = owner_ref { - match pick_window_id_for_owner(&client, owner) { - Ok(Some((pid, wid, _bounds))) => Some((pid, wid)), - Ok(None) => { - let err = CuaError::owner_not_found(owner); - record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(err), - }); - } - Err(e) => { - record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(e), - }); - } - } - } else { - match pick_topmost_window(&client) { - Ok(p) => p, - Err(e) => { - record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(e), - }); - } - } - }; - match picked { - Some((pid, window_id)) => { - args["pid"] = json!(pid); - args["window_id"] = json!(window_id); - } - None => { - // pick_topmost_window 返回 None:前台没有可点击窗口(极端 - // 场景,例如桌面本身无窗口),无法满足 pid 强约束。 - let err = CuaError::not_executed( - "pid is required or pick_topmost_window failed \ - (no foreground window with on_screen=true)", - ); - record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(err), - }); - } - } - let result = client.call_tool("type_text", args); - Ok(finalize_op( - store.inner(), - op, - detail, - result, - started, - )) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_key( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - key: String, - modifiers: Option>, - target_owner: Option, -) -> Result { - let op = "key"; - let owner_ref = target_owner - .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - store.enforce(op, owner_ref)?; - let mods = modifiers.unwrap_or_default(); - let detail = Some(json!({ - "key": key, - "modifiers": &mods, - "targetOwner": target_owner, - })); - // key:单字符或名称 → press_key;带 modifiers 的组合 → hotkey。 - let args = if mods.is_empty() { - json!({ "key": key }) - } else { - let mut keys = mods - .iter() - .map(|m| m.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect::>(); - keys.push(key.clone()); - json!({ "keys": keys }) - }; - let tool = if mods.is_empty() { - "press_key" - } else { - "hotkey" - }; - let mut args = args; - let started = Utc::now(); - // CUA-062:与 cua_type 同根因——cua-driver press_key / hotkey 强制 - // 要求 pid;target_owner 为 None 时无条件 pick_topmost_window 取 - // 前台 app,不再让调用方拿到 'Missing required integer field: pid' - // 这种不可操作错误。 - let picked: Option<(i64, i64)> = if let Some(owner) = owner_ref { - match pick_window_id_for_owner(&client, owner) { - Ok(Some((pid, wid, _bounds))) => Some((pid, wid)), - Ok(None) => { - let err = CuaError::owner_not_found(owner); - record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(err), - }); - } - Err(e) => { - record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(e), - }); - } - } - } else { - match pick_topmost_window(&client) { - Ok(p) => p, - Err(e) => { - record_op_audit(store.inner(), op, detail.clone(), &Err(e.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(e), - }); - } - } - }; - match picked { - Some((pid, window_id)) => { - args["pid"] = json!(pid); - args["window_id"] = json!(window_id); - } - None => { - let err = CuaError::not_executed( - "pid is required or pick_topmost_window failed \ - (no foreground window with on_screen=true)", - ); - record_op_audit(store.inner(), op, detail, &Err(err.clone()), started); - return Ok(CuaOpResponse { - ok: false, - error: Some(err), - }); - } - } - let result = client.call_tool(tool, args); - Ok(finalize_op( - store.inner(), - op, - detail, - result, - started, - )) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_scroll( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - x: i32, - y: i32, - dy: i32, -) -> Result { - let op = "scroll"; - store.enforce(op, None)?; - let detail = Some(json!({ "x": x, "y": y, "dy": dy })); - // cua-driver scroll.direction ∈ {"up","down","left","right"},by - // 是单位数(默认 3)。`dy > 0` 视为向上滚动,与原 osascript 语义一致。 - let direction = if dy >= 0 { "up" } else { "down" }; - let amount = dy.unsigned_abs().max(1); - let mut args = json!({ - "direction": direction, - "amount": amount, - "x": x, - "y": y, - }); - if let Ok(Some((pid, window_id))) = pick_topmost_window(&client) { - args["pid"] = json!(pid); - args["window_id"] = json!(window_id); - } - let started = Utc::now(); - let result = client.call_tool("scroll", args); - Ok(finalize_op( - store.inner(), - op, - detail, - result, - started, - )) -} - -#[tauri::command(rename_all = "camelCase")] -pub fn cua_drag( - store: State<'_, Arc>, - client: State<'_, CuaClient>, - x1: i32, - y1: i32, - x2: i32, - y2: i32, -) -> Result { - let op = "drag"; - store.enforce(op, None)?; - let detail = Some(json!({ - "x1": x1, "y1": y1, "x2": x2, "y2": y2 - })); - let mut args = json!({ - "from_x": x1, "from_y": y1, "to_x": x2, "to_y": y2, - }); - if let Ok(Some((pid, window_id))) = pick_topmost_window(&client) { - args["pid"] = json!(pid); - args["window_id"] = json!(window_id); - } - let started = Utc::now(); - let result = client.call_tool("drag", args); - Ok(finalize_op( - store.inner(), - op, - detail, - result, - started, - )) -} - -// ───────── Helpers ───────── - -/// CUA-059:cua-driver zoom 拒绝零面积坐标。bounds 拿得到就用真实 -/// 矩形;拿不到就用「屏幕尺寸」兜底(任意大于真实屏幕的数值都行, -/// cua-driver zoom 会按窗口 bounds 裁剪)。这里用 10000 作为保守 -/// 上界——覆盖所有现代 macOS / Windows / Linux 桌面分辨率。 -const FALLBACK_SCREEN_W: i32 = 10000; -const FALLBACK_SCREEN_H: i32 = 10000; - -fn zoom_rect(bounds: Option) -> (i32, i32, i32, i32) { - match bounds { - Some(b) if b.width > 0 && b.height > 0 => (b.x, b.y, b.x + b.width, b.y + b.height), - _ => (0, 0, FALLBACK_SCREEN_W, FALLBACK_SCREEN_H), - } -} - -/// 解析 PNG IHDR 拿宽高。 -fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { - if bytes.len() < 24 { - return None; - } - if &bytes[..8] != b"\x89PNG\r\n\x1a\n".as_slice() { - return None; - } - let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); - let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); - Some((width, height)) -} - -/// 极简 JPEG 尺寸解析(SOF0/SOF2 段)。失败回退到 (0,0)。 -fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { - if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 { - return None; - } - let mut i = 2; - while i + 9 < bytes.len() { - if bytes[i] != 0xFF { - return None; - } - let marker = bytes[i + 1]; - // SOF0..SOF15 (除 DHT 0xC4 / DAC 0xCC / DNL 0xCC 之外)。 - if (0xC0..=0xCF).contains(&marker) - && marker != 0xC4 - && marker != 0xC8 - && marker != 0xCC - { - let height = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32; - let width = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32; - return Some((width, height)); - } - let seg_len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; - i += 2 + seg_len; - } - None -} - -/// 用 `list_windows` 拿最前面的窗口(layer 0 + on_screen_only)。 -/// 返回 (pid, window_id) 或 None。 -/// -/// CUA-057:cua-driver MCP 2025-06-18 把 windows 数组放在 -/// `structuredContent` 下;顶层 `content` 是 `[{type:"text", text:"…"}]` -/// 文本条目数组,把「Found 9 window(s).」当 window 对象解析永远 -/// 不命中 `pid` / `window_id`。所以这里只信任 `unwrap_mcp(...).windows`。 -fn pick_topmost_window(client: &CuaClient) -> Result, CuaError> { - let value = client.call_tool("list_windows", json!({ "on_screen_only": true }))?; - let value = unwrap_mcp(&value); - let wins = value.get("windows").and_then(Value::as_array); - if let Some(arr) = wins { - for w in arr { - let pid = w.get("pid").and_then(Value::as_i64); - let window_id = w - .get("window_id") - .or_else(|| w.get("windowId")) - .or_else(|| w.get("id")) - .and_then(Value::as_i64); - if let (Some(pid), Some(wid)) = (pid, window_id) { - if w.get("on_screen") - .and_then(Value::as_bool) - .unwrap_or(true) - { - return Ok(Some((pid, wid))); - } - } - } - } - Ok(None) -} - -/// 通过 owner 名匹配 `list_apps` 的 pid,再查其首个可见 window。 -/// 找不到时返回 Ok(None) 而不是 Err(best-effort)。 -/// -/// CUA-056:list_apps / list_windows 的 apps / windows 数组都在 -/// structuredContent 下;必须先 `unwrap_mcp` 再取,否则 zoom 路径 -/// 永远拿不到 pid + window_id → 降级到 get_desktop_state。 -/// -/// CUA-059:顺带拆出窗口 bounds({x,y,width,height}),避免 zoom -/// 调用传零面积坐标被驱动拒。 -fn pick_window_id_for_owner( - client: &CuaClient, - owner: &str, -) -> Result)>, CuaError> { - let apps = client.call_tool("list_apps", json!({}))?; - let apps = unwrap_mcp(&apps); - let pid = apps - .get("apps") - .and_then(Value::as_array) - .and_then(|arr| { - arr.iter() - .find(|a| { - a.get("name") - .and_then(Value::as_str) - .map(|s| s.eq_ignore_ascii_case(owner)) - .unwrap_or(false) - || a.get("bundle_id") - .and_then(Value::as_str) - .map(|s| s.eq_ignore_ascii_case(owner)) - .unwrap_or(false) - }) - .and_then(|a| a.get("pid").and_then(Value::as_i64)) - }); - let Some(pid) = pid else { - return Ok(None); - }; - let windows = client.call_tool("list_windows", json!({ "pid": pid, "on_screen_only": true }))?; - let windows = unwrap_mcp(&windows); - let mut picked: Option<(i64, Option)> = None; - if let Some(arr) = windows.get("windows").and_then(Value::as_array) { - for w in arr { - let wid = w - .get("window_id") - .or_else(|| w.get("windowId")) - .or_else(|| w.get("id")) - .and_then(Value::as_i64); - if let Some(wid) = wid { - picked = Some((wid, extract_window_bounds(w))); - break; - } - } - } - Ok(picked.map(|(wid, bounds)| (pid, wid, bounds))) -} - -/// cua-driver 入口命令:把主窗口重新推到最前并等待 `is_focused()` 落 -/// 住。命令不依赖 CUA enable 开关——它的目的正是让 enable=true 之 -/// 后的 click / AX 路径能找到 WebView。 -#[tauri::command(rename_all = "camelCase")] -pub async fn cua_window_ready( - window: tauri::WebviewWindow, -) -> Result { - Ok(crate::cua_window_ready(window).await) -} - -/// CUA-020/021/022: 在路由切换、overlay 打开/关闭、cua-driver 主动唤 -/// 起等场景重新触发 NSWindow/WKWebView 的 AX 注解 + 一回弹广播 -/// UIElementCreatedNotification。 -#[tauri::command(rename_all = "camelCase")] -pub fn cua_refresh_a11y(window: tauri::WebviewWindow) -> crate::CuaRefreshA11yResponse { - crate::cua_refresh_a11y(&window) -} - -// ───────── CUA Driver 安装器(CUA-100) ───────── -// -// installer.rs 检测 cua-driver 二进制;这里只暴露命令桥。 -// 设计:不经过 CuaStore 的 enable 守卫——安装器本身就是为了让后续 CUA -// 操作可用;enable 留到装完再由用户在 UI 中开启。 - -/// 检测 cua-driver 是否已安装、版本、daemon 状态。无 IO 副作用。 -#[tauri::command(rename_all = "camelCase")] -pub fn cua_driver_detect() -> CuaDriverDetection { - crate::services::cua::detect_driver() -} - -/// 启动 cua-driver daemon(macOS: `open CuaDriver --args serve`)。 -/// 失败时返回结构化 `CuaError`;前端不阻塞在失败上,可重试。 -#[tauri::command(rename_all = "camelCase")] -pub fn cua_driver_start_daemon(app: AppHandle) -> Result { - crate::services::cua::start_driver_daemon(&app) - .map(|started| CuaOpResponse { - ok: started, - error: None, - }) - .map_err(|detail| match std::env::consts::OS { - "macos" | "linux" | "windows" => CuaError::not_executed(&detail), - other => CuaError::installer_unsupported_platform(other), - }) -} - -/// 安装 cua-driver。30 分钟超时;进度经 `cua_install_progress` 事件 -/// 推送。装完自动尝试 start_daemon。 -#[tauri::command(rename_all = "camelCase")] -pub async fn cua_driver_install(app: AppHandle) -> Result { - let os = std::env::consts::OS; - match os { - "macos" | "linux" | "windows" => {} - other => return Err(CuaError::installer_unsupported_platform(other)), - } - let preflight = tauri::async_runtime::spawn_blocking({ - let app = app.clone(); - move || crate::services::cua::install_driver(&app) - }); - match preflight.await { - Ok(result) => { - if result.success { - Ok(result) - } else { - let detail = result.error.unwrap_or_else(|| "unknown".into()); - let lower = detail.to_lowercase(); - let err = if lower.contains("timeout") { - CuaError::installer_timeout(30) - } else if lower.contains("network") - || lower.contains("dns") - || lower.contains("could not resolve") - || lower.contains("connection refused") - { - CuaError::installer_network_unavailable(&detail) - } else if lower.contains("permission") || lower.contains("denied") { - CuaError::installer_permission_denied(&detail) - } else { - CuaError::installer_curl_failed(&detail) - }; - Err(err) - } - } - Err(join_err) => Err(CuaError::io(&format!( - "installer task join error: {join_err}" - ))), - } -} - -/// 检查更新 +(可选)应用。`apply=false` 时只跑 `check-update`。 -#[tauri::command(rename_all = "camelCase")] -pub async fn cua_driver_update(apply: bool) -> Result { - let join = tauri::async_runtime::spawn_blocking(move || { - crate::services::cua::update_driver(apply) - }) - .await - .map_err(|e| CuaError::io(&format!("update task join error: {e}")))?; - if let Some(err) = join.error.as_deref() { - let lower = err.to_lowercase(); - let mapped = if lower.contains("network") - || lower.contains("dns") - || lower.contains("connection") - { - CuaError::installer_network_unavailable(err) - } else { - CuaError::installer_curl_failed(err) - }; - Err(mapped) - } else { - Ok(join) - } -} - -/// 把 install 命令(program / args)暴露给前端做展示(无 spawn)。 -#[tauri::command(rename_all = "camelCase")] -pub fn cua_driver_install_preview() -> Result { - crate::services::cua::build_install_preview().map_err(|detail| match std::env::consts::OS { - "macos" | "linux" | "windows" => CuaError::not_executed(&detail), - other => CuaError::installer_unsupported_platform(other), - }) -} - -// ───────── CUA-051 截屏黑屏兜底 ───────── - -/// CUA-051 阈值:1920×1080 = 2,073,600 像素;阈值 1000 ≈ 0.05%。低于 -/// 这值几乎一定是 ScreenCaptureKit attribution 失效(裸 bin 启动)或 -/// 屏幕本身关闭,返回全黑 / 接近全黑帧。低于阈值的「截屏」对 Agent -/// 决策毫无意义,所以由后端直接拒。 -/// -/// 调高会让一些「真黑屏」(夜间模式 + 空桌面)误报;调低会让全黑帧 -/// 漏过去。1000 是经验值——cua-driver 真截图在最小桌面也会远高于此。 -const MIN_NON_BLACK_PIXELS: u64 = 1000; - -/// 把 cua-driver 返回的 PNG / JPEG 字节解码成 RGBA8 数非黑像素数。 -/// `None` 表示解码失败(不是 PNG / JPEG / 文件坏)——caller 应该 -/// 把这种当成 decode error 而不是 black-screen。 -/// -/// 与 PIL.Image(...).getdata() 等价的最小实现:只统计 -/// (R, G, B) != (0, 0, 0) 的像素。alpha < 255 也算黑(透明像素不携带 -/// 信息,对 Agent 来说一样没用)。 -fn count_non_black_pixels(bytes: &[u8]) -> Option { - let mut reader = ImageReader::new(Cursor::new(bytes)); - reader = reader.with_guessed_format().ok()?; - let decoded = reader.decode().ok()?; - let rgba = decoded.to_rgba8(); - let mut count = 0u64; - for px in rgba.pixels() { - let [r, g, b, a] = px.0; - if a > 0 && (r != 0 || g != 0 || b != 0) { - count += 1; - } - } - Some(count) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - /// 极简 1×1 PNG(红)字节流;用作 fixture。 - /// hex 头:`89 50 4E 47 0D 0A 1A 0A` (PNG signature) - /// IHDR:8 字节长度 + `IHDR` + width=1 + height=1 + 8-bit RGB + 0 + 0 + 0 - fn fake_png_1x1() -> Vec { - // 长度可变的 IHDR 字段拼接(实际长度需 13)。我们手写一个可被 PNG signature 8 字节 - // + IHDR 头识别的 24 字节小图。 - vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR length = 13 - b'I', b'H', b'D', b'R', - 0x00, 0x00, 0x00, 0x01, // width = 1 - 0x00, 0x00, 0x00, 0x01, // height = 1 - 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc. - ] - } - - #[test] - fn extract_screenshot_handles_image_content() { - let value = json!({ - "content": [ - { "type": "text", "text": "desktop screenshot 1920x1080" }, - { "type": "image", "data": "AAAA", "mimeType": "image/png" } - ] - }); - // 不是真正的 PNG 字节流;这里只验证抽取路径:base64 解码 + png_dimensions 返回 (0,0)。 - let (bytes, _w, _h) = extract_screenshot(&value).expect("must extract"); - assert_eq!(bytes, vec![0, 0, 0]); - } - - #[test] - fn extract_screenshot_returns_none_when_no_image() { - let value = json!({ - "content": [{ "type": "text", "text": "no image here" }] - }); - assert!(extract_screenshot(&value).is_none()); - } - - #[test] - fn summarize_value_counts_image_and_text() { - let value = json!({ - "content": [ - { "type": "text", "text": "hello" }, - { "type": "image", "data": "x", "mimeType": "image/png" }, - { "type": "image", "data": "y", "mimeType": "image/jpeg" }, - { "type": "other" } - ] - }); - let summary = summarize_value(&value); - assert_eq!(summary.get("text").and_then(Value::as_str), Some("hello")); - assert_eq!(summary.get("imageCount").and_then(Value::as_u64), Some(2)); - assert_eq!(summary.get("otherCount").and_then(Value::as_u64), Some(1)); - } - - #[test] - fn summarize_value_handles_null() { - let v = Value::Null; - let summary = summarize_value(&v); - assert_eq!(summary.get("ok").and_then(Value::as_bool), Some(true)); - } - - #[test] - fn png_dimensions_reads_ihdr() { - let bytes = fake_png_1x1(); - // 我们手工的 IHDR 写了 width=1, height=1。 - assert_eq!(png_dimensions(&bytes), Some((1, 1))); - } - - #[test] - fn png_dimensions_rejects_non_png() { - assert_eq!(png_dimensions(b"not a png"), None); - } - - #[test] - fn screen_capture_unavailable_emits_structured_error() { - let err = CuaError::screen_capture_unavailable("bundle_identity=fail"); - assert_eq!(err.kind, "cua.errors.screenCaptureUnavailable"); - assert_eq!( - err.params.get("detail").and_then(Value::as_str), - Some("bundle_identity=fail") - ); - assert!(err.message.contains("CuaDriver.app")); - assert!(err.message.contains("Screen Recording")); - } - - /// 4×4 全 0 RGB PNG → 0 个非黑像素。 - #[test] - fn count_non_black_pixels_all_black() { - let mut out: Vec = Vec::new(); - image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])) - .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) - .unwrap(); - let n = count_non_black_pixels(&out); - assert_eq!(n, Some(0)); - } - - /// 4×4 全 255 RGB PNG → 16 个非黑像素。 - #[test] - fn count_non_black_pixels_all_white() { - let mut out: Vec = Vec::new(); - image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 255, 255, 255])) - .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) - .unwrap(); - let n = count_non_black_pixels(&out); - assert_eq!(n, Some(16)); - } - - /// alpha=0 的像素视为黑(透传不携带信息)。 - #[test] - fn count_non_black_pixels_treats_transparent_as_black() { - let mut out: Vec = Vec::new(); - image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 255, 255, 0])) - .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) - .unwrap(); - let n = count_non_black_pixels(&out); - assert_eq!(n, Some(0)); - } - - #[test] - fn count_non_black_pixels_returns_none_on_garbage() { - // 不是图片字节流 → 解码失败 → None。 - assert_eq!(count_non_black_pixels(b"not an image at all"), None); - } - - /// 校验 MIN_NON_BLACK_PIXELS 阈值:CUA-051 要求非黑像素低于阈值的 - /// 截屏被拒。改这个常量会直接影响 cua_screenshot 的语义,所以 - /// 留个 sanity 测试。 - #[test] - fn min_threshold_is_sane() { - assert!(MIN_NON_BLACK_PIXELS >= 100, "threshold too small"); - assert!(MIN_NON_BLACK_PIXELS <= 100_000, "threshold too large"); - } - - // ───────── CUA-053/055/056/057 unwrap_mcp 路径测试 ───────── - - /// CUA-057:pick_topmost_window 不再退回错误地把 `content` 文本数组 - /// 当 windows 数组解析。只有 structuredContent.windows 才是权威来源。 - #[test] - fn pick_topmost_window_ignores_content_text_array() { - // 模拟 cua-driver MCP 包过的响应:content 是文本、windows 在 - // structuredContent 下。 - let value = json!({ - "content": [{"type":"text", "text":"Found 9 window(s)."}], - "structuredContent": { - "windows": [ - {"pid": 42, "window_id": 7, "on_screen": true}, - {"pid": 42, "window_id": 8, "on_screen": true} - ] - }, - "isError": false - }); - // 直接调用 helper 不方便(要 client),但我们可以验证 - // `unwrap_mcp` + `value.get("windows")` 这条路径的语义: - let inner = unwrap_mcp(&value); - let wins = inner.get("windows").and_then(Value::as_array).expect("windows array"); - assert_eq!(wins.len(), 2); - assert_eq!(wins[0].get("pid").and_then(Value::as_i64), Some(42)); - // 反例:旧的 content fallback 不会拿到 pid。 - assert!(value - .get("content") - .and_then(Value::as_array) - .and_then(|arr| arr[0].get("pid")) - .is_none()); - } - - /// CUA-056:pick_window_id_for_owner 的 list_apps 解析只信任 - /// structuredContent.apps(顶层 v.get("apps") 拿不到)。 - #[test] - fn pick_window_id_for_owner_uses_structured_apps() { - let apps_resp = json!({ - "content": [{"type":"text", "text":"2 apps"}], - "structuredContent": { - "apps": [ - {"name": "Finder", "pid": 100}, - {"name": "LiveAgent", "pid": 200, "bundle_id": "com.liveagent.desktop"} - ] - } - }); - let pid = unwrap_mcp(&apps_resp) - .get("apps") - .and_then(Value::as_array) - .and_then(|arr| arr.iter().find(|a| a.get("name").and_then(Value::as_str) == Some("LiveAgent"))) - .and_then(|a| a.get("pid").and_then(Value::as_i64)) - .expect("must resolve pid"); - assert_eq!(pid, 200); - } - - /// CUA-055:cua_list_windows 返回纯 windows 数组,而不是 MCP 包装。 - #[test] - fn list_windows_unwraps_to_pure_array() { - let mcp_resp = json!({ - "content": [{"type":"text", "text":"9 windows"}], - "structuredContent": { - "windows": [ - {"pid": 1, "window_id": 11}, - {"pid": 2, "window_id": 22} - ] - } - }); - // 模拟 cua_list_windows 的解包路径: - let mapped = unwrap_mcp(&mcp_resp) - .get("windows") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - let arr = mapped.as_array().expect("must be array"); - assert_eq!(arr.len(), 2); - // 不应再嵌套 content / structuredContent。 - assert!(arr[0].get("content").is_none()); - assert!(arr[0].get("structuredContent").is_none()); - } - - // ───────── CUA-054 业务层 effect 失败检测 ───────── - - /// cua_click 拿到 {isError:true, effect:"refused"} 时,finalize_op - /// 必须返回 ok=false + 结构化 CuaError,而不是吞掉 effect 当成功。 - #[test] - fn finalize_op_translates_refused_effect_to_error() { - let mcp_resp: Result = Ok(json!({ - "isError": true, - "content": [{"type":"text", "text":"refused"}], - "structuredContent": { - "effect": "refused", - "code": "off_space_or_ax_unresolved", - "escalation": {"recommended": "foreground"} - } - })); - let err = check_effect_failure(&mcp_resp).expect("must surface effect failure"); - assert_eq!(err.kind, "cua.errors.notExecuted"); - assert!(err.message.contains("refused")); - assert!(err.message.contains("off_space_or_ax_unresolved")); - } - - #[test] - fn finalize_op_translates_suspected_noop() { - let mcp_resp: Result = Ok(json!({ - "isError": true, - "structuredContent": {"effect": "suspected_noop", "code": "no_focus_change"} - })); - let err = check_effect_failure(&mcp_resp).expect("must surface"); - assert!(err.message.contains("suspected_noop")); - } - - #[test] - fn finalize_op_translates_unverifiable() { - let mcp_resp: Result = Ok(json!({ - "isError": true, - "structuredContent": {"effect": "unverifiable", "code": "ax_timeout"} - })); - let err = check_effect_failure(&mcp_resp).expect("must surface"); - assert!(err.message.contains("cannot verify")); - } - - /// isError=false 的成功响应(含无 effect 字段)保持 ok=true。 - #[test] - fn check_effect_failure_passes_through_success() { - let mcp_resp: Result = Ok(json!({ - "isError": false, - "content": [{"type":"text", "text":"clicked"}] - })); - assert!(check_effect_failure(&mcp_resp).is_none()); - - // effect 字段缺失但 isError=false:也视为成功(保守)。 - let mcp_resp2: Result = Ok(json!({ - "isError": false, - "structuredContent": {"foo": "bar"} - })); - assert!(check_effect_failure(&mcp_resp2).is_none()); - } - - /// isError 缺失但 effect=refused 的响应:保守起见,只信任 isError - /// 这个明确标记;这里 effect 单独不足以判定失败。 - #[test] - fn check_effect_failure_requires_iserror_flag() { - let mcp_resp: Result = Ok(json!({ - // 没有 isError:true - "structuredContent": {"effect": "refused"} - })); - assert!(check_effect_failure(&mcp_resp).is_none()); - } - - /// 传输层失败(Err)时 effect 检查不动(caller 走 Err 分支)。 - #[test] - fn check_effect_failure_ignores_transport_error() { - let transport_err: Result = Err(CuaError::io("subprocess died")); - assert!(check_effect_failure(&transport_err).is_none()); - } - - /// finalize_op 端到端:isError=true 应落地 ok=false。 - #[test] - fn finalize_op_returns_failure_on_iserror_true() { - let store = Arc::new(CuaStore::new(CuaRuntimeConfig::default())); - let resp = Ok(json!({ - "isError": true, - "structuredContent": {"effect": "refused", "code": "x"} - })); - let op_resp = finalize_op(&store, "click", None, resp, Utc::now()); - assert!(!op_resp.ok); - assert!(op_resp.error.is_some()); - assert!(op_resp.error.unwrap().message.contains("refused")); - } - - /// finalize_op 端到端:成功路径保持 ok=true、error=None。 - #[test] - fn finalize_op_keeps_success_on_iserror_false() { - let store = Arc::new(CuaStore::new(CuaRuntimeConfig::default())); - let resp = Ok(json!({"isError": false, "content": []})); - let op_resp = finalize_op(&store, "click", None, resp, Utc::now()); - assert!(op_resp.ok); - assert!(op_resp.error.is_none()); - } - - // ───────── CUA-059/060/061/062 回归测试 ───────── - - /// CUA-061:cua-driver 在 isError=true 时把错误消息放在 content[] - /// 的 text 条目里;extract_error_text 必须把它们拼出来,避免被 - /// extract_screenshot 当 None 翻成 'no image content'。 - #[test] - fn extract_error_text_pulls_creats_text_chunks() { - let value = json!({ - "isError": true, - "content": [ - { "type": "text", "text": "x2 must be > x1 and y2 must be > y1" } - ] - }); - let detail = extract_error_text(&value).expect("must surface error text"); - assert!(detail.contains("x2 must be > x1")); - assert!(detail.contains("y2 must be > y1")); - } - - /// CUA-061:没有 isError=true 时 extract_error_text 不返回真实 - /// 文本,避免误报(即使 content[] 里恰好有 text 条目)。 - #[test] - fn extract_error_text_returns_none_when_no_iserror() { - let value = json!({ - "isError": false, - "content": [ - { "type": "text", "text": "ok" }, - { "type": "image", "data": "AAAA", "mimeType": "image/png" } - ] - }); - // extract_error_text 只看 content[] 文本条目,但 caller(cua_screenshot) - // 必须先用 isError=true 守门。这里验证 helper 自身的抽取行为。 - let detail = extract_error_text(&value); - // 即使 image 也存在,文本条目仍会被收集;这是有意的——helper - // 单纯做文本提取,是否调用由 caller 决定。caller 应该走 - // extract_io_error_on_failure 而不是直接调本 helper。 - assert_eq!(detail.as_deref(), Some("ok")); - } - - // ───────── CUA-064 守门 helper 回归测试 ───────── - - /// CUA-064:成功响应(zoom / get_desktop_state)下 content 同时 - /// 含 type:text 描述项 + type:image 字节。`extract_io_error_on_failure` - /// 必须返回 None(不进 io error 分支),让 caller 走图像抽取。 - #[test] - fn extract_io_error_on_failure_returns_none_on_success_with_text() { - let value = json!({ - "isError": false, - "content": [ - { "type": "text", "text": "Zoom region (260,68)–(1660,868) → 500×286 px JPEG." }, - { "type": "image", "data": "AAAA", "mimeType": "image/png" } - ] - }); - assert!(extract_io_error_on_failure(&value).is_none()); - } - - /// CUA-064 反向:isError 缺失(null)也按成功处理,不透出文本。 - #[test] - fn extract_io_error_on_failure_returns_none_on_missing_iserror() { - let value = json!({ - "content": [ - { "type": "text", "text": "any description" }, - { "type": "image", "data": "AAAA", "mimeType": "image/png" } - ] - }); - assert!(extract_io_error_on_failure(&value).is_none()); - } - - /// CUA-064 失败路径:isError=true 时仍按之前的行为透出真实错误。 - #[test] - fn extract_io_error_on_failure_returns_text_on_iserror_true() { - let value = json!({ - "isError": true, - "content": [ - { "type": "text", "text": "x2 must be > x1 and y2 must be > y1" }, - { "type": "image", "data": "AAAA", "mimeType": "image/png" } - ] - }); - let detail = extract_io_error_on_failure(&value).expect("must surface failure text"); - assert!(detail.contains("x2 must be > x1")); - } - - /// CUA-064 失败路径:isError=true 但 content 里只有 image 时 - /// 仍返回 None(与原有 extract_error_text 行为一致)。 - #[test] - fn extract_io_error_on_failure_returns_none_when_only_image() { - let value = json!({ - "isError": true, - "content": [ - { "type": "image", "data": "AAAA", "mimeType": "image/png" } - ] - }); - assert!(extract_io_error_on_failure(&value).is_none()); - } - - /// CUA-059:extract_window_bounds 同时识别 nested `bounds` 对象 - /// 和顶层平铺 {x,y, width, height}。 - #[test] - fn extract_window_bounds_handles_both_shapes() { - let nested = json!({ - "window_id": 7, - "bounds": { "x": 100, "y": 50, "width": 800, "height": 600 } - }); - let b = extract_window_bounds(&nested).expect("nested bounds must parse"); - assert_eq!((b.x, b.y, b.width, b.height), (100, 50, 800, 600)); - - let flat = json!({ - "window_id": 8, - "x": 200, "y": 75, "width": 1024, "height": 768 - }); - let b2 = extract_window_bounds(&flat).expect("flat bounds must parse"); - assert_eq!((b2.x, b2.y, b2.width, b2.height), (200, 75, 1024, 768)); - } - - /// CUA-059:cua-driver list_windows 返回的 bounds 字段值是浮点 - ///(例如 `{"x":260.0,"y":68.0,...}`),extract_window_bounds 必须 - /// 截到 i32 而不是返回 None。 - #[test] - fn extract_window_bounds_accepts_floats() { - let real = json!({ - "window_id": 24391, - "bounds": { "x": 260.0, "y": 68.0, "width": 1400.0, "height": 800.0 } - }); - let b = extract_window_bounds(&real).expect("float bounds must parse"); - assert_eq!((b.x, b.y, b.width, b.height), (260, 68, 1400, 800)); - - // 混用 int + float 也兼容。 - let mixed = json!({ - "window_id": 9, - "bounds": { "x": 260.0, "y": 68, "width": 1400, "height": 800.0 } - }); - let b2 = extract_window_bounds(&mixed).expect("mixed bounds must parse"); - assert_eq!((b2.x, b2.y, b2.width, b2.height), (260, 68, 1400, 800)); - } - - /// CUA-059:bounds 缺失或 width/height <= 0 时返回 None,让 - /// zoom_rect 走 screen-size fallback(避免零面积)。 - #[test] - fn extract_window_bounds_rejects_zero_area() { - let no_bounds = json!({ "window_id": 9 }); - assert!(extract_window_bounds(&no_bounds).is_none()); - - let zero_area = json!({ - "bounds": { "x": 0, "y": 0, "width": 0, "height": 0 } - }); - assert!(extract_window_bounds(&zero_area).is_none()); - } - - /// CUA-059:zoom_rect 在 bounds 拿得到时输出真实矩形,bounds - /// 拿不到时输出非零面积的兜底矩形。 - #[test] - fn zoom_rect_uses_bounds_or_screen_fallback() { - let real = zoom_rect(Some(WindowBounds { - x: 10, - y: 20, - width: 800, - height: 600, - })); - assert_eq!(real, (10, 20, 810, 620)); - - let fallback = zoom_rect(None); - assert_eq!(fallback, (0, 0, FALLBACK_SCREEN_W, FALLBACK_SCREEN_H)); - // 兜底矩形的面积必须严格 > 0(cua-driver zoom 拒绝零面积)。 - assert!(fallback.2 > fallback.0); - assert!(fallback.3 > fallback.1); - - // width/height ≤ 0 也走 fallback。 - let degenerate = zoom_rect(Some(WindowBounds { - x: 0, - y: 0, - width: 0, - height: 0, - })); - assert_eq!(degenerate, (0, 0, FALLBACK_SCREEN_W, FALLBACK_SCREEN_H)); - } - - /// CUA-060:CuaError::owner_not_found 携带稳定的 i18n kind + owner 参数。 - #[test] - fn owner_not_found_emits_structured_error() { - let err = CuaError::owner_not_found("GhostApp"); - assert_eq!(err.kind, "cua.errors.ownerNotFound"); - assert_eq!( - err.params.get("owner").and_then(Value::as_str), - Some("GhostApp") - ); - assert!(err.message.contains("GhostApp")); - assert!(!err.message.contains('你'), "no Chinese leaks"); - } -} diff --git a/crates/agent-gui/src-tauri/src/commands/mod.rs b/crates/agent-gui/src-tauri/src/commands/mod.rs index e34fdd654..9e1fbc982 100644 --- a/crates/agent-gui/src-tauri/src/commands/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/mod.rs @@ -4,7 +4,6 @@ pub mod app_commands; pub mod automation_commands; #[path = "config/mod.rs"] pub mod config_commands; -pub mod cua; #[path = "history/mod.rs"] pub mod history_commands; #[path = "integration/mod.rs"] @@ -15,6 +14,7 @@ pub mod runtime_commands; pub mod workspace_commands; pub use app_commands::app; +pub use app_commands::ax; pub use app_commands::system; pub use app_commands::tray; pub use app_commands::update; diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 21fc7bcfa..26f12066f 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -309,26 +309,10 @@ macro_rules! app_invoke_handler { commands::system::system_begin_power_activity, commands::system::system_end_power_activity, commands::system::system_clipboard_read_text, - commands::cua::cua_status, - commands::cua::cua_set_config, - commands::cua::cua_clear_audit, - commands::cua::cua_list_windows, - commands::cua::cua_focus_window, - commands::cua::cua_screenshot, - commands::cua::cua_click, - commands::cua::cua_double_click, - commands::cua::cua_type, - commands::cua::cua_key, - commands::cua::cua_scroll, - commands::cua::cua_drag, - commands::cua::cua_window_ready, - commands::cua::cua_refresh_a11y, - // CUA driver installer (CUA-100 series). - commands::cua::cua_driver_detect, - commands::cua::cua_driver_install, - commands::cua::cua_driver_update, - commands::cua::cua_driver_start_daemon, - commands::cua::cua_driver_install_preview, + // AX 辅助命令:让外部自动化工具(cua-driver)能读到本窗口的 + // 无障碍树。与「操作用户电脑」无关——那条链路走 MCP Hub。 + commands::ax::cua_window_ready, + commands::ax::cua_refresh_a11y, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, @@ -1167,13 +1151,6 @@ pub fn run() { commands::app::CLOSE_WINDOW_BEHAVIOR_MINIMIZE, )); let stt_manager = Arc::new(services::stt::SttManager::default()); - // CUA 默认 disabled。前端首次加载后会把用户的开关 / 名单 push 进来。 - let cua_store = Arc::new(services::cua::CuaStore::new( - services::cua::CuaRuntimeConfig::default(), - )); - // 共享 cua-driver MCP 子进程客户端。第一次 cua_* 命令才懒 spawn, - // 失败时由上层把 IO 错误翻译成结构化 CuaError。 - let cua_client = services::cua::CuaClient::new(); let builder = tauri::Builder::default(); // dev 构建与已安装正式版共享 identifier;若 dev 也注册单实例, @@ -1220,8 +1197,6 @@ pub fn run() { .manage(Arc::clone(&close_window_behavior)) .manage(Arc::clone(&automation_store)) .manage(Arc::clone(&automation_scheduler)) - .manage(Arc::clone(&cua_store)) - .manage(cua_client.clone()) .manage(Arc::new(commands::hook::HookScopeRegistry::default())) .manage(stt_manager) .on_page_load(|webview, payload| { diff --git a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs b/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs deleted file mode 100644 index fdcffb4a9..000000000 --- a/crates/agent-gui/src-tauri/src/services/cua/cua_client.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! cua-driver MCP client。 -//! -//! 历史:之前的 `driver.rs` 是 osascript + screencapture 自研实现,仅 -//! macOS / 单线程 / 抢光标 / 看不到 Chromium 等非 AX 表面;reviewer 要求 -//! 改用上游 `cua-driver`([trycua/cua](https://github.com/trycua/cua),MIT), -//! 它跨 macOS / Windows / Linux、AX + 像素双路径、按窗口寻址、不抢光标。 -//! -//! 协议:`cua-driver mcp --direct` 起一个 stdio JSON-RPC server(每条消息 -//! 一行 newline-delimited JSON,符合 MCP 2025-06-18)。我们用 -//! `Arc>` 持单一长连子进程,调用方拿锁后写入 -//! `{jsonrpc,id,method,params}` → 从 stdout 读直到匹配 id。30 秒 -//! 同步超时,子进程死了下一次调用重新 spawn。 -//! -//! 设计边界: -//! - **不做沙箱 / 白名单 / audit**:那是 `CuaStore::enforce` 的活; -//! 这里只负责把 MCP 调用落地。 -//! - **不缓冲任何状态**:除 `next_id` 之外的会话字段(session / cursor -//! theme 之类)由调用方在 args 里按需指定;持久化的 MCP session 留给 -//! 上层 policy 决定。 - -use serde_json::{json, Value}; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use super::error::CuaError; - -/// 单次调用的同步超时。cua-driver 大多数 op(点击 / 截图 / 找 pid) -/// 都在 1-5 秒内完成;留给 `start_session` / `bring_to_front` 等 -/// 复合路径 30 秒缓冲。超出即报 `io("timeout after 30s")` 让上层 -/// 把它转成结构化 `CuaError::io`。 -const CALL_TIMEOUT: Duration = Duration::from_secs(30); - -/// 轮询读 stdout 的间隔。read_line 是阻塞的,每次最多等这么久就回 -/// 一次 deadline 检查 + child 状态探测。 -const READ_SLICE: Duration = Duration::from_millis(250); - -/// `health_report` 的 bundle_identity 缓存有效期。短到能在「用户重新授权 / -/// 重启 CuaDriver.app」后尽快恢复有效,长到不至于每次截屏都重发一次 -/// health_report(每次 ~200ms;同帧多次调用浪费)。 -const BUNDLE_ATTRIBUTION_CACHE: Duration = Duration::from_secs(60); - -/// cua-driver 可执行文件名(与 `installer::find_cua_driver` 同源; -/// 这里直接 `Command::new` 让 OS 走 PATH,不再做二次候选目录 walk, -/// 启动期由 installer 把 PATH 准备好)。 -const CUA_DRIVER_BIN: &str = "cua-driver"; - -/// 共享给 Tauri Command 的薄包装。`spawn` 懒:第一个调用方拿锁时 -/// 才起子进程;后续命中复用句柄。 -#[derive(Clone)] -pub struct CuaClient { - inner: Arc>, -} - -struct Inner { - /// 懒启动的子进程句柄。第一次 `call` 时若为 None 就 spawn。 - child: Option, - /// MCP 请求 id 自增;初始化握手与后续 tools/call 共享同一计数器。 - next_id: AtomicU64, - /// `health_report.bundle_identity` 缓存 + 取样时间。`None` 表示 - /// 还没取过;超时或子进程重启后会重新拉一次(CUA-051)。 - bundle_attribution: Mutex>, -} - -/// 拆出 stdio 句柄后剩下的子进程字段。 -struct RunningProcess { - child: Child, - stdout: BufReader, - stdin: ChildStdin, - /// stderr 我们不消费,但保留 handle 防止子进程写阻塞;drop 时 - /// 自动 close。调用方不直接读。 - _stderr: Option, -} - -impl CuaClient { - pub fn new() -> Self { - Self { - inner: Arc::new(Mutex::new(Inner { - child: None, - next_id: AtomicU64::new(1), - bundle_attribution: Mutex::new(None), - })), - } - } - - /// 调用一次 MCP 工具,返回 `result` 字段(结构化 JSON)。 - /// 错误一律翻译成 `CuaError`(i18n key + 英文 message),由 - /// Command 层直接透传给前端。 - pub fn call_tool(&self, tool: &str, arguments: Value) -> Result { - let (proc, id) = { - let mut guard = self - .inner - .lock() - .map_err(|e| CuaError::io(&format!("cua client lock poisoned: {e}")))?; - // 子进程死了 / 没起 → 重启一次。重启失败直接报错,不无限重试。 - let needs_respawn = match &mut guard.child { - None => true, - Some(p) => child_exited(&mut p.child), - }; - if needs_respawn { - guard.child = None; - let spawned = spawn_cua_driver()?; - guard.child = Some(spawned); - } - let id = guard.next_id.fetch_add(1, Ordering::SeqCst); - let proc = guard - .child - .as_mut() - .expect("just spawned") - as *mut RunningProcess; - // SAFETY: 整段 call_tool 全程持锁 `guard`,proc 不会被独占 - // 借用之外的其它路径触碰。把 `&mut guard.child.as_mut()` - // 重借用转成裸指针,再在锁里重新借回来。 - (unsafe { &mut *proc }, id) - }; - - let request = json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { - "name": tool, - "arguments": arguments, - } - }); - let mut line = serde_json::to_string(&request) - .map_err(|e| CuaError::io(&format!("serialize mcp request: {e}")))?; - line.push('\n'); - - proc.stdin - .write_all(line.as_bytes()) - .map_err(|e| CuaError::io(&format!("write mcp request: {e}")))?; - proc.stdin - .flush() - .map_err(|e| CuaError::io(&format!("flush mcp request: {e}")))?; - - // 阻塞读直到拿到匹配 id 的响应。带 30 秒总超时。 - let deadline = Instant::now() + CALL_TIMEOUT; - loop { - if Instant::now() >= deadline { - return Err(CuaError::io(&format!( - "cua-driver mcp call '{tool}' timed out after {}s", - CALL_TIMEOUT.as_secs() - ))); - } - let slice_deadline = Instant::now() + READ_SLICE; - let mut buf = String::new(); - loop { - if Instant::now() >= slice_deadline { - break; - } - if child_exited(&mut proc.child) { - self.shutdown(); - return Err(CuaError::io("cua-driver exited mid-call")); - } - let mut temp = String::new(); - match proc.stdout.read_line(&mut temp) { - Ok(0) => { - // EOF:子进程关了 stdout。 - self.shutdown(); - return Err(CuaError::io( - "cua-driver stdout closed unexpectedly", - )); - } - Ok(_) => { - buf.push_str(&temp); - if buf.ends_with('\n') { - break; - } - } - Err(e) => { - return Err(CuaError::io(&format!("read mcp response: {e}"))); - } - } - } - if buf.is_empty() { - // 本轮 slice 没读到完整行 → 检查总 deadline 后再继续。 - continue; - } - // 解析 JSON-RPC 响应。 - let parsed: Value = match serde_json::from_str(buf.trim()) { - Ok(v) => v, - Err(_) => { - // 可能收到 cua-driver 的 stderr 噪音(启动 banner 等) - // 混入 stdout;遇到非 JSON 行就跳过等下一行。 - continue; - } - }; - // 匹配 id;服务端可能并发推送 notifications/... 等无 id 帧, - // 那些在 MCP 2025-06-18 之前是 server→client;这里跳过。 - let resp_id = parsed.get("id").and_then(Value::as_u64); - if resp_id != Some(id) { - continue; - } - // 错误响应:把 MCP error 转成 CuaError。 - if let Some(err) = parsed.get("error") { - let code = err.get("code").and_then(Value::as_i64).unwrap_or(-1); - let msg = err - .get("message") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(); - return Err(map_mcp_error(code, &msg)); - } - return Ok(parsed.get("result").cloned().unwrap_or(Value::Null)); - } - } - - /// 关掉子进程。下次调用会重新 spawn。 - pub fn shutdown(&self) { - if let Ok(mut guard) = self.inner.lock() { - if let Some(mut proc) = guard.child.take() { - let _ = proc.child.kill(); - let _ = proc.child.wait(); - } - } - } - - /// CUA-051:检测 cua-driver 是否带 CFBundleIdentifier 启动。 - /// `true` 表示 health_report 中 `bundle_identity` 为 pass,可以 - /// 信任 `get_desktop_state` 返回真实屏幕像素;`false` 表示该路径 - /// 会返回全黑帧(TCC Screen Recording attribution 失效),调用方 - /// 应该改走 `zoom`(按窗口寻址,attribution 不依赖 bundle)或直接 - /// 报 `screen_capture_unavailable`。 - /// - /// 结果缓存 `BUNDLE_ATTRIBUTION_CACHE`(60s),避免每次截屏都发 - /// health_report 浪费时间。子进程重启 / `shutdown()` 后下次调用 - /// 会重发——`bundle_attribution` 是与 child 同寿命的字段。 - pub fn check_bundle_attribution(&self) -> bool { - // fast path:缓存有效就直接返回。 - if let Ok(guard) = self.inner.lock() { - if let Ok(attr) = guard.bundle_attribution.lock() { - if let Some((ok, at)) = *attr { - if at.elapsed() < BUNDLE_ATTRIBUTION_CACHE { - return ok; - } - } - } - } - // slow path:调 health_report 拿 bundle_identity。失败 / 超时 - // 一律按 fail 处理(保守;宁可错杀不要返回全黑帧)。 - // cua-driver MCP 2025-06-18 把 `checks` 放在 structuredContent 下, - // 必须先 unwrap 再取(CUA-053)。 - let parsed = self - .call_tool("health_report", json!({})) - .ok() - .map(|v| unwrap_mcp(&v)) - .and_then(|v| v.get("checks").and_then(Value::as_array).cloned()) - .and_then(|checks| { - checks - .into_iter() - .find(|c| c.get("name").and_then(Value::as_str) == Some("bundle_identity")) - }); - let ok = parsed - .as_ref() - .and_then(|c| c.get("status").and_then(Value::as_str)) - .map(|s| s == "pass") - .unwrap_or(false); - if let Ok(guard) = self.inner.lock() { - if let Ok(mut attr) = guard.bundle_attribution.lock() { - *attr = Some((ok, Instant::now())); - } - } - ok - } -} - -impl Default for CuaClient { - fn default() -> Self { - Self::new() - } -} - -impl Drop for CuaClient { - fn drop(&mut self) { - self.shutdown(); - } -} - -fn child_exited(child: &mut Child) -> bool { - matches!(child.try_wait(), Ok(Some(_))) -} - -/// 解开 cua-driver MCP 2025-06-18 `{content, structuredContent}` 包装。 -/// 返回内部 `structuredContent`(如存在),否则 fallback 到原 `result`。 -/// -/// MCP 2025-06-18 把工具响应拆成「人类可读 content 数组 + 结构化 -/// structuredContent」,但 cua-driver 把数据(`windows` / `apps` / -/// `checks` / `effect`)放在 `structuredContent` 里;很多旧路径 -/// 直接 `result.get("xxx")` 顶层取,结果永远拿不到。所有解析点都 -/// 必须先过这一层(CUA-053 / 055 / 056 / 057 同根因)。 -pub(crate) fn unwrap_mcp(result: &Value) -> Value { - if let Some(sc) = result.get("structuredContent") { - sc.clone() - } else { - result.clone() - } -} - -/// 起一个 `cua-driver mcp --direct` 子进程。stdout / stdin 都 pipe。 -/// `--host-bundle-id` 给一个非空占位(cua-driver 关心 TCC attribution, -/// LiveAgent 主程序 bundle id 即可,不强求精确)。 -fn spawn_cua_driver() -> Result { - let mut cmd = Command::new(CUA_DRIVER_BIN); - cmd.arg("mcp") - .arg("--direct") - .arg("--host-bundle-id") - .arg("com.liveagent.desktop"); - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = cmd - .spawn() - .map_err(|e| CuaError::io(&format!("failed to spawn cua-driver mcp: {e}")))?; - let stdin = child - .stdin - .take() - .ok_or_else(|| CuaError::io("cua-driver stdin missing"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| CuaError::io("cua-driver stdout missing"))?; - let stderr = child.stderr.take(); - Ok(RunningProcess { - child, - stdout: BufReader::new(stdout), - stdin, - _stderr: stderr, - }) -} - -/// 把 MCP `error.code` + message 翻成稳定的 `CuaError`。 -/// cua-driver 用 `code` 表示语义(permission / unsupported / io), -/// 我们把已知 code 映射到 i18n kind,其余走 io 兜底。 -fn map_mcp_error(code: i64, message: &str) -> CuaError { - match code { - // MCP 标准错误码:-32001 ~ -32099 是服务端自定义。 - -32001 => CuaError::permission_required("accessibility", "Accessibility"), - -32002 => CuaError::permission_required("screen-recording", "Screen Recording"), - // -32601 method not found:MCP 协议层错误,可能是工具名拼错。 - -32601 => CuaError::not_executed(&format!( - "cua-driver does not know this tool: {message}" - )), - _ => CuaError::io(&format!("cua-driver mcp error {code}: {message}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mcp_error_maps_permission_codes() { - let e = map_mcp_error(-32001, "AX denied"); - assert_eq!(e.kind, "cua.errors.permissionRequired"); - assert_eq!( - e.params.get("permissionKey").and_then(Value::as_str), - Some("accessibility") - ); - } - - #[test] - fn mcp_error_maps_unknown_code_to_io() { - let e = map_mcp_error(-99999, "wat"); - assert_eq!(e.kind, "cua.errors.io"); - assert!(e.message.contains("cua-driver")); - } - - #[test] - fn map_mcp_error_method_not_found_is_not_executed() { - let e = map_mcp_error(-32601, "unknown tool"); - assert_eq!(e.kind, "cua.errors.notExecuted"); - } - - #[test] - fn client_can_be_cloned() { - let c = CuaClient::new(); - let _c2 = c.clone(); - assert!(Arc::strong_count(&c.inner) >= 2); - } - - /// CUA-053:health_report 返回的 `checks` 在 structuredContent 下, - /// 顶层没有 `checks` 字段。`unwrap_mcp` 必须穿透这一层。 - #[test] - fn unwrap_mcp_extracts_structured_content() { - let v = json!({ - "content": [{"type": "text", "text": "all good"}], - "structuredContent": { - "checks": [ - {"name": "bundle_identity", "status": "pass"}, - {"name": "ax", "status": "pass"} - ] - }, - "isError": false - }); - let inner = unwrap_mcp(&v); - let checks = inner - .get("checks") - .and_then(Value::as_array) - .expect("checks array"); - assert_eq!(checks.len(), 2); - assert_eq!( - checks[0].get("name").and_then(Value::as_str), - Some("bundle_identity") - ); - assert_eq!( - checks[0].get("status").and_then(Value::as_str), - Some("pass") - ); - } - - /// 没有 structuredContent 包装时(如未来协议简化),`unwrap_mcp` - /// 必须保持原值可用,避免误删数据。 - #[test] - fn unwrap_mcp_passthrough_when_no_structured_content() { - let v = json!({ "checks": [{"name": "bundle_identity", "status": "pass"}] }); - let inner = unwrap_mcp(&v); - assert_eq!(inner.get("checks").and_then(Value::as_array).map(|a| a.len()), Some(1)); - } -} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/cua/error.rs b/crates/agent-gui/src-tauri/src/services/cua/error.rs deleted file mode 100644 index 86fb5bc71..000000000 --- a/crates/agent-gui/src-tauri/src/services/cua/error.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! CUA 跨 IPC 的结构化错误。 -//! -//! 之前每个错误分支都用 `#[error("…中文…")]` 把字面量硬塞进 IPC 返回值, -//! 结果在英文 locale 下审计日志与 tool 错误都飘出中文(CUA-006)。现在改成 -//! 仅由后端承载「稳定 i18n key + 模板参数 + 英文兜底消息」,由前端 `t(...)` -//! 按活动 locale 渲染。 -//! -//! 不用 `thiserror` 派生:因为 key + params 都来自手工拼装而非 fmt。 - -use serde::{Deserialize, Serialize}; - -/// 跨 IPC 传递的 CUA 错误。前端拿到 `kind` 后查 i18n map;查不到时退回 -/// `message`(始终是英文)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaError { - /// i18n key,例如 `"cua.errors.disabled"`。 - pub kind: String, - /// 模板参数,键名与 i18n 文案的 `{xxx}` 占位一一对应。 - #[serde(default, skip_serializing_if = "is_null_or_empty")] - pub params: serde_json::Value, - /// 英文兜底消息。当前端无法识别 `kind` 时显示。 - pub message: String, -} - -fn is_null_or_empty(v: &serde_json::Value) -> bool { - v.is_null() -} - -impl CuaError { - pub fn new(kind: impl Into, message: impl Into) -> Self { - Self { - kind: kind.into(), - params: serde_json::Value::Null, - message: message.into(), - } - } - - pub fn with_params(kind: impl Into, message: impl Into, params: serde_json::Value) -> Self { - Self { - kind: kind.into(), - params, - message: message.into(), - } - } - - /// 总开关未启用。 - pub fn disabled() -> Self { - Self::new( - "cua.errors.disabled", - "CUA is not enabled. Open Settings → CUA Driver and turn the master switch on.", - ) - } - - /// 当前命令安全模式是 sandboxOffline:CUA 工具一律拒绝,避免 - /// 通过 cua-driver 突破 sandbox 强制断网(CUA-reviewer 要求)。 - pub fn sandbox_offline() -> Self { - Self::new( - "cua.errors.sandboxOffline", - "CUA tools are disabled while the command safety mode is sandboxOffline. \ - CUA would bypass the offline sandbox by spawning a separate network-capable \ - process. Switch the command safety mode to ask / auto / sandbox to use CUA.", - ) - } - - /// 目标 owner 不在白名单。 - pub fn denied_by_allowlist(target: &str, allowed: &[String]) -> Self { - Self::with_params( - "cua.errors.deniedByAllowlist", - format!( - "CUA operation denied: target \"{target}\" is not in the allowlist {allowed:?}." - ), - serde_json::json!({ - "target": target, - "allowed": allowed, - }), - ) - } - - /// 当前 OS 尚未实现 CUA 驱动。`platform` 是 OS 标签如 "macos"。 - /// 历史上用于自研 `MacOsDriver` / `UnsupportedDriver` 的 stub; - /// cua-driver 跨平台后这条路径只会在子进程 spawn 失败时由 - /// `cua_client` 兜底翻出来。 - #[allow(dead_code)] - pub fn unsupported_platform(os: &str) -> Self { - Self::with_params( - "cua.errors.unsupportedPlatform", - format!("CUA driver is not available on {os}; only macOS is implemented."), - serde_json::json!({ "platform": os }), - ) - } - - /// macOS 权限拒绝(辅助功能 / 屏幕录制)。 - pub fn permission_required(permission_key: &str, display: &str) -> Self { - Self::with_params( - "cua.errors.permissionRequired", - format!( - "CUA needs macOS permission ({display}); grant LiveAgent the {permission_key} permission in System Settings → Privacy & Security, then try again." - ), - serde_json::json!({ - "permissionKey": permission_key, - "permission": display, - }), - ) - } - - /// 驱动未执行(前置条件未满足,例如缺少 `cliclick`)。 - pub fn not_executed(detail: &str) -> Self { - Self::with_params( - "cua.errors.notExecuted", - format!("CUA operation was not executed: {detail}."), - serde_json::json!({ "detail": detail }), - ) - } - - /// CUA-060:cua_screenshot / cua_type / cua_key 拿到具体 owner 但 - /// `list_apps` + `list_windows` 找不到对应进程 / 窗口时上报一个 - /// 明确的「owner 找不到」错误,而不是默默退回 get_desktop_state - /// 后触发 CUA-051 后置防线被翻译成「TCC 失效」。 - pub fn owner_not_found(owner: &str) -> Self { - Self::with_params( - "cua.errors.ownerNotFound", - format!( - "Could not find a running window for owner \"{owner}\". \ - Verify the app name / bundle id and that it has at least \ - one visible window before retrying." - ), - serde_json::json!({ "owner": owner }), - ) - } - - /// 子进程 IO 失败(osascript / screencapture 启动失败等)。 - pub fn io(detail: &str) -> Self { - Self::with_params( - "cua.errors.io", - format!("CUA subprocess IO error: {detail}."), - serde_json::json!({ "detail": detail }), - ) - } - - /// 截屏不可用:CUA 工具能跑通协议但 `get_desktop_state` 返回全黑 - /// 帧(cua-driver 没有在 CuaDriver.app bundle 内启动 → TCC Screen - /// Recording attribution 失效;或权限被吊销)。Agent 拿到全黑图 - /// 对决策毫无意义,所以由后端在 health_report.bundle_identity / - /// 全黑帧检测触发时直接拒(CUA-051)。 - pub fn screen_capture_unavailable(detail: &str) -> Self { - Self::with_params( - "cua.errors.screenCaptureUnavailable", - format!( - "CUA screen capture is unavailable: {detail}. \ - Launch cua-driver inside CuaDriver.app (so TCC Screen Recording \ - attributes to com.trycua.driver) or re-grant Screen Recording \ - for CuaDriver in System Settings → Privacy & Security, then retry." - ), - serde_json::json!({ "detail": detail }), - ) - } - - // ───────── Installer error variants (CUA-100 series) ───────── - - /// 网络不可用 / DNS 失败 / 离线。 - pub fn installer_network_unavailable(detail: &str) -> Self { - Self::with_params( - "cua.errors.installer.networkUnavailable", - format!( - "CUA driver installer could not reach the network: {detail}. \ - Check your connection and try again." - ), - serde_json::json!({ "detail": detail }), - ) - } - - /// curl / powershell 启动失败。 - pub fn installer_curl_failed(detail: &str) -> Self { - Self::with_params( - "cua.errors.installer.curlFailed", - format!("CUA driver installer download failed: {detail}."), - serde_json::json!({ "detail": detail }), - ) - } - - /// 脚本签名校验失败(占位;目前 install 脚本未签名,留接口备用)。 - #[allow(dead_code)] - pub fn installer_signature_invalid(detail: &str) -> Self { - Self::with_params( - "cua.errors.installer.signatureInvalid", - format!("CUA driver installer signature check failed: {detail}."), - serde_json::json!({ "detail": detail }), - ) - } - - /// 权限不足(典型:用户拒绝 sudo / 文件 ACL)。 - pub fn installer_permission_denied(detail: &str) -> Self { - Self::with_params( - "cua.errors.installer.permissionDenied", - format!("CUA driver installer needs elevated permission: {detail}."), - serde_json::json!({ "detail": detail }), - ) - } - - /// 当前平台不支持 CUA 安装器(理论上 webUI / 不支持 OS 才会触发)。 - pub fn installer_unsupported_platform(os: &str) -> Self { - Self::with_params( - "cua.errors.installer.unsupportedPlatform", - format!("CUA driver installer is not available on {os}."), - serde_json::json!({ "platform": os }), - ) - } - - /// 安装超时。 - pub fn installer_timeout(minutes: u64) -> Self { - Self::with_params( - "cua.errors.installer.timeout", - format!( - "CUA driver installer did not finish within {minutes} minutes. \ - The download may have stalled; please retry." - ), - serde_json::json!({ "minutes": minutes }), - ) - } -} - -impl std::fmt::Display for CuaError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -impl std::error::Error for CuaError {} diff --git a/crates/agent-gui/src-tauri/src/services/cua/installer.rs b/crates/agent-gui/src-tauri/src/services/cua/installer.rs deleted file mode 100644 index 6f4dcf703..000000000 --- a/crates/agent-gui/src-tauri/src/services/cua/installer.rs +++ /dev/null @@ -1,980 +0,0 @@ -//! CUA Driver 安装 / 检测 / 更新模块。 -//! -//! 设计目标:把 CUA 官方 install 脚本(`https://cua.ai/driver/install.sh` / -//! `install.ps1`)当成「可信外部脚本」对待——只通过 `Command::new` 直起官方 -//! 提供的安装器,把 stdout / stderr 实时经 Tauri `emit("cua_install_progress")` -//! 回给前端;不在 Rust 内部复刻安装逻辑,避免与官方版本漂移。 -//! -//! 检测:按平台在 PATH / 平台特定目录查找 `cua-driver` / `CuaDriver.app`, -//! 调用 `cua-driver --version` 与 `cua-driver doctor` 探测健康度。 -//! -//! 不在模块内做任何权限校验 / 启用开关——安装器面向「需要驱动但还没装」 -//! 的场景;上层 `cua_driver_*` Command 才是策略边界。 - -use serde::{Deserialize, Serialize}; -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::time::Duration; -use tauri::{AppHandle, Emitter}; - -const INSTALL_PROGRESS_EVENT: &str = "cua_install_progress"; -/// 安装脚本最大允许运行时长。官方 `install.sh` 含下载 + 解压 + 自启动 -/// autostart,最坏情况约 3-5 分钟;30 分钟已留足余量。 -const INSTALL_TIMEOUT_SECS: u64 = 30 * 60; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum InstallerStage { - /// 开始:分配任务、准备 spawn。 - Starting, - /// 下载 install 脚本中。 - Downloading, - /// 正在执行安装。 - Installing, - /// 启动 daemon(仅 macOS / Windows)。 - StartingDaemon, - /// 安装 + 启动成功。 - Completed, - /// 失败;详见 error 字段。 - Failed, - /// 用户 / 守护进程主动取消。 - Cancelled, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallerProgressEvent { - pub stage: InstallerStage, - /// 人类可读进度信息(英文;UI 自行翻译)。 - pub message: String, - /// 累积的日志(最近 200 行)。 - #[serde(skip_serializing_if = "Option::is_none")] - pub log_tail: Option, - /// 0..=100 的进度百分比。`None` 表示未知。 - #[serde(skip_serializing_if = "Option::is_none")] - pub percent: Option, -} - -/// 检测结果。安装成功但 daemon 仍未起来时 `installed=true` / -/// `daemon_running=false`,UI 据此给出不同 CTA。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaDriverDetection { - /// 能否在 PATH 或平台特定位置找到 `cua-driver` / `CuaDriver.app`。 - pub installed: bool, - /// `cua-driver --version` 输出解析出的版本字符串。 - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - /// 解析出的可执行文件绝对路径。 - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - /// 平台标签("macos" / "windows" / "linux")。 - pub platform: &'static str, - /// 是否找到 CuaDriver.app(macOS 专属)。 - #[serde(skip_serializing_if = "Option::is_none")] - pub app_bundle_installed: Option, - /// daemon 进程是否在运行(macOS 下用 `pgrep CuaDriver` 探测)。 - pub daemon_running: bool, - /// `cua-driver doctor` 的完整输出(已截到 8 KB)。 - #[serde(skip_serializing_if = "Option::is_none")] - pub doctor_output: Option, - /// 检测过程中的错误(路径找不到 / --version 失败等),非致命。 - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// 安装结果。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaInstallResult { - pub success: bool, - /// 完整日志(已截到 64 KB),即使失败也返回。 - pub log: String, - /// 成功后探测到的 cua-driver 版本。 - #[serde(skip_serializing_if = "Option::is_none")] - pub installed_version: Option, - /// 安装后是否自动启动了 daemon。 - pub daemon_started: bool, - /// 失败时的错误描述。 - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// 更新结果。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaUpdateResult { - /// 是否检测到新版本。 - pub update_available: bool, - /// 完整 check-update 输出。 - pub log: String, - /// 实际执行 update --apply 后才填。 - #[serde(skip_serializing_if = "Option::is_none")] - pub new_version: Option, - /// 失败时的错误描述。 - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -// ───────── 平台特定路径探测 ───────── - -fn platform_label() -> &'static str { - if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "windows") { - "windows" - } else { - "linux" - } -} - -fn home_dir() -> Option { - // 跨平台 home:`HOME` (Unix) / `USERPROFILE` (Windows) / `HOME` 兜底。 - if let Some(p) = std::env::var_os("HOME").filter(|s| !s.is_empty()) { - return Some(PathBuf::from(p)); - } - if let Some(p) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) { - return Some(PathBuf::from(p)); - } - if let Some(p) = dirs::home_dir() { - return Some(p); - } - None -} - -fn candidate_paths() -> Vec { - let mut out: Vec = Vec::new(); - let home = home_dir(); - match platform_label() { - "macos" => { - out.push(PathBuf::from("/Applications/CuaDriver.app/Contents/MacOS/cua-driver")); - out.push(PathBuf::from("/Applications/CuaDriver.app")); - if let Some(h) = &home { - out.push(h.join(".local/bin/cua-driver")); - } - } - "windows" => { - if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|s| !s.is_empty()) { - let base = PathBuf::from(local); - out.push(base.join("Programs/Cua/cua-driver/bin/cua-driver.exe")); - out.push(base.join("Programs/Cua/cua-driver/bin")); - } - } - _ => { - // linux - if let Some(h) = &home { - out.push(h.join(".cua-driver/packages/releases/cua-driver")); - out.push(h.join(".local/bin/cua-driver")); - } - } - } - out -} - -fn find_cua_driver() -> Option { - // 1) PATH 中:手工 walk env::split_paths(PATH) 找 `cua-driver`。 - if let Some(p) = find_in_path("cua-driver") { - return Some(p); - } - // 2) 平台特定候选路径。 - for candidate in candidate_paths() { - if candidate.is_file() { - return Some(candidate); - } - } - None -} - -fn find_in_path(binary: &str) -> Option { - let path_var = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&path_var) { - let candidate = dir.join(binary); - if candidate.is_file() { - return Some(candidate); - } - // Windows 走 .exe 后缀。 - #[cfg(target_os = "windows")] - { - let with_exe = dir.join(format!("{binary}.exe")); - if with_exe.is_file() { - return Some(with_exe); - } - } - } - None -} - -// ───────── 版本 / doctor / daemon 探测 ───────── - -fn parse_version(output: &str) -> Option { - // 兼容 "cua-driver 0.3.1" / "0.3.1" / "v0.3.1 (commit abc)"。 - for token in output.split_whitespace() { - let stripped = token.trim_start_matches('v').trim_end_matches(','); - if stripped.is_empty() { - continue; - } - // 至少 vX.Y 形式。 - if stripped - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - && stripped.contains('.') - { - return Some(stripped.to_string()); - } - } - None -} - -fn run_with_timeout(mut cmd: Command, timeout: Duration) -> Result { - // 简单同步 + 上层 `tokio::task::spawn_blocking` 把 IO 移出主线程。 - // 进程级 timeout 用 `wait-timeout` crate(已有依赖)。 - let mut child = cmd - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .stdin(Stdio::null()) - .spawn() - .map_err(|e| format!("failed to spawn: {e}"))?; - let start = std::time::Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - if let Some(mut s) = child.stdout.take() { - let _ = std::io::Read::read_to_end(&mut s, &mut stdout); - } - if let Some(mut s) = child.stderr.take() { - let _ = std::io::Read::read_to_end(&mut s, &mut stderr); - } - return Ok(std::process::Output { - status, - stdout, - stderr, - }); - } - Ok(None) => { - if start.elapsed() > timeout { - let _ = child.kill(); - return Err(format!("timeout after {:?}", timeout)); - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(e) => return Err(format!("try_wait failed: {e}")), - } - } -} - -fn truncate_log(s: &str, max: usize) -> String { - if s.len() <= max { - return s.to_string(); - } - // 保留尾部,便于排错(最新输出更有用)。 - let start = s.len() - max; - let mut idx = start; - while idx < s.len() && !s.is_char_boundary(idx) { - idx += 1; - } - format!("…(truncated {start} bytes)…\n{}", &s[idx..]) -} - -// ───────── 对外 API ───────── - -/// 检测 cua-driver 是否已安装、版本、daemon 状态。 -pub fn detect() -> CuaDriverDetection { - let mut det = CuaDriverDetection { - installed: false, - version: None, - path: None, - platform: platform_label(), - app_bundle_installed: None, - daemon_running: false, - doctor_output: None, - error: None, - }; - - // macOS 专属:探测 .app bundle 是否已安装(与可执行文件分离的状态)。 - #[cfg(target_os = "macos")] - { - det.app_bundle_installed = Some(PathBuf::from("/Applications/CuaDriver.app").exists()); - } - - let Some(bin) = find_cua_driver() else { - det.error = Some("cua-driver not found in PATH or platform-specific locations".into()); - return det; - }; - det.installed = true; - det.path = Some(bin.display().to_string()); - - // --version - let mut version_cmd = Command::new(&bin); - version_cmd.arg("--version"); - match run_with_timeout(version_cmd, Duration::from_secs(5)) { - Ok(out) if out.status.success() => { - let stdout = String::from_utf8_lossy(&out.stdout); - if let Some(v) = parse_version(&stdout) { - det.version = Some(v); - } else { - det.error = Some(format!( - "could not parse version from: {}", - stdout.trim() - )); - } - } - Ok(out) => { - det.error = Some(format!( - "cua-driver --version exited with status {:?}: {}", - out.status.code(), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Err(e) => { - det.error = Some(e); - } - } - - // doctor(best-effort;失败不影响 installed 判定) - let mut doctor_cmd = Command::new(&bin); - doctor_cmd.arg("doctor"); - if let Ok(out) = run_with_timeout(doctor_cmd, Duration::from_secs(8)) { - let combined = format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - det.doctor_output = Some(truncate_log(&combined, 8 * 1024)); - } - - // daemon 探测 - det.daemon_running = is_daemon_running(); - - det -} - -pub fn is_daemon_running() -> bool { - // macOS / Linux:尝试 `pgrep -f CuaDriver`。Windows:tasklist。 - #[cfg(any(target_os = "macos", target_os = "linux"))] - { - if let Ok(out) = Command::new("pgrep") - .args(["-f", "CuaDriver"]) - .output() - { - if out.status.success() && !out.stdout.is_empty() { - return true; - } - } - // 兜底:cua-driver serve 进程。 - if let Ok(out) = Command::new("pgrep") - .args(["-f", "cua-driver serve"]) - .output() - { - if out.status.success() && !out.stdout.is_empty() { - return true; - } - } - false - } - #[cfg(target_os = "windows")] - { - if let Ok(out) = Command::new("tasklist") - .args(["/FI", "IMAGENAME eq CuaDriver.exe", "/NH"]) - .output() - { - let stdout = String::from_utf8_lossy(&out.stdout); - return stdout.contains("CuaDriver.exe"); - } - false - } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - false - } -} - -/// 按平台构建安装命令。返回 (program, args, description)——program 是 -/// 实际要 spawn 的二进制(`bash` / `powershell`),args 是参数。 -pub fn build_install_command() -> Result { - let script_url = "https://cua.ai/driver/install.sh"; - let ps_url = "https://cua.ai/driver/install.ps1"; - match platform_label() { - "macos" | "linux" => Ok(InstallCommand { - program: "/bin/bash".to_string(), - args: vec![ - "-c".to_string(), - format!( - "set -e; curl -fsSL {script_url} | bash -s --; rc=$?; echo \"[install.sh exit code: $rc]\"; exit $rc" - ), - ], - description: "macOS / Linux installer".to_string(), - needs_sudo: false, - }), - "windows" => Ok(InstallCommand { - program: "powershell.exe".to_string(), - // PowerShell: `irm | iex` 把脚本下载到内存并立即执行; - // 末尾 `cua-driver autostart kick` 是官方安装器要求的最后一步。 - args: vec![ - "-NoProfile".to_string(), - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - "-Command".to_string(), - format!("irm {ps_url} | iex; cua-driver autostart kick"), - ], - description: "Windows installer".to_string(), - needs_sudo: false, - }), - other => Err(format!("unsupported platform for CUA driver install: {other}")), - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallCommand { - pub program: String, - pub args: Vec, - pub description: String, - /// Linux 上需要 sudo(apt 装依赖);macOS / Windows 上不需要。 - pub needs_sudo: bool, -} - -/// Linux 专属:检测并提示/安装 X11 / AT-SPI 依赖。当前只探测,不自动 sudo。 -/// 返回 (apt_available: bool, missing: Vec<&'static str>)。 -pub fn linux_check_apt_deps() -> (bool, Vec<&'static str>) { - if !cfg!(target_os = "linux") { - return (false, Vec::new()); - } - // 简单探测 dpkg 是否可用。 - let has_dpkg = Command::new("dpkg-query") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if has_dpkg { - let mut missing = Vec::new(); - for pkg in &["libxi6", "at-spi2-core"] { - let ok = Command::new("dpkg-query") - .args(["-W", "-f=${Status}", pkg]) - .output() - .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("install ok")) - .unwrap_or(false); - if !ok { - missing.push(*pkg); - } - } - (true, missing) - } else { - (false, Vec::new()) - } -} - -/// 给前端展示用:把即将执行的 install 命令 + Linux 依赖状态一起打包。 -/// 前端可以把它显示在「即将运行的命令」卡片里,给用户最终确认。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallPreview { - pub platform: &'static str, - pub command: InstallCommand, - /// Linux 专属:`true` 表示系统使用 apt,UI 给出「需先装 libxi6 / at-spi2-core」提示。 - #[serde(skip_serializing_if = "Option::is_none")] - pub linux_apt_available: Option, - /// Linux 专属:缺失的包。空 = 已就绪。 - #[serde(skip_serializing_if = "Option::is_none")] - pub linux_missing_packages: Option>, -} - -pub fn build_install_preview() -> Result { - let command = build_install_command()?; - let mut preview = InstallPreview { - platform: platform_label(), - command, - linux_apt_available: None, - linux_missing_packages: None, - }; - if platform_label() == "linux" { - let (apt, missing) = linux_check_apt_deps(); - preview.linux_apt_available = Some(apt); - preview.linux_missing_packages = Some(missing.into_iter().map(String::from).collect()); - } - Ok(preview) -} - -/// 执行安装。`app` 用于发 progress 事件;返回 `CuaInstallResult`。 -/// 设计:单线程同步阻塞命令跑在调用方(由 `tauri::async_runtime::spawn_blocking` -/// 调度),stdout / stderr 实时 line-by-line 推送 + 累积到 `log`。 -pub fn install(app: &AppHandle) -> CuaInstallResult { - // 起始事件 - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Starting, - message: "Preparing CUA driver installer…".to_string(), - log_tail: None, - percent: Some(0), - }, - ); - - let cmd = match build_install_command() { - Ok(c) => c, - Err(e) => { - return finalize_failure( - app, - "Failed to build installer command", - &e, - String::new(), - ); - } - }; - - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Downloading, - message: format!("Downloading installer from cua.ai ({})", cmd.description), - log_tail: None, - percent: Some(5), - }, - ); - - let mut command = Command::new(&cmd.program); - command - .args(&cmd.args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .stdin(Stdio::null()); - // 同步阻塞,由 caller 包在 spawn_blocking 里。 - let mut child = match command.spawn() { - Ok(c) => c, - Err(e) => { - return finalize_failure( - app, - "Failed to spawn installer", - &e.to_string(), - String::new(), - ); - } - }; - - let mut log = String::new(); - let mut stage_emitted_installing = false; - let last_emit = std::time::Instant::now(); - let start = std::time::Instant::now(); - - // 同时读 stdout + stderr:用 thread 拼 stdout,sterr 走主循环。 - // MVP:line-alternating,跨流顺序由 OS 决定;用户能跟进度即可。 - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let app_for_thread = app.clone(); - let log_arc = Arc::new(std::sync::Mutex::new(String::new())); - let log_for_thread = Arc::clone(&log_arc); - - if let Some(stdout) = stdout { - let app_t = app_for_thread.clone(); - let log_t = Arc::clone(&log_for_thread); - std::thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - if let Ok(mut s) = log_t.lock() { - push_line(&mut s, &line); - } - let _ = app_t.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Installing, - message: line.clone(), - log_tail: Some(line), - percent: Some(percent_for(start.elapsed())), - }, - ); - } - }); - } - - if let Some(stderr) = stderr { - let app_t = app_for_thread.clone(); - let log_t = Arc::clone(&log_for_thread); - std::thread::spawn(move || { - let reader = BufReader::new(stderr); - for line in reader.lines().map_while(Result::ok) { - if let Ok(mut s) = log_t.lock() { - push_line(&mut s, &format!("[stderr] {line}")); - } - let _ = app_t.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Installing, - message: format!("(stderr) {line}"), - log_tail: Some(format!("[stderr] {line}")), - percent: Some(percent_for(start.elapsed())), - }, - ); - } - }); - } - - // 等待子进程 + 整体 timeout。 - let timeout = Duration::from_secs(INSTALL_TIMEOUT_SECS); - let exit_status = loop { - match child.try_wait() { - Ok(Some(status)) => break Ok(status), - Ok(None) => { - if start.elapsed() > timeout { - let _ = child.kill(); - break Err(format!( - "install timeout after {} minutes", - INSTALL_TIMEOUT_SECS / 60 - )); - } - if !stage_emitted_installing && last_emit.elapsed() > Duration::from_secs(1) { - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Installing, - message: "Installer is running…".to_string(), - log_tail: None, - percent: Some(20), - }, - ); - stage_emitted_installing = true; - } - std::thread::sleep(Duration::from_millis(100)); - } - Err(e) => break Err(format!("try_wait failed: {e}")), - } - }; - - // 等流读完(最好再多给 500 ms 让 reader 线程退出)。 - std::thread::sleep(Duration::from_millis(500)); - if let Ok(s) = log_arc.lock() { - log = s.clone(); - } - - let exit_status = match exit_status { - Ok(s) => s, - Err(e) => return finalize_failure(app, "Installer did not finish", &e, log), - }; - - if !exit_status.success() { - return finalize_failure( - app, - "Installer exited with non-zero status", - &format!("exit code: {:?}", exit_status.code()), - log, - ); - } - - // 安装成功 → 重新探测版本。 - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::StartingDaemon, - message: "Installer finished. Detecting installed driver…".to_string(), - log_tail: None, - percent: Some(85), - }, - ); - - let detection = detect(); - let installed_version = detection.version.clone(); - - let daemon_started = start_daemon(app).is_ok(); - - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Completed, - message: if daemon_started { - "CUA driver installed and daemon started.".to_string() - } else { - "CUA driver installed. Daemon will be started on first use.".to_string() - }, - log_tail: Some(truncate_log(&log, 4 * 1024)), - percent: Some(100), - }, - ); - - CuaInstallResult { - success: true, - log: truncate_log(&log, 64 * 1024), - installed_version, - daemon_started, - error: None, - } -} - -fn percent_for(elapsed: std::time::Duration) -> u32 { - // 简单线性插值 0..90%(剩下 10% 给 daemon start)。30 分钟硬上限, - // 但大部分安装 < 1 分钟;这里给前 5 分钟跑到 90% 足够。 - let secs = elapsed.as_secs(); - if secs < 5 { - 5 + (secs as u32 * 3) // 5..20% - } else if secs < 30 { - 20 + ((secs - 5) as u32) // 20..45% - } else if secs < 90 { - 45 + ((secs - 30) as u32 / 2) // 45..75% - } else { - 75 + ((secs - 90).min(900) as u32 / 30).min(15) // 75..90% - } -} - -fn push_line(buf: &mut String, line: &str) { - if buf.len() + line.len() + 1 > 64 * 1024 { - // 环形裁剪:丢前一半,避免无限增长。 - let drop = buf.len() / 2; - if let Some(idx) = buf[drop..].find('\n') { - buf.drain(..drop + idx + 1); - } else { - buf.clear(); - } - } - buf.push_str(line); - buf.push('\n'); -} - -fn finalize_failure( - app: &AppHandle, - title: &str, - detail: &str, - log: String, -) -> CuaInstallResult { - let error = format!("{title}: {detail}"); - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::Failed, - message: error.clone(), - log_tail: Some(truncate_log(&log, 4 * 1024)), - percent: Some(0), - }, - ); - CuaInstallResult { - success: false, - log: truncate_log(&log, 64 * 1024), - installed_version: None, - daemon_started: false, - error: Some(error), - } -} - -/// 启动 daemon。macOS: `open -n -g -a CuaDriver --args serve`; -/// Linux: `cua-driver serve &`(一次性 spawn)。Windows: 暂 no-op(依赖 -/// 安装器的 `autostart kick`)。 -pub fn start_daemon(app: &AppHandle) -> Result { - #[cfg(target_os = "macos")] - { - let status = Command::new("/usr/bin/open") - .args(["-n", "-g", "-a", "CuaDriver", "--args", "serve"]) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status() - .map_err(|e| format!("open failed: {e}"))?; - if !status.success() { - return Err(format!( - "open exited with status {:?}; is CuaDriver.app installed at /Applications?", - status.code() - )); - } - let _ = app.emit( - INSTALL_PROGRESS_EVENT, - InstallerProgressEvent { - stage: InstallerStage::StartingDaemon, - message: "Started CuaDriver.app (background).".to_string(), - log_tail: None, - percent: Some(95), - }, - ); - return Ok(true); - } - #[cfg(target_os = "linux")] - { - let Some(bin) = find_cua_driver() else { - return Err("cua-driver not found in PATH".to_string()); - }; - // 一次性 spawn detach。 - let _ = Command::new(&bin) - .arg("serve") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) - .spawn() - .map_err(|e| format!("cua-driver serve spawn failed: {e}"))?; - return Ok(true); - } - #[cfg(target_os = "windows")] - { - // Windows 安装器最后一步 `cua-driver autostart kick` 会把 daemon - // 注册为开机自启;这里仅探测是否已在运行,不主动启动服务。 - let running = is_daemon_running(); - if !running { - return Err( - "Windows: daemon is managed by 'cua-driver autostart kick'. \ - Run it manually if the driver is not running." - .to_string(), - ); - } - return Ok(running); - } - #[allow(unreachable_code)] - { - Err("daemon start not implemented for this platform".to_string()) - } -} - -/// 调用 `cua-driver check-update` 与(可选)`cua-driver update --apply`。 -/// `apply` 为 false 时只检查不更新。 -pub fn update(apply: bool) -> CuaUpdateResult { - let Some(bin) = find_cua_driver() else { - return CuaUpdateResult { - update_available: false, - log: String::new(), - new_version: None, - error: Some("cua-driver not installed".to_string()), - }; - }; - - // Step 1: check-update - let check_out = run_with_timeout( - { - let mut c = Command::new(&bin); - c.arg("check-update"); - c - }, - Duration::from_secs(15), - ); - let check_output = match &check_out { - Ok(o) => format!( - "$ cua-driver check-update\n{}{}", - String::from_utf8_lossy(&o.stdout), - String::from_utf8_lossy(&o.stderr) - ), - Err(e) => format!("$ cua-driver check-update\n"), - }; - let update_available = match &check_out { - Ok(o) => o.status.success() && parse_update_available(&o.stdout), - Err(_) => false, - }; - - if !apply || !update_available { - return CuaUpdateResult { - update_available, - log: check_output, - new_version: None, - error: None, - }; - } - - // Step 2: update --apply - let apply_out = run_with_timeout( - { - let mut c = Command::new(&bin); - c.args(["update", "--apply"]); - c - }, - Duration::from_secs(INSTALL_TIMEOUT_SECS), - ); - let apply_output = match &apply_out { - Ok(o) => format!( - "\n$ cua-driver update --apply\n{}{}", - String::from_utf8_lossy(&o.stdout), - String::from_utf8_lossy(&o.stderr) - ), - Err(e) => format!("\n$ cua-driver update --apply\n"), - }; - let combined = format!("{check_output}{apply_output}"); - let new_version = if apply_out - .as_ref() - .map(|o| o.status.success()) - .unwrap_or(false) - { - detect().version - } else { - None - }; - let error = match &apply_out { - Err(e) => Some(e.clone()), - Ok(o) if !o.status.success() => Some(format!("exit code: {:?}", o.status.code())), - _ => None, - }; - CuaUpdateResult { - update_available, - log: combined, - new_version, - error, - } -} - -fn parse_update_available(stdout: &[u8]) -> bool { - let s = String::from_utf8_lossy(stdout).to_lowercase(); - // 官方脚本常用 "update available" / "new version" 字符串。 - s.contains("update available") - || s.contains("new version") - || s.contains("a new version is available") - || s.contains("outdated") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_version_handles_common_formats() { - assert_eq!(parse_version("cua-driver 0.3.1"), Some("0.3.1".into())); - assert_eq!(parse_version("v0.3.1 (commit abc)"), Some("0.3.1".into())); - assert_eq!(parse_version("0.10.2"), Some("0.10.2".into())); - assert_eq!(parse_version(""), None); - assert_eq!(parse_version("nothing here"), None); - } - - #[test] - fn platform_label_matches_target() { - let label = platform_label(); - if cfg!(target_os = "macos") { - assert_eq!(label, "macos"); - } else if cfg!(target_os = "windows") { - assert_eq!(label, "windows"); - } else { - assert_eq!(label, "linux"); - } - } - - #[test] - fn build_install_command_always_succeeds_on_supported_platform() { - let cmd = build_install_command(); - if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) { - let cmd = cmd.expect("supported platform"); - assert!(!cmd.program.is_empty()); - assert!(!cmd.args.is_empty()); - } else { - assert!(cmd.is_err()); - } - } - - #[test] - fn truncate_log_keeps_tail() { - let s = "a".repeat(2000); - let t = truncate_log(&s, 100); - assert!(t.len() <= 200); - assert!(t.contains("a")); - } - - #[test] - fn parse_update_available_keywords() { - assert!(parse_update_available(b"update available: 0.4.0")); - assert!(parse_update_available(b"new version: 0.4.0")); - assert!(parse_update_available(b"a new version is available")); - assert!(parse_update_available(b"outdated")); - assert!(!parse_update_available(b"you are up to date")); - } - - #[test] - fn push_line_keeps_recent_lines() { - let mut buf = String::new(); - for i in 0..5000 { - push_line(&mut buf, &format!("line {i} with some content")); - } - // 容量上限 64KB;远小于 5000 * 30 = 150KB。buf 内部已环形裁剪。 - assert!(buf.len() <= 64 * 1024 + 100, "buf {} > 64KB", buf.len()); - assert!(buf.contains("line 4999")); - } -} diff --git a/crates/agent-gui/src-tauri/src/services/cua/mod.rs b/crates/agent-gui/src-tauri/src/services/cua/mod.rs deleted file mode 100644 index eab2f29ee..000000000 --- a/crates/agent-gui/src-tauri/src/services/cua/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! CUA (Computer Use Agent) 服务层:把桌面 / 屏幕 / 输入抽象为一组结构化操作, -//! 暴露给 Tauri Command,再由前端 MCP 工具层注入给 Agent。 -//! -//! 范围:窗口枚举 + 截屏 + 点击 + 输入 + 按键 + 滚动 + 拖拽 + 焦点。 -//! 平台:跨 macOS / Windows / Linux(cua-driver 提供)。 -//! -//! 设计原则: -//! - **KISS**:所有 OS 行为走 `cua-driver mcp --direct` 子进程 JSON-RPC, -//! 不在 Rust 内复刻 AX / 像素路径,体积与维护代价最小。 -//! - **DRY**:状态、审计日志、配额放同一个 `CuaStore`;所有命令统一过 -//! `enforce + check_allowed + audit` 守卫。 -//! - **审计 + 策略边界在后端**:sandboxOffline / 白名单 / trustMode 都 -//! 在 `CuaStore::enforce` 里统一决策,前端只能改 `CuaRuntimeConfig`、 -//! 不能绕过审计。 - -pub mod cua_client; -pub mod error; -pub mod installer; -pub mod store; - -pub use cua_client::CuaClient; -pub use error::CuaError; -#[allow(unused_imports)] -pub use installer::{ - build_install_command, build_install_preview, detect as detect_driver, install as install_driver, - is_daemon_running, start_daemon as start_driver_daemon, update as update_driver, - CuaDriverDetection, CuaInstallResult, CuaUpdateResult, InstallCommand, InstallPreview, - InstallerProgressEvent, InstallerStage, -}; -pub use store::{CuaAuditEntry, CuaRuntimeConfig, CuaStore, CuaStoreSnapshot}; \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/cua/store.rs b/crates/agent-gui/src-tauri/src/services/cua/store.rs deleted file mode 100644 index 494cc0ce4..000000000 --- a/crates/agent-gui/src-tauri/src/services/cua/store.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! CUA 运行时状态:启用开关、允许的应用名单(白名单 / 黑名单语义)、审计日志。 -//! -//! 设计:纯内存 `Arc>`,启动时由 `lib::run` 装载默认配置。 -//! 持久化走前端 localStorage(与 STT / 托盘偏好同源),保持与项目里 -//! 「桌面端非关键配置走 localStorage,敏感配置走 SQLite」的约定一致。 -//! 审计日志不进 SQLite(成本/收益不匹配),仅在内存保留最近 100 条供 UI -//! 显示「最近操作」。 - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::sync::Mutex; - -use super::error::CuaError; - -/// 编译期确定的平台标签。直接给字符串常量,避免在 trait object 上 -/// 调用 `Self::platform_label()` 这种 Sized-bound 关联函数。 -const fn platform_label_const() -> &'static str { - #[cfg(target_os = "macos")] - { - "macos" - } - #[cfg(target_os = "windows")] - { - "windows" - } - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] - { - "linux" - } -} - -/// 把 `DateTime` 用 RFC3339 字符串承载,避免引入 chrono 的 `serde` feature。 -mod chrono_iso { - use chrono::{DateTime, Utc}; - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(value: &DateTime, s: S) -> Result { - s.serialize_str(&value.to_rfc3339()) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let s = String::deserialize(d)?; - DateTime::parse_from_rfc3339(&s) - .map(|dt| dt.with_timezone(&Utc)) - .map_err(serde::de::Error::custom) - } -} - -const AUDIT_LOG_MAX: usize = 100; - -/// 持久化的 CUA 配置。前端写过来什么就原样回写(schema 由前端 TS 类型保证)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaRuntimeConfig { - /// 总开关。`false` 时所有 CUA 命令返回 `Disabled` 错误,前端工具注册表也跳过。 - pub enabled: bool, - /// 白名单:仅当非空时生效;非空时仅允许列表内的 owner 名通过 `focus_window` - /// / 输入 / 点击(截屏 / 列举不走名单以便 Agent 自检)。 - /// owner 匹配用 `equals_ignore_ascii_case`,避免大小写差异漏判。 - #[serde(default)] - pub allowed_owners: Vec, - /// 最大审计日志条数(默认 100;<=0 视作关闭)。 - #[serde(default = "default_audit_limit")] - pub audit_log_limit: usize, - /// 「信任模式」开关:开启后 `group:cua` 工具在前端不再弹审批(用户 - /// 自担风险)。关闭时由 `toolPolicy.ts` 走默认 `ask`。 - /// CUA-reviewer 要求:默认逐次审批,显式 trust 才免审。 - #[serde(default)] - pub trust_mode: bool, - /// 当前命令安全模式是否 sandboxOffline。CUA 进程会绕过 sandbox 边界 - /// 起子进程,因此 sandboxOffline 下必须强制 deny 全部 `cua_*` 工具。 - /// 由 Tauri 侧 `cua_set_config` 在写入时按 `system.commandSafetyMode` - /// 自动设置;前端仅展示。 - #[serde(default)] - pub sandbox_offline: bool, -} - -fn default_audit_limit() -> usize { - AUDIT_LOG_MAX -} - -impl Default for CuaRuntimeConfig { - fn default() -> Self { - Self { - enabled: false, - allowed_owners: Vec::new(), - audit_log_limit: AUDIT_LOG_MAX, - trust_mode: false, - sandbox_offline: false, - } - } -} - -/// 单条审计记录。`ok=false` 时附 `error`(结构化 CuaError, -/// 由前端按 locale 翻译;CUA-006)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaAuditEntry { - #[serde(with = "chrono_iso")] - pub timestamp: DateTime, - pub operation: String, - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub detail: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CuaStoreSnapshot { - pub config: CuaRuntimeConfig, - pub platform: &'static str, - pub available: bool, - /// 与 `config.sandboxOffline` 同字段;显式平铺便于 UI 渲染沙箱 - /// 离线指示器时不必走整份 config(CUA-reviewer 要求)。 - #[serde(default)] - pub sandbox_offline: bool, - pub recent: Vec, -} - -#[derive(Debug)] -pub struct CuaStore { - inner: Mutex, -} - -#[derive(Debug)] -struct Inner { - config: CuaRuntimeConfig, - recent: Vec, -} - -impl CuaStore { - pub fn new(config: CuaRuntimeConfig) -> Self { - Self { - inner: Mutex::new(Inner { - config, - recent: Vec::new(), - }), - } - } - - pub fn snapshot(&self) -> CuaStoreSnapshot { - let inner = self.inner.lock().expect("cua store poisoned"); - CuaStoreSnapshot { - config: inner.config.clone(), - platform: platform_label_const(), - available: inner.config.enabled, - sandbox_offline: inner.config.sandbox_offline, - recent: inner.recent.clone(), - } - } - - pub fn replace_config(&self, config: CuaRuntimeConfig) { - let mut inner = self.inner.lock().expect("cua store poisoned"); - inner.config = config; - } - - /// 把 store 内的 sandbox_offline 同步到传入值。`cua_status` 每次 - /// 取快照前调用,保证 UI 看到的是当前命令安全模式的最新真值, - /// 而不是上一次 `cua_set_config` 时写入的陈旧值。 - pub fn refresh_sandbox_offline(&self, sandbox_offline: bool) { - let mut inner = self.inner.lock().expect("cua store poisoned"); - inner.config.sandbox_offline = sandbox_offline; - } - - /// 命令执行前调用:返回 ok 或对应的结构化拒绝原因。 - /// 优先级(由粗到细,任一命中即返回): - /// 1. 命令安全模式 = sandboxOffline → `CuaError::sandbox_offline()` - /// (防止 CUA 子进程突破 sandbox 强制断网)。 - /// 2. 未启用 → `CuaError::disabled()` - /// 3. 有白名单且 owner 不在表里 → `CuaError::denied_by_allowlist(...)` - pub fn enforce( - &self, - op: &str, - owner: Option<&str>, - ) -> Result<(), CuaError> { - let inner = self.inner.lock().expect("cua store poisoned"); - if inner.config.sandbox_offline { - return Err(CuaError::sandbox_offline()); - } - if !inner.config.enabled { - return Err(CuaError::disabled()); - } - let allowed_owners = &inner.config.allowed_owners; - if !allowed_owners.is_empty() { - let target = match owner { - Some(o) => o.trim(), - None => "", // 操作没指定 owner,但名单非空 → 拒绝 - }; - if target.is_empty() - || !allowed_owners - .iter() - .any(|a| a.eq_ignore_ascii_case(target)) - { - let _ = op; - return Err(CuaError::denied_by_allowlist(target, allowed_owners)); - } - } - Ok(()) - } - - /// 把审计条目写入内存。若用户在前端把 `audit_log_limit` 设为 0 - /// (即关闭审计),本方法跳过 push——尊重 UI 「0 = disable」 - /// 文案(CUA-005 修复)。`audit_log_limit > 1000` 截到 1000。 - pub fn record(&self, entry: CuaAuditEntry) { - let mut inner = self.inner.lock().expect("cua store poisoned"); - let limit = inner.config.audit_log_limit.min(1000); - if limit == 0 { - // 用户禁用审计:直接退出,不写入任何条目。 - return; - } - inner.recent.push(entry); - if inner.recent.len() > limit { - let drop_n = inner.recent.len() - limit; - inner.recent.drain(0..drop_n); - } - } - - pub fn clear_audit(&self) { - let mut inner = self.inner.lock().expect("cua store poisoned"); - inner.recent.clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fake_config(audit_log_limit: usize, enabled: bool) -> CuaRuntimeConfig { - CuaRuntimeConfig { - enabled, - allowed_owners: Vec::new(), - audit_log_limit, - trust_mode: false, - sandbox_offline: false, - } - } - - fn fake_entry(op: &str) -> CuaAuditEntry { - CuaAuditEntry { - timestamp: Utc::now(), - operation: op.to_string(), - ok: true, - error: None, - detail: None, - } - } - - /// CUA-005:当 audit_log_limit = 0(用户在 UI 中点 "0 = disable"), - /// store 应完全跳过 push,确保 recent 始终为空。后续改成 50 时正常 - /// 记录并裁剪到 50 条。 - #[test] - fn audit_log_zero_means_disabled() { - let cfg_zero = fake_config(0, false); - let store = CuaStore::new(cfg_zero); - for i in 0..5 { - store.record(fake_entry(&format!("op{i}"))); - } - assert!( - store.snapshot().recent.is_empty(), - "audit_log_limit=0 must drop every entry" - ); - - let mut next_cfg = store.snapshot().config.clone(); - next_cfg.audit_log_limit = 50; - next_cfg.enabled = true; - store.replace_config(next_cfg); - - for i in 0..3 { - store.record(fake_entry(&format!("op{i}"))); - } - let snap = store.snapshot(); - assert_eq!(snap.recent.len(), 3); - assert_eq!(snap.recent[0].operation, "op0"); - assert_eq!(snap.recent[2].operation, "op2"); - } - - /// CUA-005 附带:limit=50 时正常裁剪到 50 条。 - #[test] - fn audit_log_respects_positive_limit() { - let store = CuaStore::new(fake_config(50, false)); - for i in 0..80 { - store.record(fake_entry(&format!("op{i}"))); - } - let snap = store.snapshot(); - assert_eq!(snap.recent.len(), 50); - // 应丢弃最早的 30 条,剩下 op30..op79。 - assert_eq!(snap.recent[0].operation, "op30"); - assert_eq!(snap.recent[49].operation, "op79"); - } - - /// CUA-005 附带:limit 巨大值(>1000)也会被夹到 1000 上限。 - #[test] - fn audit_log_caps_huge_limit_at_1000() { - let store = CuaStore::new(fake_config(usize::MAX, false)); - for i in 0..1500 { - store.record(fake_entry(&format!("op{i}"))); - } - let snap = store.snapshot(); - assert_eq!(snap.recent.len(), 1000); - } - - /// CUA-006:enforce 返回的禁用错误是结构化 CuaError(kind 稳定、 - /// message 是英文),不再泄露中文硬编码字符串。 - #[test] - fn enforce_returns_structured_error_when_disabled() { - let store = CuaStore::new(fake_config(100, false)); - let err = store - .enforce("list_windows", None) - .expect_err("must reject when disabled"); - assert_eq!(err.kind, "cua.errors.disabled"); - assert!(err.message.contains("CUA"), "fallback message usable: {}", err.message); - assert!(!err.message.contains('你'), "no Chinese leaks: {}", err.message); - } - - /// CUA-006:denied_by_allowlist 携带 target + allowed 参数。 - #[test] - fn enforce_returns_structured_denied_by_allowlist() { - let mut cfg = fake_config(100, true); - cfg.allowed_owners = vec!["Finder".into()]; - let store = CuaStore::new(cfg); - let err = store - .enforce("click", Some("Safari")) - .expect_err("must reject Safari"); - assert_eq!(err.kind, "cua.errors.deniedByAllowlist"); - assert_eq!( - err.params - .get("target") - .and_then(|v| v.as_str()) - .map(str::to_owned), - Some("Safari".into()) - ); - assert!(!err.message.contains('你')); - } - - /// CUA-reviewer 安全门控:命令安全模式 = sandboxOffline 时, - /// 即使 enabled=true + 白名单匹配,也必须强制 deny 全部 cua_*, - /// 防止 CUA 子进程突破 sandbox 的强制断网。 - #[test] - fn enforce_denies_when_sandbox_offline() { - let mut cfg = fake_config(100, true); - cfg.allowed_owners = vec!["Finder".into()]; - cfg.sandbox_offline = true; - let store = CuaStore::new(cfg); - let err = store - .enforce("click", Some("Finder")) - .expect_err("must reject under sandboxOffline"); - assert_eq!(err.kind, "cua.errors.sandboxOffline"); - // 即使 enabled 关闭也应该 deny,验证优先级:sandboxOffline > disabled。 - let mut cfg2 = fake_config(100, false); - cfg2.sandbox_offline = true; - let store2 = CuaStore::new(cfg2); - assert_eq!( - store2.enforce("list_windows", None).unwrap_err().kind, - "cua.errors.sandboxOffline" - ); - // 关掉 sandboxOffline 后回到 enabled=true 走通。 - let mut cfg3 = fake_config(100, true); - cfg3.allowed_owners = vec!["Finder".into()]; - let store3 = CuaStore::new(cfg3); - store3 - .enforce("click", Some("Finder")) - .expect("must allow when sandboxOffline=false"); - } -} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/services/mod.rs b/crates/agent-gui/src-tauri/src/services/mod.rs index 17cd52128..bb39f9755 100644 --- a/crates/agent-gui/src-tauri/src/services/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/mod.rs @@ -1,6 +1,5 @@ pub mod automation; pub mod chat_run_ledger; -pub mod cua; pub mod gateway; pub mod gateway_bridge; pub mod memory; diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index e14ea0549..de9f9cf19 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -27,7 +27,6 @@ import { AppBootShell } from "./components/app/AppBootShell"; import { useNativeInputContextMenu } from "./components/input-context-menu/NativeInputContextMenu"; import { WindowsTitleBar } from "./components/WindowsTitleBar"; import { useAppUpdateController } from "./lib/appUpdates"; -import { desktopCuaService } from "./lib/cua/cuaService"; import { type AppSettings, getDefaultSettings, @@ -811,7 +810,6 @@ export default function App() { sttSettingsService={desktopSttSettingsService} onSttProviderChange={setSttProviderOverride} reloadSettings={reloadPersistedSettings} - cuaService={desktopCuaService} /> diff --git a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx index 2fe899f07..043ba2d4b 100644 --- a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx @@ -1,7 +1,6 @@ import { Archive, Info, Keyboard } from "@liveagent/ui/components/IconSet"; import type { SettingsSectionDefinition, UiExtensionSlots } from "@liveagent/ui/contracts/registry"; import { isMacOsTauri, MacOsTitleBarSpacer } from "../components/MacOsTitleBarSpacer"; -import { desktopCuaService } from "../lib/cua/cuaService"; import { AboutSection } from "../pages/settings/AboutSection"; import { BackupSyncSection } from "../pages/settings/BackupSyncSection"; import { GlobalShortcutsSection } from "../pages/settings/GlobalShortcutsSection"; @@ -12,7 +11,6 @@ export function createSettingsExtension(props: SettingsPageProps): { iconClassName: string; slots: UiExtensionSlots; sections: SettingsSectionDefinition[]; - cuaService: SettingsPageProps["cuaService"]; } { const { settings, setSettings, appUpdate, reloadSettings } = props; return { @@ -61,6 +59,5 @@ export function createSettingsExtension(props: SettingsPageProps): { ), }, ], - cuaService: props.cuaService ?? desktopCuaService, }; } diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 21e2da293..d46d1aa46 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -249,104 +249,6 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.remoteWebTunnelsHint": "开启后,已登录 WebUI 可为 localhost 或 IP 地址 HTTP 服务创建和关闭临时访问链接。", "settings.remoteHeartbeatHint": "本地 Agent 向 Gateway 上报存活状态的间隔", - "settings.navCua": "CUA 驱动", - "settings.cua.title": "CUA(Computer Use Agent)驱动", - "settings.cua.enable": "启用 CUA", - "settings.cua.enableDesc": - "开启后,Agent 可通过 cua_* 工具操作电脑界面(窗口枚举、截屏、点击、输入等)。", - "settings.cua.enableDescUnsupported": "当前平台尚未实现 CUA 驱动;启用后所有操作都会失败。", - "settings.cua.platform": "驱动平台", - "settings.cua.platformDesc": "由 Rust 端按编译目标决定(macOS / Windows / Linux)。", - "settings.cua.platformNotes.macos.accessibility": - "辅助功能(Accessibility)— CuaDriver.app 通过 AX API 操控鼠标 / 键盘", - "settings.cua.platformNotes.macos.screenRecording": - "屏幕录制(Screen Recording)— CuaDriver.app 截屏", - "settings.cua.platformNotes.macos.dragAndDrop": "拖拽由 cua-driver 内置支持,无需第三方工具", - "settings.cua.platformNotes.unsupported": "驱动尚未实现;启用后所有 CUA 操作都会失败", - "settings.cua.allowedOwners": "允许的目标应用", - "settings.cua.allowedOwnersDesc": - "白名单:仅这些 owner(应用名)的输入 / 焦点 / 点击会被放行。空 = 不限制。多个 owner 用英文逗号或换行分隔。", - "settings.cua.allowedOwnersHint": "例:Finder, Safari, Terminal", - "settings.cua.trustModeGroup": "信任模式", - "settings.cua.trustMode": "信任 CUA 工具", - "settings.cua.trustModeDesc": - "关闭时所有 cua_* 工具每次调用都会弹审批(默认)。开启后不再逐次弹窗,但 CUA 仍受后端白名单与审计日志约束。", - "settings.cua.trustModeHint": - "CUA 会接管鼠标键盘,启用后相当于把控制权交给 Agent;截图等敏感操作也会被捕获。建议日常保持关闭,仅在批量自动化场景下临时开启。", - "settings.cua.sandboxOfflineBanner": - "当前命令安全模式是 sandboxOffline(断网沙箱)。CUA 工具已全部禁用,避免 CUA 子进程绕过强制断网。切换命令安全模式后可重新启用。", - "settings.cua.enableDescSandboxOffline": - "当前命令安全模式是 sandboxOffline;CUA 已禁用,无法开启。", - "settings.cua.audit": "审计日志", - "settings.cua.auditLimit": "审计日志上限", - "settings.cua.auditLimitDesc": "内存中保留最近 N 条 CUA 操作(0 = 关闭)。", - "settings.cua.auditCurrent": "当前 {count} 条", - "settings.cua.auditEmpty": "暂无 CUA 操作记录。", - "settings.cua.auditClear": "清空日志", - "settings.cua.saving": "正在同步 CUA 配置…", - "settings.cua.errors.fetch": "无法读取 CUA 状态", - "settings.cua.errors.save": "保存 CUA 配置失败", - "cua.errors.sandboxOffline": - "当前命令安全模式是 sandboxOffline,CUA 工具已全部禁用。请把命令安全模式切换为 ask / auto / sandbox 后再使用 CUA。", - // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 - // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, - // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 - // 译成 TCC 失效」的掩盖行为。 - "cua.errors.ownerNotFound": - "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", - "cua.errors.ownerNotFoundDetail": - "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", - // CUA-100: driver installer (zh-CN) - "cua.errors.installer.networkUnavailable": - "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", - "cua.errors.installer.curlFailed": "CUA 驱动安装脚本下载失败:{detail}", - "cua.errors.installer.signatureInvalid": "CUA 驱动安装脚本签名校验失败:{detail}", - "cua.errors.installer.permissionDenied": "CUA 驱动安装器需要更高权限:{detail}", - "cua.errors.installer.unsupportedPlatform": "CUA 驱动安装器在当前平台({platform})不可用。", - "cua.errors.installer.timeout": - "CUA 驱动安装超过 {minutes} 分钟仍未完成,下载可能已卡住,请重试。", - "settings.cua.installer.title": "CUA Driver", - "settings.cua.installer.description": - "LiveAgent 自身不内置 cua-driver;它由 cua.ai 官方脚本下载安装。安装脚本从 cua.ai 拉取、不需要 sudo(Linux 桌面端除外)。", - "settings.cua.installer.install": "下载并安装 cua-driver", - "settings.cua.installer.restartDaemon": "启动 daemon", - "settings.cua.installer.checkUpdate": "检查更新", - "settings.cua.installer.applyUpdate": "应用更新", - "settings.cua.installer.refresh": "重新检测", - "settings.cua.installer.dismiss": "关闭", - "settings.cua.installer.statusUnknown": "状态未知", - "settings.cua.installer.statusNotInstalled": "未安装", - "settings.cua.installer.statusInstalledNotRunning": "已安装,daemon 未运行", - "settings.cua.installer.statusInstalledRunning": "已安装,daemon 正在运行", - "settings.cua.installer.versionLabel": "v{version}", - "settings.cua.installer.versionUnknown": "已安装(版本未知)", - "settings.cua.installer.daemonStarted": "daemon 已启动", - "settings.cua.installer.daemonNotStarted": "daemon 未启动(首次使用时再启动)", - "settings.cua.installer.installSuccess": "安装成功:{version};{daemon}", - "settings.cua.installer.linuxMissingDeps": - "Linux 桌面端需要以下系统包,请先在终端执行 `sudo apt install {packages}` 后再继续安装。", - "settings.cua.installer.macosPermissionsTitle": "macOS 权限提示", - "settings.cua.installer.macosPermissionsBody": - "请到「系统设置 → 隐私与安全」授予 CuaDriver:\n· 辅助功能(Accessibility)\n· 屏幕录制(Screen Recording)\n完成后把 LiveAgent 切到前台即可使用。", - "settings.cua.installer.macosOpenSettings": "打开系统设置", - "settings.cua.installer.logLabel": "安装日志", - "settings.cua.installer.stage.starting": "准备安装…", - "settings.cua.installer.stage.downloading": "下载官方安装脚本…", - "settings.cua.installer.stage.installing": "正在安装…", - "settings.cua.installer.stage.startingDaemon": "启动 daemon…", - "settings.cua.installer.stage.checkingUpdate": "检查更新…", - "settings.cua.installer.stage.applyingUpdate": "应用更新…", - "settings.cua.installer.errors.invoke": "无法调用后端 CUA 安装器命令。", - "settings.cua.installer.errors.unknown": "未知错误", - "settings.cua.installer.errors.exception": "异常:{message}", - "settings.cua.installer.errors.daemonStart": - "启动 daemon 失败。请检查 cua-driver 是否已正确安装。", - // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染。 - "settings.cua.installer.update.available": - "检测到新版本,请点击「检查更新」旁的 apply 按钮应用。\n\n{log}", - "settings.cua.installer.update.upToDate": "已是最新版本。\n\n{log}", - "settings.cua.installer.update.applied": "更新成功:{version}。\n\n{log}", - "settings.cua.installer.update.failed": "更新失败。\n\n{log}", }, "en-US": { "app.windowPinned": "Pinned", @@ -606,111 +508,6 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.remoteWebTunnelsHint": "Allow authenticated WebUI clients to create and close temporary links for localhost or IP-address HTTP services.", "settings.remoteHeartbeatHint": "How often the local Agent reports liveness to the Gateway", - "settings.navCua": "CUA Driver", - "settings.cua.title": "CUA (Computer Use Agent) Driver", - "settings.cua.enable": "Enable CUA", - "settings.cua.enableDesc": - "When enabled, the Agent can operate the desktop through cua_* tools (window listing, screenshot, click, type, etc.).", - "settings.cua.enableDescUnsupported": - "CUA is not implemented for this platform yet; turning it on will fail every operation.", - "settings.cua.platform": "Driver platform", - "settings.cua.platformDesc": - "Selected at build time by the Rust target (macOS / Windows / Linux).", - "settings.cua.platformNotes.macos.accessibility": - "Accessibility — CuaDriver.app drives mouse / keyboard via the AX API", - "settings.cua.platformNotes.macos.screenRecording": - "Screen Recording — CuaDriver.app captures screenshots", - "settings.cua.platformNotes.macos.dragAndDrop": - "Drag is built into cua-driver; no third-party tool required", - "settings.cua.platformNotes.unsupported": - "Driver is not implemented yet; every CUA operation will fail when enabled", - "settings.cua.allowedOwners": "Allowed target apps", - "settings.cua.allowedOwnersDesc": - "Allowlist: only these owner (app) names are allowed to receive typed / focused / clicked input. Empty = no restriction. Separate multiple owners with commas or newlines.", - "settings.cua.allowedOwnersHint": "e.g. Finder, Safari, Terminal", - "settings.cua.trustModeGroup": "Trust mode", - "settings.cua.trustMode": "Trust CUA tools", - "settings.cua.trustModeDesc": - "Off (default): every cua_* tool call prompts for approval. On: prompts are skipped, but the backend allowlist and audit log still apply.", - "settings.cua.trustModeHint": - "CUA takes over the mouse and keyboard; screenshots are also captured. Leave this off by default and only enable it for short batch automation runs.", - "settings.cua.sandboxOfflineBanner": - "The current command safety mode is sandboxOffline. All CUA tools are disabled so the cua-driver subprocess cannot bypass the offline sandbox. Switch the command safety mode to re-enable CUA.", - "settings.cua.enableDescSandboxOffline": - "The current command safety mode is sandboxOffline; CUA is disabled and cannot be turned on here.", - "settings.cua.audit": "Audit log", - "settings.cua.auditLimit": "Audit log limit", - "settings.cua.auditLimitDesc": "Keep the most recent N CUA operations in memory (0 = disable).", - "settings.cua.auditCurrent": "{count} entries", - "settings.cua.auditEmpty": "No CUA operations recorded yet.", - "settings.cua.auditClear": "Clear log", - "settings.cua.saving": "Syncing CUA config…", - "settings.cua.errors.fetch": "Failed to read CUA status", - "settings.cua.errors.save": "Failed to save CUA config", - "cua.errors.sandboxOffline": - "CUA tools are disabled while the command safety mode is sandboxOffline. Switch the command safety mode to ask / auto / sandbox to use CUA.", - // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 - // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, - // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 - // 译成 TCC 失效」的掩盖行为。 - "cua.errors.ownerNotFound": - "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", - "cua.errors.ownerNotFoundDetail": - "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", - // CUA-100: driver installer (en-US) - "cua.errors.installer.networkUnavailable": - "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", - "cua.errors.installer.curlFailed": "CUA driver installer download failed: {detail}", - "cua.errors.installer.signatureInvalid": - "CUA driver installer signature check failed: {detail}", - "cua.errors.installer.permissionDenied": - "CUA driver installer needs elevated permission: {detail}", - "cua.errors.installer.unsupportedPlatform": - "CUA driver installer is not available on {platform}.", - "cua.errors.installer.timeout": - "CUA driver installer did not finish within {minutes} minutes; the download may have stalled, please retry.", - "settings.cua.installer.title": "CUA Driver", - "settings.cua.installer.description": - "LiveAgent does not bundle cua-driver; it is downloaded by the official cua.ai installer script. The script pulls from cua.ai and does not require sudo (except for Linux desktop dependencies).", - "settings.cua.installer.install": "Download & install cua-driver", - "settings.cua.installer.restartDaemon": "Start daemon", - "settings.cua.installer.checkUpdate": "Check for update", - "settings.cua.installer.applyUpdate": "Apply update", - "settings.cua.installer.refresh": "Re-detect", - "settings.cua.installer.dismiss": "Dismiss", - "settings.cua.installer.statusUnknown": "Status unknown", - "settings.cua.installer.statusNotInstalled": "Not installed", - "settings.cua.installer.statusInstalledNotRunning": "Installed, daemon not running", - "settings.cua.installer.statusInstalledRunning": "Installed, daemon running", - "settings.cua.installer.versionLabel": "v{version}", - "settings.cua.installer.versionUnknown": "Installed (version unknown)", - "settings.cua.installer.daemonStarted": "daemon started", - "settings.cua.installer.daemonNotStarted": "daemon not started (will start on first use)", - "settings.cua.installer.installSuccess": "Installed: {version}; {daemon}", - "settings.cua.installer.linuxMissingDeps": - "Linux desktop requires these system packages. Run `sudo apt install {packages}` in a terminal before continuing.", - "settings.cua.installer.macosPermissionsTitle": "macOS permissions", - "settings.cua.installer.macosPermissionsBody": - "Open System Settings → Privacy & Security and grant CuaDriver:\n• Accessibility\n• Screen Recording\nThen bring LiveAgent to the front to use it.", - "settings.cua.installer.macosOpenSettings": "Open System Settings", - "settings.cua.installer.logLabel": "Install log", - "settings.cua.installer.stage.starting": "Preparing…", - "settings.cua.installer.stage.downloading": "Downloading official installer…", - "settings.cua.installer.stage.installing": "Installing…", - "settings.cua.installer.stage.startingDaemon": "Starting daemon…", - "settings.cua.installer.stage.checkingUpdate": "Checking for update…", - "settings.cua.installer.stage.applyingUpdate": "Applying update…", - "settings.cua.installer.errors.invoke": "Failed to invoke the backend CUA installer command.", - "settings.cua.installer.errors.unknown": "Unknown error", - "settings.cua.installer.errors.exception": "Exception: {message}", - "settings.cua.installer.errors.daemonStart": - "Failed to start daemon. Verify that cua-driver is installed correctly.", - // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染。 - "settings.cua.installer.update.available": - 'A new version is available. Click the apply button next to "Check for update" to install.\n\n{log}', - "settings.cua.installer.update.upToDate": "Already up to date.\n\n{log}", - "settings.cua.installer.update.applied": "Update applied: {version}.\n\n{log}", - "settings.cua.installer.update.failed": "Update failed.\n\n{log}", }, }; diff --git a/crates/agent-gui/src/lib/cua/cuaService.ts b/crates/agent-gui/src/lib/cua/cuaService.ts deleted file mode 100644 index 98027c0a0..000000000 --- a/crates/agent-gui/src/lib/cua/cuaService.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { - CuaDriverDetection, - CuaInstallResult, - CuaUpdateResult, - InstallerProgressEvent, - InstallPreview, -} from "@liveagent/ui/pages/settings/CuaInstaller"; -import type { CuaService, CuaSettings, CuaStatus } from "@liveagent/ui/pages/settings/CuaSection"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; - -/** - * 桌面端到 Rust `cua_*` Tauri Command 的桥。设置面板与 Settings 扩展 - * 都会经这里读 / 写后端 CuaStore;前端 localStorage 是镜像(详见 - * `lib/settings/storage.ts` 的 CUA 持久化段落)。 - * - * 重要:调用方拿到的是 `CuaService`(不耦合 Tauri 细节),settings 面板 - * / 任何后续 CLI 入口都能复用同一接口做自检。 - */ -export const desktopCuaService: CuaService & CuaInstallerService = { - platformLabel: "macos", - async fetchStatus(): Promise { - try { - return await invoke("cua_status"); - } catch (err) { - console.warn("[cua] fetchStatus failed", err); - return null; - } - }, - async setConfig(config: CuaSettings): Promise { - try { - return await invoke("cua_set_config", { config }); - } catch (err) { - console.warn("[cua] setConfig failed", err); - return null; - } - }, - async clearAudit(): Promise { - try { - return await invoke("cua_clear_audit"); - } catch (err) { - console.warn("[cua] clearAudit failed", err); - return null; - } - }, - async detectDriver(): Promise { - try { - return await invoke("cua_driver_detect"); - } catch (err) { - console.warn("[cua] detectDriver failed", err); - return null; - } - }, - async installDriver(): Promise { - try { - return await invoke("cua_driver_install"); - } catch (err) { - console.warn("[cua] installDriver failed", err); - return null; - } - }, - async updateDriver(apply: boolean): Promise { - try { - return await invoke("cua_driver_update", { apply }); - } catch (err) { - console.warn("[cua] updateDriver failed", err); - return null; - } - }, - async startDriverDaemon(): Promise { - try { - const result = await invoke<{ ok: boolean; error?: unknown }>("cua_driver_start_daemon"); - return result.ok; - } catch (err) { - console.warn("[cua] startDriverDaemon failed", err); - return false; - } - }, - async getInstallPreview(): Promise { - try { - return await invoke("cua_driver_install_preview"); - } catch (err) { - console.warn("[cua] getInstallPreview failed", err); - return null; - } - }, - async subscribeProgress(handler: (event: InstallerProgressEvent) => void): Promise<() => void> { - // Tauri 2 的 listen 返回 unlisten 句柄;直接透传给调用方。 - const unlisten = await listen("cua_install_progress", (event) => - handler(event.payload), - ); - return unlisten; - }, -}; - -export type CuaInstallerService = { - detectDriver: () => Promise; - installDriver: () => Promise; - updateDriver: (apply: boolean) => Promise; - startDriverDaemon: () => Promise; - getInstallPreview: () => Promise; - subscribeProgress: (handler: (event: InstallerProgressEvent) => void) => Promise<() => void>; -}; diff --git a/crates/agent-gui/src/lib/settings/storage.ts b/crates/agent-gui/src/lib/settings/storage.ts index 5dbb9ecf3..9d9041822 100644 --- a/crates/agent-gui/src/lib/settings/storage.ts +++ b/crates/agent-gui/src/lib/settings/storage.ts @@ -14,7 +14,6 @@ import { normalizeChatRuntimeControls, normalizeChatTranscriptSettings, normalizeCloseWindowBehavior, - normalizeCuaSettings, normalizeFontFamily, normalizeFontScaleSettings, normalizeRightDockSettings, @@ -54,7 +53,6 @@ type LocalUiSettings = { theme?: unknown; locale?: unknown; closeWindowBehavior?: unknown; - cua?: unknown; }; export type SettingsSaveState = @@ -103,7 +101,6 @@ function readLocalUiSettings(): { theme: Theme; locale: Locale; closeWindowBehavior: CloseWindowBehavior; - cua: AppSettings["cua"]; } { const defaults = getDefaultSettings(); @@ -143,7 +140,6 @@ function readLocalUiSettings(): { theme: defaults.theme, locale: defaults.locale, closeWindowBehavior: defaults.closeWindowBehavior, - cua: defaults.cua, }; } @@ -166,7 +162,6 @@ function readLocalUiSettings(): { closeWindowBehavior: normalizeCloseWindowBehavior( parsed?.closeWindowBehavior ?? defaults.closeWindowBehavior, ), - cua: parsed?.cua ? normalizeCuaSettings(parsed.cua) : defaults.cua, }; } catch { return { @@ -179,7 +174,6 @@ function readLocalUiSettings(): { theme: defaults.theme, locale: defaults.locale, closeWindowBehavior: defaults.closeWindowBehavior, - cua: defaults.cua, }; } } @@ -195,7 +189,6 @@ function writeLocalUiSettings( | "theme" | "locale" | "closeWindowBehavior" - | "cua" >, ) { const payload = { @@ -207,7 +200,6 @@ function writeLocalUiSettings( theme: settings.theme, locale: settings.locale, closeWindowBehavior: settings.closeWindowBehavior, - cua: settings.cua, }; localStorage.setItem(LOCAL_UI_SETTINGS_STORAGE_KEY, JSON.stringify(payload)); } @@ -276,7 +268,6 @@ export async function loadPersistedSettingsWithDefaults(): Promise void | Promise; onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; - /** CUA 总开关;只有 `true` 时才把 cua_* 工具挂进注册表(后端仍会再次 - * 校验 enabled,白名单也是后端权威)。 */ - cuaEnabled?: boolean; - /** 当前 UI locale;cua_* 工具用此把后端结构化错误翻译成用户语言。 - * 不传则由 tool bundle 自己 fallback 到 detectSystemLocale(仅在 Tauri WebView - * 上下文中可用);Web/Cron 等非用户场景保持默认 zh-CN。 */ - getLocale?: () => Locale; }; const resolveHomeDir = () => homeDir(); @@ -297,7 +288,6 @@ async function buildBaseBuiltinToolBundles( }), ] : []), - ...(params.cuaEnabled ? [createCuaTools({ getLocale: params.getLocale })] : []), ]; const enabledServers = selectEnabledMcpServers(params.getMcpSettings()); diff --git a/crates/agent-gui/src/lib/tools/cuaTools.ts b/crates/agent-gui/src/lib/tools/cuaTools.ts deleted file mode 100644 index 162979cfd..000000000 --- a/crates/agent-gui/src/lib/tools/cuaTools.ts +++ /dev/null @@ -1,441 +0,0 @@ -import type { - ImageContent, - TextContent, - Tool, - ToolCall, - ToolResultMessage, -} from "@earendil-works/pi-ai"; -import { DEFAULT_LOCALE, type Locale, t } from "@liveagent/app/i18n/config"; -import { type CuaErrorPayload, formatUnknownCuaError } from "@liveagent/ui/lib/cua/formatCuaError"; -import { invoke } from "@tauri-apps/api/core"; - -import type { BuiltinToolBundle, BuiltinToolMetadata } from "./builtinTypes"; -import { createBuiltinMetadataMap } from "./builtinTypes"; -import { normalizeToolParametersSchema } from "./toolSchema"; - -type CuaWindow = { - windowId: number; - owner: string; - title: string; - bounds?: { x: number; y: number; width: number; height: number }; - focused: boolean; -}; - -type CuaConfig = { - enabled: boolean; - allowedOwners: string[]; - auditLogLimit: number; -}; - -type CuaStatus = { - config: CuaConfig; - platform: string; - available: boolean; - recent: Array<{ - timestamp: string; - operation: string; - ok: boolean; - error?: string; - detail?: unknown; - }>; -}; - -type CuaOpResponse = { ok: boolean; error?: CuaErrorPayload }; -type CuaScreenshotResponse = { - width: number; - height: number; - base64Png: string; -}; - -const SCREENSHOT_MIME = "image/png"; -const MAX_TEXT_LEN = 64 * 1024; - -function trimText(text: string): string { - if (text.length <= MAX_TEXT_LEN) return text; - return `${text.slice(0, MAX_TEXT_LEN)}\n…(剩余 ${text.length - MAX_TEXT_LEN} 字符已截断)`; -} - -function errorResult(toolCall: ToolCall, message: string): ToolResultMessage { - return { - role: "toolResult", - toolCallId: toolCall.id, - toolName: toolCall.name, - content: [{ type: "text", text: trimText(message) }], - details: {}, - isError: true, - timestamp: Date.now(), - }; -} - -function textResult(toolCall: ToolCall, text: string): ToolResultMessage { - return { - role: "toolResult", - toolCallId: toolCall.id, - toolName: toolCall.name, - content: [{ type: "text", text: trimText(text) }], - details: {}, - isError: false, - timestamp: Date.now(), - }; -} - -function imageResult( - toolCall: ToolCall, - data: { base64Png: string; width: number; height: number }, - textSummary: string, -): ToolResultMessage { - const content: Array = [ - { type: "text", text: trimText(textSummary) }, - { type: "image", data: data.base64Png, mimeType: SCREENSHOT_MIME }, - ]; - return { - role: "toolResult", - toolCallId: toolCall.id, - toolName: toolCall.name, - content, - details: { width: data.width, height: data.height }, - isError: false, - timestamp: Date.now(), - }; -} - -function jsonResult(toolCall: ToolCall, payload: T, summary: string): ToolResultMessage { - return textResult(toolCall, `${summary}\n${JSON.stringify(payload, null, 2)}`); -} - -/** - * 创建一组把 CUA 操作暴露给 Agent 的 builtin tool。CUA 总开关与名单 - * 由后端 CuaStore 强制执行;前端 bundle 仅在用户启用时挂载。 - * - * `getLocale` 用来把后端结构化错误(kind + params)按用户当前 UI locale - * 翻译成可读消息;缺省时回落到系统/默认 locale(CUA-006)。 - */ -export function createCuaTools(params?: { getLocale?: () => Locale }): BuiltinToolBundle { - const resolveLocale = (): Locale => - params?.getLocale ? safeReadLocale(params.getLocale) : DEFAULT_LOCALE; - const formatError = (input: unknown): string => formatUnknownCuaError(input, t, resolveLocale()); - const formatOpError = (payload: CuaErrorPayload | undefined): string => { - if (payload) { - return formatUnknownCuaError(payload, t, resolveLocale()); - } - return t("cua.errors.unknown", resolveLocale()); - }; - const metadataEntries: Array<[string, BuiltinToolMetadata]> = [ - ["cua_list_windows", { groupId: "cua", kind: "cua", isReadOnly: true, displayCategory: "cua" }], - [ - "cua_focus_window", - { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }, - ], - ["cua_screenshot", { groupId: "cua", kind: "cua", isReadOnly: true, displayCategory: "cua" }], - ["cua_click", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], - [ - "cua_double_click", - { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }, - ], - ["cua_type", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], - ["cua_key", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], - ["cua_scroll", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], - ["cua_drag", { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }], - ]; - - const tools: Tool[] = [ - { - name: "cua_list_windows", - description: - "[CUA] 列出当前系统中所有可见窗口(owner 应用名、title、bounds)。需要先在设置中启用 CUA。", - parameters: normalizeToolParametersSchema( - { type: "object", properties: {}, additionalProperties: false }, - "CUA cua_list_windows", - ), - }, - { - name: "cua_focus_window", - description: "[CUA] 将指定 owner(应用名,例如 Finder、Safari)聚焦到前台。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { owner: { type: "string", description: "应用名" } }, - required: ["owner"], - additionalProperties: false, - }, - "CUA cua_focus_window", - ), - }, - { - name: "cua_screenshot", - description: - "[CUA] 截取整个主屏幕的当前画面,返回 PNG 图片(image content)。需要屏幕录制权限。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - windowOwner: { - type: "string", - description: "可选:目标应用名(当前 MVP 仍截全屏)", - }, - }, - additionalProperties: false, - }, - "CUA cua_screenshot", - ), - }, - { - name: "cua_click", - description: "[CUA] 在屏幕坐标 (x, y) 处模拟鼠标点击。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - x: { type: "integer", description: "屏幕 x 坐标" }, - y: { type: "integer", description: "屏幕 y 坐标" }, - button: { type: "string", enum: ["left", "middle", "right"], default: "left" }, - }, - required: ["x", "y"], - additionalProperties: false, - }, - "CUA cua_click", - ), - }, - { - name: "cua_double_click", - description: "[CUA] 在屏幕坐标 (x, y) 处双击。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - x: { type: "integer" }, - y: { type: "integer" }, - }, - required: ["x", "y"], - additionalProperties: false, - }, - "CUA cua_double_click", - ), - }, - { - name: "cua_type", - description: "[CUA] 模拟键盘输入字符串。targetOwner 可选:指定后操作会经后端白名单校验。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - text: { type: "string", description: "要输入的文本" }, - targetOwner: { - type: "string", - description: "可选:当前焦点应用名(用于白名单校验)", - }, - }, - required: ["text"], - additionalProperties: false, - }, - "CUA cua_type", - ), - }, - { - name: "cua_key", - description: - "[CUA] 按下指定按键;key 可填入 key code(数字,如 49 = Return)或键名(return / tab / escape 等)。modifiers 数组可传 command / shift / control / option。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - key: { type: "string", description: "按键 key code 或名称" }, - modifiers: { - type: "array", - items: { type: "string" }, - description: "修饰键:command | shift | control | option", - }, - targetOwner: { type: "string" }, - }, - required: ["key"], - additionalProperties: false, - }, - "CUA cua_key", - ), - }, - { - name: "cua_scroll", - description: "[CUA] 在屏幕坐标 (x, y) 处滚动:dy > 0 向上、< 0 向下。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - x: { type: "integer" }, - y: { type: "integer" }, - dy: { type: "integer" }, - }, - required: ["x", "y", "dy"], - additionalProperties: false, - }, - "CUA cua_scroll", - ), - }, - { - name: "cua_drag", - description: "[CUA] 拖拽(cua-driver 内置支持,无需第三方工具)。从 (x1, y1) 到 (x2, y2)。", - parameters: normalizeToolParametersSchema( - { - type: "object", - properties: { - x1: { type: "integer" }, - y1: { type: "integer" }, - x2: { type: "integer" }, - y2: { type: "integer" }, - }, - required: ["x1", "y1", "x2", "y2"], - additionalProperties: false, - }, - "CUA cua_drag", - ), - }, - ]; - - async function invokeCua(name: string, args: Record): Promise { - return invoke(name, args as never); - } - - async function executeToolCall(toolCall: ToolCall): Promise { - const args = (toolCall.arguments ?? {}) as Record; - try { - switch (toolCall.name) { - case "cua_list_windows": { - const wins = await invokeCua("cua_list_windows", {}); - if (wins.length === 0) { - return textResult( - toolCall, - "未发现可见窗口(可能前台应用没有窗口,或缺少屏幕录制权限)。", - ); - } - return jsonResult(toolCall, wins, `共 ${wins.length} 个可见窗口:`); - } - case "cua_focus_window": { - const owner = String(args.owner ?? "").trim(); - if (!owner) return errorResult(toolCall, "缺少必填参数 owner"); - const resp = await invokeCua("cua_focus_window", { owner }); - if (!resp.ok) { - return errorResult(toolCall, formatOpError(resp.error)); - } - return textResult(toolCall, `已尝试聚焦「${owner}」`); - } - case "cua_screenshot": { - const windowOwner = - typeof args.windowOwner === "string" && args.windowOwner.trim() - ? args.windowOwner.trim() - : undefined; - const resp = await invokeCua("cua_screenshot", { - windowOwner, - }); - return imageResult(toolCall, resp, `截图成功:${resp.width}×${resp.height}`); - } - case "cua_click": { - const x = Number(args.x); - const y = Number(args.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) { - return errorResult(toolCall, "x / y 必须是数字"); - } - const button = typeof args.button === "string" ? args.button : "left"; - const resp = await invokeCua("cua_click", { x, y, button }); - if (!resp.ok) return errorResult(toolCall, formatOpError(resp.error)); - return textResult(toolCall, `已在 (${x}, ${y}) 点击 (${button})`); - } - case "cua_double_click": { - const x = Number(args.x); - const y = Number(args.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) { - return errorResult(toolCall, "x / y 必须是数字"); - } - const resp = await invokeCua("cua_double_click", { x, y }); - if (!resp.ok) return errorResult(toolCall, formatOpError(resp.error)); - return textResult(toolCall, `已在 (${x}, ${y}) 双击`); - } - case "cua_type": { - const text = typeof args.text === "string" ? args.text : ""; - if (!text) return errorResult(toolCall, "缺少必填参数 text"); - const targetOwner = - typeof args.targetOwner === "string" && args.targetOwner.trim() - ? args.targetOwner.trim() - : undefined; - const resp = await invokeCua("cua_type", { text, targetOwner }); - if (!resp.ok) return errorResult(toolCall, formatOpError(resp.error)); - return textResult(toolCall, `已输入 ${text.length} 字符`); - } - case "cua_key": { - const key = String(args.key ?? "").trim(); - if (!key) return errorResult(toolCall, "缺少必填参数 key"); - const modifiers = Array.isArray(args.modifiers) - ? (args.modifiers as unknown[]).filter((s): s is string => typeof s === "string") - : undefined; - const targetOwner = - typeof args.targetOwner === "string" && args.targetOwner.trim() - ? args.targetOwner.trim() - : undefined; - const resp = await invokeCua("cua_key", { - key, - modifiers, - targetOwner, - }); - if (!resp.ok) return errorResult(toolCall, formatOpError(resp.error)); - return textResult( - toolCall, - `已按键「${key}」${modifiers?.length ? `(+${modifiers.join("+")})` : ""}`, - ); - } - case "cua_scroll": { - const x = Number(args.x); - const y = Number(args.y); - const dy = Number(args.dy); - if (![x, y, dy].every(Number.isFinite)) { - return errorResult(toolCall, "x / y / dy 必须是数字"); - } - const resp = await invokeCua("cua_scroll", { x, y, dy }); - if (!resp.ok) return errorResult(toolCall, formatOpError(resp.error)); - return textResult(toolCall, `已在 (${x}, ${y}) 滚动 dy=${dy}`); - } - case "cua_drag": { - const [x1, y1, x2, y2] = [args.x1, args.y1, args.x2, args.y2].map((v) => Number(v)); - if (![x1, y1, x2, y2].every(Number.isFinite)) { - return errorResult(toolCall, "x1 / y1 / x2 / y2 必须是数字"); - } - const resp = await invokeCua("cua_drag", { x1, y1, x2, y2 }); - if (!resp.ok) return errorResult(toolCall, formatOpError(resp.error)); - return textResult(toolCall, `已拖拽 (${x1}, ${y1}) → (${x2}, ${y2})`); - } - default: - return errorResult(toolCall, `未知 CUA 工具:${toolCall.name}`); - } - } catch (err) { - return errorResult(toolCall, `CUA 调用失败:${formatError(err)}`); - } - } - - return { - groupId: "cua", - tools, - executeToolCall, - metadataByName: createBuiltinMetadataMap(metadataEntries), - }; -} - -/** - * 拉取后端 CUA 状态(供前端 UI 与按需工具挂载判定)。 - */ -export async function fetchCuaStatus(): Promise { - try { - return await invoke("cua_status"); - } catch { - return null; - } -} - -/** - * 工具执行器和设置面板可能在 React 渲染周期之外被调用,`getLocale` 抛错或 - * 返回非法值时回退到默认 locale,不让 CUA 工具整条降级。 - */ -function safeReadLocale(getLocale: () => Locale): Locale { - try { - const value = getLocale(); - return value === "en-US" || value === "zh-CN" ? value : DEFAULT_LOCALE; - } catch { - return DEFAULT_LOCALE; - } -} diff --git a/crates/agent-gui/src/lib/tools/toolPolicy.ts b/crates/agent-gui/src/lib/tools/toolPolicy.ts index 126f1551c..a62ed7f2b 100644 --- a/crates/agent-gui/src/lib/tools/toolPolicy.ts +++ b/crates/agent-gui/src/lib/tools/toolPolicy.ts @@ -1,3 +1,5 @@ +import { hardcodedServerPolicyDefault } from "@liveagent/ui/contracts/mcpServerDefaults"; + import type { ToolPolicy } from "../settings"; import type { BuiltinToolMetadata } from "./builtinTypes"; @@ -25,20 +27,21 @@ export function toolServerPolicyKey(serverId: string): string { } /** - * 工具组级「不可由用户覆盖的硬编码缺省」。这里列出的组在没有用户 - * 显式策略时,直接走该默认值——避免任何「隐式 allow」绕过 reviewer - * 提出的安全门控(CUA-reviewer 要求 `group:cua` 默认 ask)。 + * 工具组级「不可被隐式 allow 绕过的硬编码缺省」。这里列出的组在没有 + * 用户显式策略时直接走该默认值。 + * + * 顺序:`isHardcodedGroupDefault` 检查必须在用户策略解析之后——即只要 + * 用户显式写了 `group:` 的策略,就以用户为准(ask / allow / deny + * 任意)。`extraGroupDefaults` 也排在它之前,调用方注入的组级缺省能盖 + * 过这张表。 * - * 顺序:`isHardcodedGroupDefault` 检查必须在用户策略解析之后;解析 - * 函数里会把「硬编码默认」放在「用户组级覆盖」之前——即只要用户 - * 显式写了 `group:cua` 的策略,就以用户为准(ask / allow / deny 任 - * 意)。trustMode 切换只在 `cuaSettings.trustMode === true` 时插入 - * `group:cua: allow` 隐式覆盖,所以这里返回的默认是「未开启信任模式 - * 时的兜底」。 + * 目前为空:唯一的登记项曾是 `group:cua`,随自研 CUA 工具组一并移除 + * ——计算机操作已改为把 `cua-driver` 当普通 MCP server 接入,门控落到 + * server 粒度的 `HARDCODED_SERVER_DEFAULTS`(见 + * `@liveagent/ui/contracts/mcpServerDefaults`)。机制本体保留,后续要 + * 给某个工具组定死缺省时直接往这里加。 */ -const HARDCODED_GROUP_DEFAULTS: Readonly> = Object.freeze({ - cua: "ask", -}); +const HARDCODED_GROUP_DEFAULTS: Readonly> = Object.freeze({}); export function isHardcodedGroupDefault(groupId: string): ToolPolicy | undefined { return HARDCODED_GROUP_DEFAULTS[groupId]; @@ -51,15 +54,17 @@ export function isHardcodedGroupDefault(groupId: string): ToolPolicy | undefined * 判定顺序(由细到粗,任一命中即返回): * 1. 该工具名的显式覆盖(toolPolicies[toolName])—— 最细,最高优先级。 * 2. MCP 按 server 的策略(server:,仅对带 serverId 的 MCP 工具)。 - * 3. 工具组级默认(group:,如把"所有 MCP 工具"设为 ask/deny)。 - * 2、3 都是用户对更大范围的明确表态,应盖过下面的只读缺省。 - * 4. 只读工具(metadata.isReadOnly)恒 allow:读操作无副作用,不应打断对话。 - * 5. 其余(内置、mcp、无元数据的未知名)缺省 allow:保持现状,不制造回归。 + * 3. server 级硬编码缺省(`hardcodedServerPolicyDefault`):个别 server + * 的工具面直接操作用户机器(如 cua-driver 的 kill_app / type_text), + * 不能靠第 6 步的兜底 allow 隐式放行。用户在第 2 步显式表态即可盖过。 + * 4. 工具组级默认(group:,如把"所有 MCP 工具"设为 ask/deny)。 + * 2、4 都是用户对更大范围的明确表态,应盖过下面的只读缺省。 + * 5. 只读工具(metadata.isReadOnly)恒 allow:读操作无副作用,不应打断对话。 + * 6. 其余(内置、mcp、无元数据的未知名)缺省 allow:保持现状,不制造回归。 * - * `extraGroupDefaults` 由调用方注入:典型场景是 CUA 在 trustMode 开 - * 启时把 `group:cua` 强行设为 `allow`,让用户切信任时立刻生效, - * 不需要再回设置面板改策略表。`extraGroupDefaults` 优先级低于用户 - * 策略,高于硬编码默认(ask)。 + * `extraGroupDefaults` 由调用方注入,排在「用户组级策略」之后、 + * 「硬编码组级缺省」之前——用来实现"某个运行时开关临时放宽整组工具" + * 这类需求,而不必回设置面板改策略表。当前没有调用方传入。 */ export function resolveToolPolicy( toolName: string, @@ -69,10 +74,13 @@ export function resolveToolPolicy( ): ToolPolicy { const explicit = policies?.[toolName]; if (explicit) return explicit; - const serverPolicy = metadata?.serverId - ? policies?.[toolServerPolicyKey(metadata.serverId)] - : undefined; + const serverId = metadata?.serverId; + const serverPolicy = serverId ? policies?.[toolServerPolicyKey(serverId)] : undefined; if (serverPolicy) return serverPolicy; + if (serverId) { + const hardcodedServer = hardcodedServerPolicyDefault(serverId); + if (hardcodedServer) return hardcodedServer; + } const groupId = metadata?.groupId; const groupPolicy = groupId ? policies?.[toolGroupPolicyKey(groupId)] : undefined; if (groupPolicy) return groupPolicy; diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index b531a627c..1170a3d53 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -1719,9 +1719,6 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { ensureSshTunnelToolTab(change.projectPathKey); } }, - getCuaEnabled: () => settings.cua?.enabled === true, - getCuaTrustMode: () => settings.cua?.trustMode === true, - getLocale: () => settings.locale, onTunnelsChanged: (change) => { if (change.action === "create") { ensureTunnelToolTab(change.projectPathKey); diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index a25f3c809..5f59f752a 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -5,7 +5,6 @@ import type { ToolCall, ToolResultMessage, } from "@earendil-works/pi-ai"; -import type { Locale } from "@liveagent/app/i18n/config"; import { ASK_USER_QUESTION_TOOL_NAME } from "@liveagent/ui/lib/chat/askUserQuestion"; import type { HostedSearchBlock } from "@liveagent/ui/lib/chat/hostedSearch"; import { @@ -315,13 +314,6 @@ export type RunAgentConversationTurnParams = { associatedSshHostIds?: string[]; sshManagerRemoteAllowed?: boolean; onSshSessionsChanged?: (change: SshManagerSessionChange) => void; - /** CUA 总开关的实时读取(权威 settingsRef,非 turn 级快照)。 */ - getCuaEnabled?: () => boolean; - /** CUA 信任模式(trustMode=true 时 group:cua 工具不再 ask)。 - * 实时读取;用户切信任时立即生效,不需要回到设置面板改策略表。 */ - getCuaTrustMode?: () => boolean; - /** 当前 UI locale:cua_* 工具用来把后端结构化错误按用户语言翻译。 */ - getLocale?: () => Locale; sessionId: string; /** Run 级任务状态存储:由 send 管线构建,提交走非终态持久化。 */ taskStateStore: TaskStateStore; @@ -411,9 +403,6 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP associatedSshHostIds, sshManagerRemoteAllowed, onSshSessionsChanged, - getCuaEnabled, - getCuaTrustMode, - getLocale, sessionId, taskStateStore, conversationId, @@ -656,8 +645,6 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP sshManagerRemoteAllowed, onSshSessionsChanged, onTunnelsChanged, - cuaEnabled: getCuaEnabled?.() === true, - getLocale, onMcpLoadError: (message) => { const warning = `MCP 工具加载失败,已跳过并继续对话:${message || "未知错误"}`; console.warn(warning); @@ -682,17 +669,12 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP // 策略为 deny 的工具干脆不发给模型:省 token,且模型不会白白尝试再被拦。 // resolveToolGate 的 deny 分支保留为后备(理论上模型已看不到,不会触发)。 const toolPoliciesSnapshot = getToolPolicies?.(); - // CUA 信任模式:开启时把 group:cua 默认 allow(盖过 isHardcodedGroupDefault - // 返回的 ask),用户在 settings.system.toolPolicies 里写过 group:cua 的仍按 - // 用户策略——`extraGroupDefaults` 优先级低于用户策略。 - const cuaExtraDefaults = getCuaTrustMode?.() === true ? { cua: "allow" as const } : undefined; const combinedTools = builtinRegistry.tools.filter( (tool) => resolveToolPolicy( tool.name, builtinRegistry.metadataByName.get(tool.name), toolPoliciesSnapshot, - cuaExtraDefaults, ) !== "deny", ); @@ -787,12 +769,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP return repeatGate; } } - const policy = resolveToolPolicy( - toolCall.name, - metadata, - getToolPolicies?.(), - cuaExtraDefaults, - ); + const policy = resolveToolPolicy(toolCall.name, metadata, getToolPolicies?.()); if (policy === "deny") { return { allow: false, diff --git a/crates/agent-gui/src/pages/settings/types.ts b/crates/agent-gui/src/pages/settings/types.ts index d8e27810e..3b448a2b3 100644 --- a/crates/agent-gui/src/pages/settings/types.ts +++ b/crates/agent-gui/src/pages/settings/types.ts @@ -1,5 +1,4 @@ import type { SttSettingsService } from "@liveagent/ui/lib/stt/types"; -import type { CuaService } from "@liveagent/ui/pages/settings/CuaSection"; import type { AppUpdateController } from "../../lib/appUpdates"; import type { AppSettings, SttProviderId } from "../../lib/settings"; import type { SettingsSaveState } from "../../lib/settings/storage"; @@ -18,7 +17,6 @@ export type SectionId = | "hooks" | "cron" | "remote" - | "cua" | "about"; export type SettingsPageProps = { @@ -35,8 +33,6 @@ export type SettingsPageProps = { onSttProviderChange?: (provider: SttProviderId) => void; /** 绕过 setSettings 从 SQLite 重新载入(备份还原后用,见 SettingsSectionProps)。 */ reloadSettings?: () => Promise; - /** CUA 后端桥;桌面端由 `settingsExtension` 注入,WebUI 不提供。 */ - cuaService?: CuaService; }; export type SettingsSectionProps = { diff --git a/crates/agent-gui/test/cua-installer-service.test.mjs b/crates/agent-gui/test/cua-installer-service.test.mjs deleted file mode 100644 index 28b89709b..000000000 --- a/crates/agent-gui/test/cua-installer-service.test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -/** - * CUA Driver 安装器服务(cuaService)契约测试。 - * - * 目标:保证 desktopCuaService 把所有 cua_driver_* Tauri Command 正确 - * 桥接到前端——即便后端 Command 改名 / 加参数,前端调用面要稳定。 - */ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; - -function loadCuaService(invokeMock, listenMock) { - const loader = createTsModuleLoader({ - mocks: { - "@tauri-apps/api/core": { - async invoke(command, args) { - return invokeMock(command, args); - }, - }, - "@tauri-apps/api/event": { - async listen(eventName, handler) { - return listenMock(eventName, handler); - }, - }, - }, - }); - return loader.loadModule("src/lib/cua/cuaService.ts"); -} - -test("desktopCuaService.detectDriver forwards to cua_driver_detect", async () => { - const calls = []; - const { desktopCuaService } = loadCuaService((command) => { - calls.push(command); - if (command === "cua_driver_detect") { - return { installed: false, daemonRunning: false, platform: "macos" }; - } - return null; - }); - - const detection = await desktopCuaService.detectDriver(); - assert.deepEqual(calls, ["cua_driver_detect"]); - assert.equal(detection.installed, false); - assert.equal(detection.daemonRunning, false); - assert.equal(detection.platform, "macos"); -}); - -test("desktopCuaService.installDriver forwards to cua_driver_install", async () => { - const calls = []; - const { desktopCuaService } = loadCuaService((command) => { - calls.push(command); - if (command === "cua_driver_install") { - return { success: true, log: "ok", daemonStarted: true, installedVersion: "0.3.1" }; - } - return null; - }); - - const result = await desktopCuaService.installDriver(); - assert.deepEqual(calls, ["cua_driver_install"]); - assert.equal(result.success, true); - assert.equal(result.installedVersion, "0.3.1"); -}); - -test("desktopCuaService.updateDriver forwards apply flag to cua_driver_update", async () => { - const calls = []; - const { desktopCuaService } = loadCuaService((command, args) => { - calls.push({ command, args }); - if (command === "cua_driver_update") { - return { updateAvailable: args?.apply === true, log: "", newVersion: "0.3.2" }; - } - return null; - }); - - const checkOnly = await desktopCuaService.updateDriver(false); - const applied = await desktopCuaService.updateDriver(true); - assert.equal(checkOnly.updateAvailable, false); - assert.equal(applied.updateAvailable, true); - assert.deepEqual( - calls.map((c) => c.command), - ["cua_driver_update", "cua_driver_update"], - ); - assert.equal(calls[0].args.apply, false); - assert.equal(calls[1].args.apply, true); -}); - -test("desktopCuaService.startDriverDaemon unwraps { ok, error } response", async () => { - const { desktopCuaService } = loadCuaService((command) => { - if (command === "cua_driver_start_daemon") return { ok: true }; - return null; - }); - assert.equal(await desktopCuaService.startDriverDaemon(), true); - - const failed = loadCuaService((command) => { - if (command === "cua_driver_start_daemon") return { ok: false, error: { kind: "x" } }; - return null; - }); - const svc = failed.loadModule ? null : null; - // direct re-import using same factory pattern - const failed2 = createTsModuleLoader2((command) => { - if (command === "cua_driver_start_daemon") return { ok: false, error: { kind: "x" } }; - return null; - }); - const { desktopCuaService: svc2 } = failed2.loadModule("src/lib/cua/cuaService.ts"); - assert.equal(await svc2.startDriverDaemon(), false); -}); - -// inline secondary loader to avoid helper leakage -function createTsModuleLoader2(invokeImpl) { - return createTsModuleLoader({ - mocks: { - "@tauri-apps/api/core": { invoke: invokeImpl }, - "@tauri-apps/api/event": { async listen() { return () => {}; } }, - }, - }); -} - -test("desktopCuaService.subscribeProgress registers a Tauri listener", async () => { - const events = []; - const { desktopCuaService } = loadCuaService( - () => null, - (eventName, handler) => { - events.push({ eventName, handler }); - // 模拟 listen 立刻把 fake event 推给 handler 验证 payload 透传。 - handler({ payload: { stage: "starting", message: "hi" } }); - return () => {}; - }, - ); - - let received = null; - const unlisten = await desktopCuaService.subscribeProgress((event) => { - received = event; - }); - assert.equal(typeof unlisten, "function"); - assert.equal(events.length, 1); - assert.equal(events[0].eventName, "cua_install_progress"); - assert.deepEqual(received, { stage: "starting", message: "hi" }); -}); - -test("desktopCuaService.getInstallPreview forwards to cua_driver_install_preview", async () => { - const calls = []; - const { desktopCuaService } = loadCuaService((command) => { - calls.push(command); - if (command === "cua_driver_install_preview") { - return { - platform: "macos", - command: { - program: "/bin/bash", - args: ["-c", "curl ... | bash"], - description: "macOS / Linux installer", - needsSudo: false, - }, - }; - } - return null; - }); - const preview = await desktopCuaService.getInstallPreview(); - assert.deepEqual(calls, ["cua_driver_install_preview"]); - assert.equal(preview.command.program, "/bin/bash"); - assert.equal(preview.platform, "macos"); -}); diff --git a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs b/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs deleted file mode 100644 index e328e558e..000000000 --- a/crates/agent-gui/test/cua/cua-installer-panel-chevron.test.mjs +++ /dev/null @@ -1,247 +0,0 @@ -/** - * CUA-044 regression test: CuaInstallerPanel 安装日志折叠按钮 chevron 旋转动画 - * - * 关键要求(CUA-044 修复后): - * - 不再依赖 inline style.transform(CUA-042 证明 inline style 会被 WKWebView - * 丢弃——aria-expanded / data-chevron-expanded 已切,但 getComputedStyle - * .transform 永远 matrix(1,0,0,1,0,0))。 - * - 改用 Tailwind className 切换 + transition-transform + duration-150: - * className={cn("... transition-transform duration-150", - * logExpanded ? "rotate-180" : "rotate-0")} - * className 由 React commit 同步写入,浏览器 CSS transition 通过样式表 - * pipeline 推进,不依赖 WAAPI 时钟(CUA-041 已证 WAAPI playback clock - * 在 WKWebView 全程冻结)。 - * - chevron span 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证。 - * - 用 useLayoutEffect 读取 offsetWidth 强制 layout flush——给 WKWebView - * 一个明确的 reflow 触发点,避免 className 切换被下一帧 batch 掉。 - * - * 失败历史: - * - CUA-038:Tailwind rotate-180 + transition-transform 在 SVG 上 WAAPI - * 时钟冻结; - * - CUA-039:inline style.transform + transition,expand 路径 transition - * 不推进; - * - CUA-041:Web Animations API,WKWebView 整页 hidden 时 - * animation.currentTime 永远 0; - * - CUA-042:inline style.transform 写在统一 style 对象里,computed - * transform 永远单位矩阵——inline style 被 WKWebView 丢弃; - * - CUA-044:className 切换 + useLayoutEffect + offsetWidth flush(当前)。 - * - * 验证策略(两层): - * 1. **静态源码检查**:grep 确认 CuaInstallerPanel.tsx 已切到 className - * 驱动,并移除 inline style.transform / style.transition; - * 2. **运行时 React 行为**:用 React 在 jsdom 下挂载一个最小复刻 chevron - * span,验证 toggle 时 className 跟随 React state 同步切换 - * rotate-180 / rotate-0。 - */ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url))); -const panelPath = path.join( - repoRoot, - "crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx", -); -const panelSource = fs.readFileSync(panelPath, "utf8"); - -test("CUA-044 静态源码: chevron span 必须用 className 驱动 rotate-180/rotate-0", () => { - // 抓 chevron 容器 span 的 JSX 块:通过 ref={chevronRef} 锚点定位起始 ,避免 JSX 嵌套导致单正则跨多个 span。 - const chevronRefIdx = panelSource.indexOf("ref={chevronRef}"); - assert.ok(chevronRefIdx > 0, "panel source must contain ref={chevronRef}"); - const beforeChevron = panelSource.slice(0, chevronRefIdx); - const lastSpanStart = beforeChevron.lastIndexOf(" 0, "expected a ", chevronRefIdx); - assert.ok(closeSpanIdx > chevronRefIdx, "expected a closing tag after ref={chevronRef}"); - const block = panelSource.slice(lastSpanStart, closeSpanIdx + "".length); - - // 必须暴露 data-chevron-expanded 供 AX / CUA driver 验证。 - assert.ok( - /data-chevron-expanded=\{logExpanded\s*\?\s*"true"\s*:\s*"false"\}/.test(block), - `chevron span missing data-chevron-expanded attribute: ${block.slice(0, 200)}`, - ); - // 必须用 className 切换 rotate-180(不依赖 inline style.transform)。 - assert.ok( - /className=\{cn\([\s\S]*?logExpanded\s*\?\s*"rotate-180"\s*:\s*"rotate-0"[\s\S]*?\)\s*\}/.test( - block, - ), - `chevron span must toggle rotate-180 via className: ${block.slice(0, 200)}`, - ); - // 必须有 Tailwind transition + duration 类(让 CSS transition 管线推进旋转)。 - assert.ok( - /transition-transform/.test(block) && /duration-150/.test(block), - `chevron span must declare transition-transform + duration-150: ${block.slice(0, 200)}`, - ); - // 不允许再有 inline style.transform / style.transition(CUA-044 关键要求)。 - assert.ok( - !/style=\{\{[\s\S]*?transform:\s*logExpanded/.test(block), - `chevron span still has inline style.transform — CUA-044 forbids it: ${block.slice(0, 200)}`, - ); - assert.ok( - !/style=\{\{[\s\S]*?transition:\s*"transform\s+150ms/.test(block), - `chevron span still has inline style.transition — should use Tailwind classes: ${block.slice(0, 200)}`, - ); -}); - -test("CUA-044 静态源码: 必须用 useLayoutEffect + offsetWidth 强制 layout flush", () => { - // 必须有 chevronRef(指向 chevron span)。 - assert.ok( - /chevronRef\s*=\s*useRef]*>/.test(panelSource), - `panel is missing chevronRef for layout flush anchor`, - ); - // 必须有 useLayoutEffect 读 offsetWidth 强制 reflow。 - assert.ok( - /useLayoutEffect\(\(\)\s*=>\s*\{[\s\S]*?offsetWidth[\s\S]*?\}\s*,\s*\[logExpanded\]\)/.test( - panelSource, - ), - `panel is missing useLayoutEffect + offsetWidth flush`, - ); -}); - -test("CUA-044 静态源码: 必须移除 WAAPI .animate(...) 与旧的 inline-style 注释锚点", () => { - assert.ok( - !/\.animate\(/.test(panelSource), - `panel still calls .animate(...) on an element — should switch to className: ${ - panelSource.match(/[^\n]*\.animate\([^\n]*/)?.[0]?.slice(0, 200) ?? "" - }`, - ); - // 不允许再有「CUA-041」注释锚点(CUA-041 是被 CUA-042 取代的 WAAPI 路径); - // CUA-042 可以出现在注释里作为 inline-style 路径被否决的历史,但本测试只 - // 校验 WAAPI 路径已彻底移除。 - assert.ok( - !/CUA-041/.test(panelSource), - `panel still references CUA-041 — should be CUA-044 now`, - ); -}); - -// ---------------- 运行时 React 行为 ---------------- - -const { JSDOM } = await import( - new URL("../../node_modules/jsdom/lib/api.js", import.meta.url).href -); -const dom = new JSDOM( - "

", - { pretendToBeVisual: true, url: "http://localhost/" }, -); -globalThis.window = dom.window; -globalThis.document = dom.window.document; -globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); -globalThis.cancelAnimationFrame = (id) => clearTimeout(id); -globalThis.IS_REACT_ACT_ENVIRONMENT = true; -globalThis.matchMedia = () => ({ - matches: false, - media: "", - addEventListener: () => {}, - removeEventListener: () => {}, - addListener: () => {}, - removeListener: () => {}, - dispatchEvent: () => false, - onchange: null, -}); -if (typeof globalThis.structuredClone !== "function") { - globalThis.structuredClone = (v) => JSON.parse(JSON.stringify(v)); -} - -const req = createRequire(import.meta.url); -const React = req("react"); -const ReactDOMClient = req("react-dom/client"); - -/** - * 最小复刻 CuaInstallerPanel 的 chevron span(CUA-044 className 驱动版): - * 用 React state 直接驱动 className 上的 rotate-180 / rotate-0 切换。 - */ -function ChevronSpan({ expanded }) { - return React.createElement( - "span", - { - "data-chevron-expanded": expanded ? "true" : "false", - className: [ - "inline-flex", - "transition-transform", - "duration-150", - expanded ? "rotate-180" : "rotate-0", - ] - .filter(Boolean) - .join(" "), - }, - React.createElement("svg", { width: 12, height: 12 }), - ); -} - -// 共享 root:createRoot 只能对同一个 container 调用一次。 -const container = document.getElementById("root"); -const root = ReactDOMClient.createRoot(container); - -// React 19 的 commit 是异步的;用 React.act 强制同步 flush 避免跨测试脏读。 -function setExpanded(expanded) { - return React.act(() => { - root.render(React.createElement(ChevronSpan, { expanded })); - }); -} - -function readClasses(span) { - return (span.getAttribute("class") ?? "").split(/\s+/).filter(Boolean); -} - -test("CUA-044 运行时: 收起时 chevron span 有 rotate-0、无 rotate-180", async () => { - await setExpanded(false); - const span = container.querySelector("span"); - assert.ok(span, "chevron span should be in DOM"); - assert.equal(span.getAttribute("data-chevron-expanded"), "false"); - const classes = readClasses(span); - assert.ok( - classes.includes("rotate-0"), - `chevron span should have rotate-0 when collapsed: ${classes.join(" ")}`, - ); - assert.ok( - !classes.includes("rotate-180"), - `chevron span should NOT have rotate-180 when collapsed: ${classes.join(" ")}`, - ); - assert.ok( - classes.includes("transition-transform") && classes.includes("duration-150"), - `chevron span should declare transition-transform + duration-150: ${classes.join(" ")}`, - ); -}); - -test("CUA-044 运行时: 展开后 chevron span 有 rotate-180、无 rotate-0", async () => { - await setExpanded(true); - const span = container.querySelector("span"); - assert.ok(span); - assert.equal(span.getAttribute("data-chevron-expanded"), "true"); - const classes = readClasses(span); - assert.ok( - classes.includes("rotate-180"), - `chevron span should have rotate-180 when expanded: ${classes.join(" ")}`, - ); - assert.ok( - !classes.includes("rotate-0"), - `chevron span should NOT have rotate-0 when expanded: ${classes.join(" ")}`, - ); -}); - -test("CUA-044 运行时: 收起→展开→收起,每次都触发对应方向的 className 切换", async () => { - await setExpanded(false); - let classes = readClasses(container.querySelector("span")); - assert.ok( - classes.includes("rotate-0") && !classes.includes("rotate-180"), - `collapsed classes: ${classes.join(" ")}`, - ); - - await setExpanded(true); - classes = readClasses(container.querySelector("span")); - assert.ok( - classes.includes("rotate-180") && !classes.includes("rotate-0"), - `expanded classes: ${classes.join(" ")}`, - ); - - await setExpanded(false); - classes = readClasses(container.querySelector("span")); - assert.ok( - classes.includes("rotate-0") && !classes.includes("rotate-180"), - `re-collapsed classes: ${classes.join(" ")}`, - ); -}); \ No newline at end of file diff --git a/crates/agent-gui/test/cua/format-cua-error.test.mjs b/crates/agent-gui/test/cua/format-cua-error.test.mjs deleted file mode 100644 index 8140f49ae..000000000 --- a/crates/agent-gui/test/cua/format-cua-error.test.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; - -const loader = createTsModuleLoader(); -const { formatCuaError, formatUnknownCuaError, normalizeCuaError } = loader.loadModule( - "@liveagent/ui/lib/cua/formatCuaError.ts", -); -const { t, DEFAULT_LOCALE } = loader.loadModule("@liveagent/app/i18n/config"); - -test("formatCuaError: en-US locale renders disabled message in English", () => { - const payload = { - kind: "cua.errors.disabled", - message: "CUA is not enabled.", - params: null, - }; - const out = formatCuaError(payload, t, "en-US"); - assert.ok(out.includes("CUA is not enabled")); - assert.ok(!out.includes("CUA 未启用"), `unexpected Chinese leak: ${out}`); -}); - -test("formatCuaError: zh-CN locale renders disabled message in Chinese", () => { - const payload = { - kind: "cua.errors.disabled", - message: "CUA is not enabled.", - params: null, - }; - const out = formatCuaError(payload, t, "zh-CN"); - assert.ok(out.includes("CUA 未启用")); - assert.ok(!out.startsWith("CUA is not enabled")); -}); - -test("formatCuaError: deniedByAllowlist interpolates target + allowed list", () => { - const payload = { - kind: "cua.errors.deniedByAllowlist", - message: "denied", - params: { target: "Safari", allowed: ["Finder"] }, - }; - const en = formatCuaError(payload, t, "en-US"); - const zh = formatCuaError(payload, t, "zh-CN"); - assert.match(en, /Safari/); - assert.match(en, /Finder/); - assert.doesNotMatch(en, /\{allowed\}/); - assert.match(zh, /Safari/); - assert.match(zh, /Finder/); -}); - -test("formatCuaError: missing kind falls back to English message", () => { - const payload = { - kind: "cua.errors.does.not.exist", - message: "English fallback", - params: null, - }; - const out = formatCuaError(payload, t, "zh-CN"); - assert.equal(out, "English fallback"); -}); - -test("normalizeCuaError: recognizes IPC error JSON thrown by Rust", () => { - const rawError = new Error( - JSON.stringify({ - kind: "cua.errors.disabled", - message: "CUA is not enabled.", - params: null, - }), - ); - const parsed = normalizeCuaError(rawError); - assert.ok(parsed); - assert.equal(parsed.kind, "cua.errors.disabled"); -}); - -test("normalizeCuaError: returns null for unrelated messages", () => { - const parsed = normalizeCuaError(new Error("boom")); - assert.equal(parsed, null); -}); - -test("formatUnknownCuaError: en-US renders structured error translated", () => { - const input = new Error( - JSON.stringify({ - kind: "cua.errors.disabled", - message: "fallback", - params: null, - }), - ); - const out = formatUnknownCuaError(input, t, "en-US"); - assert.ok(out.includes("CUA is not enabled")); -}); - -// CUA-051: get_desktop_state 返回全黑帧时后端抛 screenCaptureUnavailable, -// 前端必须能在 zh-CN / en-US 两种 locale 下都翻译成可读消息 + 正确的占位。 -test("formatCuaError: screenCaptureUnavailable renders in en-US with detail interpolated", () => { - const payload = { - kind: "cua.errors.screenCaptureUnavailable", - message: "fallback", - params: { detail: "bundle_identity=fail" }, - }; - const out = formatCuaError(payload, t, "en-US"); - assert.ok(out.includes("bundle_identity=fail"), `expected detail in: ${out}`); - assert.ok( - out.includes("CuaDriver.app") || out.includes("Screen Recording"), - `expected remediation hint in: ${out}`, - ); - assert.ok(!out.includes("{detail}"), `expected placeholder interpolated in: ${out}`); -}); - -test("formatCuaError: screenCaptureUnavailable renders in zh-CN with detail interpolated", () => { - const payload = { - kind: "cua.errors.screenCaptureUnavailable", - message: "fallback", - params: { detail: "bundle_identity=fail" }, - }; - const out = formatCuaError(payload, t, "zh-CN"); - assert.ok(out.includes("bundle_identity=fail"), `expected detail in: ${out}`); - assert.ok( - out.includes("CuaDriver.app") || out.includes("屏幕录制") || out.includes("Screen Recording"), - `expected remediation hint in: ${out}`, - ); -}); - -test("DEFAULT_LOCALE is one of the supported locales", () => { - assert.ok(DEFAULT_LOCALE === "en-US" || DEFAULT_LOCALE === "zh-CN"); -}); diff --git a/crates/agent-gui/test/tools/tool-policy.test.mjs b/crates/agent-gui/test/tools/tool-policy.test.mjs index eb2c15d3a..3ce2b2948 100644 --- a/crates/agent-gui/test/tools/tool-policy.test.mjs +++ b/crates/agent-gui/test/tools/tool-policy.test.mjs @@ -57,30 +57,73 @@ test("normalizeSystemSettings 透传 toolPolicies 且旧快照缺失时不报错 assert.equal(legacy.toolPolicies, undefined); }); -test("CUA 默认 ask:没有用户策略也没有 trustMode 时,group:cua 走 ask 缺省", () => { - const cuaMeta = { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }; - assert.equal(resolveToolPolicy("cua_click", cuaMeta, undefined), "ask"); - assert.equal(resolveToolPolicy("cua_screenshot", cuaMeta, undefined), "ask"); +// cua-driver 作为普通 MCP server 接入,工具的 groupId 是 "mcp"。若只靠 +// 「mcp 缺省 allow」,kill_app / type_text / clipboard_write 会被隐式放行。 +const cuaDriverMeta = (over = {}) => ({ + groupId: "mcp", + kind: "mcp", + isReadOnly: false, + displayCategory: "mcp", + serverId: "cua-driver", + ...over, }); -test("CUA 用户显式策略覆盖 ask 缺省", () => { - const cuaMeta = { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }; - assert.equal(resolveToolPolicy("cua_click", cuaMeta, { "group:cua": "deny" }), "deny"); - assert.equal(resolveToolPolicy("cua_click", cuaMeta, { "group:cua": "allow" }), "allow"); - assert.equal(resolveToolPolicy("cua_click", cuaMeta, { cua_click: "deny" }), "deny"); +test("cua-driver 默认 ask:没有用户策略时不走 mcp 的 allow 缺省", () => { + assert.equal(resolveToolPolicy("mcp_cua-driver_click", cuaDriverMeta(), undefined), "ask"); + // 只读工具也要 ask:截屏会把整个桌面内容交给模型。 + assert.equal( + resolveToolPolicy("mcp_cua-driver_get_desktop_state", cuaDriverMeta({ isReadOnly: true }), undefined), + "ask", + ); + // 其他 MCP server 不受影响,仍是 allow。 + assert.equal( + resolveToolPolicy("mcp_other_t", cuaDriverMeta({ serverId: "other" }), undefined), + "allow", + ); }); -test("CUA trustMode 开启时 extraGroupDefaults 把 group:cua 强制 allow,但仍低于用户策略", () => { - const cuaMeta = { groupId: "cua", kind: "cua", isReadOnly: false, displayCategory: "cua" }; - const trust = { cua: "allow" }; - assert.equal(resolveToolPolicy("cua_click", cuaMeta, undefined, trust), "allow"); - // 用户 deny 仍然生效(用户策略 > extra defaults > 硬编码默认)。 - assert.equal(resolveToolPolicy("cua_click", cuaMeta, { "group:cua": "deny" }, trust), "deny"); +test("cua-driver 用户显式策略覆盖 ask 缺省", () => { + const meta = cuaDriverMeta(); + assert.equal(resolveToolPolicy("mcp_cua-driver_click", meta, { "server:cua-driver": "deny" }), "deny"); + assert.equal( + resolveToolPolicy("mcp_cua-driver_click", meta, { "server:cua-driver": "allow" }), + "allow", + ); + assert.equal( + resolveToolPolicy("mcp_cua-driver_click", meta, { "mcp_cua-driver_click": "deny" }), + "deny", + ); }); -test("isHardcodedGroupDefault 暴露 CUA ask 缺省供 UI 提示", () => { +test("server 级硬编码缺省优先于 group:mcp 的用户策略", () => { + // 用户把「所有 MCP 工具」设为 allow,cua-driver 仍单独保持 ask——组级放 + // 行不该顺带放行一个能敲键盘杀进程的 server;要放行得显式写 server:。 + assert.equal( + resolveToolPolicy("mcp_cua-driver_click", cuaDriverMeta(), { "group:mcp": "allow" }), + "ask", + ); +}); + +test("hardcodedServerPolicyDefault / effectiveServerPolicyDefault 与解析结果一致", () => { + const defaults = loader.loadModule("../agent-ui/src/contracts/mcpServerDefaults.ts"); + assert.equal(defaults.hardcodedServerPolicyDefault("cua-driver"), "ask"); + assert.equal(defaults.hardcodedServerPolicyDefault("other"), undefined); + assert.equal(defaults.effectiveServerPolicyDefault("cua-driver"), "ask"); + assert.equal(defaults.effectiveServerPolicyDefault("other"), "allow"); +}); + +test("isHardcodedGroupDefault 机制保留但当前表为空", () => { const { isHardcodedGroupDefault } = toolPolicy; - assert.equal(isHardcodedGroupDefault("cua"), "ask"); assert.equal(isHardcodedGroupDefault("mcp"), undefined); assert.equal(isHardcodedGroupDefault("shell"), undefined); + assert.equal(isHardcodedGroupDefault("cua"), undefined); +}); + +test("extraGroupDefaults 仍低于用户策略、高于硬编码组缺省", () => { + const meta = { groupId: "plugin", kind: "x", isReadOnly: false, displayCategory: "other" }; + assert.equal(resolveToolPolicy("plugin_x", meta, undefined, { plugin: "deny" }), "deny"); + assert.equal( + resolveToolPolicy("plugin_x", meta, { "group:plugin": "allow" }, { plugin: "deny" }), + "allow", + ); }); diff --git a/crates/agent-ui/src/contracts/builtinTools.ts b/crates/agent-ui/src/contracts/builtinTools.ts index 4745c5fe8..7b09690a0 100644 --- a/crates/agent-ui/src/contracts/builtinTools.ts +++ b/crates/agent-ui/src/contracts/builtinTools.ts @@ -12,8 +12,7 @@ export type BuiltinToolGroupId = | "system" | "mcp" | "subagent" - | "memory" - | "cua"; + | "memory"; export type BuiltinToolDisplayCategory = | "file" @@ -21,7 +20,6 @@ export type BuiltinToolDisplayCategory = | "terminal" | "system" | "mcp" - | "cua" | "other"; export type BuiltinToolMetadata = { diff --git a/crates/agent-ui/src/contracts/mcpServerDefaults.ts b/crates/agent-ui/src/contracts/mcpServerDefaults.ts new file mode 100644 index 000000000..34715c7b7 --- /dev/null +++ b/crates/agent-ui/src/contracts/mcpServerDefaults.ts @@ -0,0 +1,34 @@ +import type { ToolPolicy } from "@liveagent/app/lib/settings"; + +/** + * MCP server 级「不可被隐式 allow 绕过的硬编码缺省策略」。 + * + * 背景:`resolveToolPolicy` 对 MCP 工具的兜底是 `allow`——对绝大多数 + * MCP server(查文档、读数据)是合理的。但个别 server 的工具面直接 + * 操作用户的机器:`cua-driver` 暴露的 60 个工具里包含 `kill_app`、 + * `clipboard_write`、`type_text`、`browser_download`,隐式放行等于让 + * 模型无声地按键、改剪贴板、杀进程。 + * + * 这里列出的 server 在用户没有显式配置 `server:` 策略时走该默认 + * 值。用户仍可在 MCP Hub 的 server 卡片里改成 allow / deny——显式配置 + * 永远优先。与 `toolPolicy.ts` 的 `HARDCODED_GROUP_DEFAULTS` 同构, + * 只是粒度落在 server 而非工具组。 + * + * 放在 contracts/ 是因为要被两侧共用:`agent-gui` 的 + * `lib/tools/toolPolicy.ts` 用它做策略解析,`agent-ui` 的 + * `pages/mcp-hub/McpServersForm.tsx` 用它做下拉框的显示缺省。两边读同 + * 一张表,否则会出现「UI 显示 allow、实际按 ask 执行」的错位。 + */ +const HARDCODED_SERVER_DEFAULTS: Readonly> = Object.freeze({ + "cua-driver": "ask", +}); + +/** 该 server 的硬编码缺省策略;没有登记则返回 undefined。 */ +export function hardcodedServerPolicyDefault(serverId: string): ToolPolicy | undefined { + return HARDCODED_SERVER_DEFAULTS[serverId]; +} + +/** 该 server 在无显式配置时的生效策略(含全局兜底 allow)。 */ +export function effectiveServerPolicyDefault(serverId: string): ToolPolicy { + return hardcodedServerPolicyDefault(serverId) ?? "allow"; +} diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 0dee514ef..b0c77270a 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1184,125 +1184,4 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.hooksActiveHooks": "Active", "settings.hooksInactiveHooks": "Inactive", "settings.hooksSelectedEvent": "Selected Event", - "settings.cua.title": "CUA (Computer Use Agent) Driver", - "settings.cua.enable": "Enable CUA", - "settings.cua.enableDesc": - "When enabled, the Agent can operate the desktop through cua_* tools (window listing, screenshot, click, type, etc.).", - "settings.cua.enableDescUnsupported": - "The CUA driver is not implemented for this platform yet; all operations will fail when enabled.", - "settings.cua.platform": "Driver platform", - "settings.cua.platformDesc": - "Selected at build time by the Rust target (macOS / Windows / Linux).", - "settings.cua.platformNotes.macos.accessibility": - "LiveAgent needs the Accessibility permission to send mouse and keyboard events.", - "settings.cua.platformNotes.macos.screenRecording": - "LiveAgent needs the Screen Recording permission to capture screenshots.", - "settings.cua.platformNotes.macos.dragAndDrop": - "Drag-and-drop is built into cua-driver; no third-party tool is required.", - "settings.cua.platformNotes.unsupported": - "Driver not implemented; all CUA operations will fail when enabled", - "settings.cua.allowedOwners": "Allowed target apps", - "settings.cua.allowedOwnersDesc": - "When non-empty, only listed owner names are allowed for focus / input / click. Screenshots and window listing bypass this list.", - "settings.cua.allowedOwnersHint": "e.g. Finder, Safari, Terminal", - "settings.cua.trustModeGroup": "Trust mode", - "settings.cua.trustMode": "Trust CUA tools", - "settings.cua.trustModeDesc": - "Off (default): every cua_* tool call prompts for approval. On: prompts are skipped, but the backend allowlist and audit log still apply.", - "settings.cua.trustModeHint": - "CUA takes over the mouse and keyboard; screenshots are also captured. Leave this off by default and only enable it for short batch automation runs.", - "settings.cua.sandboxOfflineBanner": - "The current command safety mode is sandboxOffline. All CUA tools are disabled so the cua-driver subprocess cannot bypass the offline sandbox. Switch the command safety mode to re-enable CUA.", - "settings.cua.enableDescSandboxOffline": - "The current command safety mode is sandboxOffline; CUA is disabled and cannot be turned on here.", - "settings.cua.audit": "Audit log", - "settings.cua.auditLimit": "Audit log limit", - "settings.cua.auditLimitDesc": "Keep the most recent N CUA operations in memory (0 = disable).", - "settings.cua.auditCurrent": "{count} entries", - "settings.cua.auditEmpty": "No CUA operations recorded yet.", - "settings.cua.auditClear": "Clear log", - "settings.cua.saving": "Syncing CUA config…", - "settings.cua.errors.fetch": "Failed to read CUA status", - "settings.cua.errors.save": "Failed to save CUA config", - "cua.errors.disabled": - "CUA is not enabled. Open Settings → CUA Driver and turn the master switch on, then retry.", - "cua.errors.deniedByAllowlist": - 'CUA operation denied: target "{target}" is not in the allowlist {allowed}.', - "cua.errors.unsupportedPlatform": - "CUA driver is not available on {platform}; only macOS is implemented right now.", - "cua.errors.permissionRequired": - "CUA needs the macOS {permission} permission. Open System Settings → Privacy & Security and grant LiveAgent the {permission} permission, then retry.", - "cua.errors.notExecuted": "CUA operation was not executed: {detail}", - "cua.errors.io": "CUA subprocess IO error: {detail}", - "cua.errors.unknown": "unknown reason", - // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 - // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, - // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 - // 译成 TCC 失效」的掩盖行为。 - "cua.errors.ownerNotFound": - "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", - "cua.errors.ownerNotFoundDetail": - "Could not find a running window for owner \"{owner}\". Verify the app name / bundle id and that it has at least one visible window before retrying.", - // CUA-051: cua-driver 没有在 CuaDriver.app bundle 内启动导致 - // ScreenCaptureKit attribution 失效 → get_desktop_state 返回全黑帧。 - "cua.errors.screenCaptureUnavailable": - "CUA screen capture is unavailable: {detail}. Launch cua-driver inside CuaDriver.app (so TCC Screen Recording attributes to com.trycua.driver), or re-grant Screen Recording for CuaDriver in System Settings → Privacy & Security, then retry.", - "cua.errors.sandboxOffline": - "CUA tools are disabled while the command safety mode is sandboxOffline. Switch the command safety mode to ask / auto / sandbox to use CUA.", - // CUA-100 series: driver installer errors - "cua.errors.installer.networkUnavailable": - "CUA driver installer could not reach the network: {detail}. Check your connection and retry.", - "cua.errors.installer.curlFailed": "CUA driver installer download failed: {detail}", - "cua.errors.installer.signatureInvalid": "CUA driver installer signature check failed: {detail}", - "cua.errors.installer.permissionDenied": - "CUA driver installer needs elevated permission: {detail}", - "cua.errors.installer.unsupportedPlatform": - "CUA driver installer is not available on {platform}.", - "cua.errors.installer.timeout": - "CUA driver installer did not finish within {minutes} minutes; the download may have stalled, please retry.", - // CUA Driver installer panel (CUA-100) - "settings.cua.installer.title": "CUA Driver", - "settings.cua.installer.description": - "LiveAgent does not bundle cua-driver; it is downloaded by the official cua.ai installer script. The script pulls from cua.ai and does not require sudo (except for Linux desktop dependencies).", - "settings.cua.installer.install": "Download & install cua-driver", - "settings.cua.installer.restartDaemon": "Start daemon", - "settings.cua.installer.checkUpdate": "Check for update", - "settings.cua.installer.applyUpdate": "Apply update", - "settings.cua.installer.refresh": "Re-detect", - "settings.cua.installer.dismiss": "Dismiss", - "settings.cua.installer.statusUnknown": "Status unknown", - "settings.cua.installer.statusNotInstalled": "Not installed", - "settings.cua.installer.statusInstalledNotRunning": "Installed, daemon not running", - "settings.cua.installer.statusInstalledRunning": "Installed, daemon running", - "settings.cua.installer.versionLabel": "v{version}", - "settings.cua.installer.versionUnknown": "Installed (version unknown)", - "settings.cua.installer.daemonStarted": "daemon started", - "settings.cua.installer.daemonNotStarted": "daemon not started (will start on first use)", - "settings.cua.installer.installSuccess": "Installed: {version}; {daemon}", - "settings.cua.installer.linuxMissingDeps": - "Linux desktop requires these system packages. Run `sudo apt install {packages}` in a terminal before continuing.", - "settings.cua.installer.macosPermissionsTitle": "macOS permissions", - "settings.cua.installer.macosPermissionsBody": - "Open System Settings → Privacy & Security and grant CuaDriver:\n• Accessibility\n• Screen Recording\nThen bring LiveAgent to the front to use it.\n\nIf `cua_screenshot` returns a fully-black frame, cua-driver is likely running outside the CuaDriver.app bundle and TCC Screen Recording is not attributed to com.trycua.driver. Launch cua-driver via `open -n -g -a CuaDriver --args serve` (the daemon) or rerun the installer so the bundle path is used.", - "settings.cua.installer.macosOpenSettings": "Open System Settings", - "settings.cua.installer.logLabel": "Install log", - "settings.cua.installer.stage.starting": "Preparing…", - "settings.cua.installer.stage.downloading": "Downloading official installer…", - "settings.cua.installer.stage.installing": "Installing…", - "settings.cua.installer.stage.startingDaemon": "Starting daemon…", - "settings.cua.installer.stage.checkingUpdate": "Checking for update…", - "settings.cua.installer.stage.applyingUpdate": "Applying update…", - "settings.cua.installer.errors.invoke": "Failed to invoke the backend CUA installer command.", - "settings.cua.installer.errors.unknown": "Unknown error", - "settings.cua.installer.errors.exception": "Exception: {message}", - "settings.cua.installer.errors.daemonStart": - "Failed to start daemon. Verify that cua-driver is installed correctly.", - // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染, - // 不再与 errors.* 混在一起。 - "settings.cua.installer.update.available": - 'A new version is available. Click the apply button next to "Check for update" to install.\n\n{log}', - "settings.cua.installer.update.upToDate": "Already up to date.\n\n{log}", - "settings.cua.installer.update.applied": "Update applied: {version}.\n\n{log}", - "settings.cua.installer.update.failed": "Update failed.\n\n{log}", - "settings.navCua": "CUA Driver", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index cca0964f9..b194612cc 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1129,117 +1129,4 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.hooksActiveHooks": "已启用", "settings.hooksInactiveHooks": "已禁用", "settings.hooksSelectedEvent": "当前事件", - "settings.cua.title": "CUA(Computer Use Agent)驱动", - "settings.cua.enable": "启用 CUA", - "settings.cua.enableDesc": - "开启后,Agent 可通过 cua_* 工具操作电脑界面(窗口枚举、截屏、点击、输入等)。", - "settings.cua.enableDescUnsupported": "当前平台尚未实现 CUA 驱动;启用后所有操作都会失败。", - "settings.cua.platform": "驱动平台", - "settings.cua.platformDesc": "由 Rust 端按编译目标决定(macOS / Windows / Linux)。", - "settings.cua.platformNotes.macos.accessibility": - "需要授予 LiveAgent「辅助功能」权限,用于向系统发送鼠标 / 键盘事件。", - "settings.cua.platformNotes.macos.screenRecording": - "需要授予 LiveAgent「屏幕录制」权限,用于截屏。", - "settings.cua.platformNotes.macos.dragAndDrop": - "拖拽需要先安装 `cliclick`(brew install cliclick)。", - "settings.cua.platformNotes.unsupported": "驱动尚未实现;启用后所有 CUA 操作都会失败", - "settings.cua.allowedOwners": "允许的目标应用", - "settings.cua.allowedOwnersDesc": - "非空时生效,名单内的 owner 名才允许 focus / 输入 / 点击。截屏与窗口枚举不受名单限制。", - "settings.cua.allowedOwnersHint": "例:Finder, Safari, Terminal", - "settings.cua.trustModeGroup": "信任模式", - "settings.cua.trustMode": "信任 CUA 工具", - "settings.cua.trustModeDesc": - "关闭时所有 cua_* 工具每次调用都会弹审批(默认)。开启后不再逐次弹窗,但 CUA 仍受后端白名单与审计日志约束。", - "settings.cua.trustModeHint": - "CUA 会接管鼠标键盘,启用后相当于把控制权交给 Agent;截图等敏感操作也会被捕获。建议日常保持关闭,仅在批量自动化场景下临时开启。", - "settings.cua.sandboxOfflineBanner": - "当前命令安全模式是 sandboxOffline(断网沙箱)。CUA 工具已全部禁用,避免 CUA 子进程绕过强制断网。切换命令安全模式后可重新启用。", - "settings.cua.enableDescSandboxOffline": - "当前命令安全模式是 sandboxOffline;CUA 已禁用,无法开启。", - "settings.cua.audit": "审计日志", - "settings.cua.auditLimit": "审计日志上限", - "settings.cua.auditLimitDesc": "内存中保留最近 N 条 CUA 操作(0 = 关闭)。", - "settings.cua.auditCurrent": "当前 {count} 条", - "settings.cua.auditEmpty": "暂无 CUA 操作记录。", - "settings.cua.auditClear": "清空日志", - "settings.cua.saving": "正在同步 CUA 配置…", - "settings.cua.errors.fetch": "无法读取 CUA 状态", - "settings.cua.errors.save": "保存 CUA 配置失败", - "cua.errors.disabled": "CUA 未启用。请到「设置 → CUA 驱动」打开总开关后再试。", - "cua.errors.deniedByAllowlist": "CUA 操作被拒绝:目标「{target}」不在白名单 {allowed} 中。", - "cua.errors.unsupportedPlatform": - "CUA 驱动在当前平台({platform})不可用,目前仅在 macOS 上实现。", - "cua.errors.permissionRequired": - "CUA 操作需要 macOS 权限({permission})。请到「系统设置 → 隐私与安全」授予 LiveAgent「{permission}」权限后重试。", - "cua.errors.notExecuted": "CUA 操作未执行:{detail}", - "cua.errors.io": "CUA 子进程 IO 错误:{detail}", - "cua.errors.unknown": "未知原因", - // CUA-060: cua_screenshot / cua_type / cua_key 拿到具体 owner 但 - // list_apps + list_windows 找不到对应进程 / 窗口时的结构化错误, - // 替代原先「静默回退 get_desktop_state 后被 CUA-051 后置防线翻 - // 译成 TCC 失效」的掩盖行为。 - "cua.errors.ownerNotFound": - "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", - "cua.errors.ownerNotFoundDetail": - "找不到 owner「{owner}」对应的运行窗口。请确认应用名 / bundle id 拼写正确,且该应用至少有 1 个可见窗口后再重试。", - // CUA-051: cua-driver 没有在 CuaDriver.app bundle 内启动导致 - // ScreenCaptureKit attribution 失效 → get_desktop_state 返回全黑帧。 - "cua.errors.screenCaptureUnavailable": - "CUA 截屏不可用:{detail}。请把 cua-driver 在 CuaDriver.app 内启动(TCC Screen Recording 会归属到 com.trycua.driver),或到「系统设置 → 隐私与安全 → 屏幕录制」重新授权 CuaDriver 后再试。", - "cua.errors.sandboxOffline": - "当前命令安全模式是 sandboxOffline,CUA 工具已全部禁用。请把命令安全模式切换为 ask / auto / sandbox 后再使用 CUA。", - // CUA-100 系列:driver 安装器错误 - "cua.errors.installer.networkUnavailable": - "CUA 驱动安装器无法连接网络:{detail}。请检查网络后重试。", - "cua.errors.installer.curlFailed": "CUA 驱动安装脚本下载失败:{detail}", - "cua.errors.installer.signatureInvalid": "CUA 驱动安装脚本签名校验失败:{detail}", - "cua.errors.installer.permissionDenied": "CUA 驱动安装器需要更高权限:{detail}", - "cua.errors.installer.unsupportedPlatform": "CUA 驱动安装器在当前平台({platform})不可用。", - "cua.errors.installer.timeout": - "CUA 驱动安装超过 {minutes} 分钟仍未完成,下载可能已卡住,请重试。", - // CUA Driver 安装器面板(CUA-100) - "settings.cua.installer.title": "CUA Driver", - "settings.cua.installer.description": - "LiveAgent 自身不内置 cua-driver;它由 cua.ai 官方脚本下载安装。安装脚本从 cua.ai 拉取、不需要 sudo(Linux 桌面端除外)。", - "settings.cua.installer.install": "下载并安装 cua-driver", - "settings.cua.installer.restartDaemon": "启动 daemon", - "settings.cua.installer.checkUpdate": "检查更新", - "settings.cua.installer.applyUpdate": "应用更新", - "settings.cua.installer.refresh": "重新检测", - "settings.cua.installer.dismiss": "关闭", - "settings.cua.installer.statusUnknown": "状态未知", - "settings.cua.installer.statusNotInstalled": "未安装", - "settings.cua.installer.statusInstalledNotRunning": "已安装,daemon 未运行", - "settings.cua.installer.statusInstalledRunning": "已安装,daemon 正在运行", - "settings.cua.installer.versionLabel": "v{version}", - "settings.cua.installer.versionUnknown": "已安装(版本未知)", - "settings.cua.installer.daemonStarted": "daemon 已启动", - "settings.cua.installer.daemonNotStarted": "daemon 未启动(首次使用时再启动)", - "settings.cua.installer.installSuccess": "安装成功:{version};{daemon}", - "settings.cua.installer.linuxMissingDeps": - "Linux 桌面端需要以下系统包,请先在终端执行 `sudo apt install {packages}` 后再继续安装。", - "settings.cua.installer.macosPermissionsTitle": "macOS 权限提示", - "settings.cua.installer.macosPermissionsBody": - "请到「系统设置 → 隐私与安全」授予 CuaDriver:\n· 辅助功能(Accessibility)\n· 屏幕录制(Screen Recording)\n完成后把 LiveAgent 切到前台即可使用。\n\n如果 `cua_screenshot` 拿到的画面是全黑,cua-driver 大概率没在 CuaDriver.app bundle 内启动,TCC Screen Recording 没有归属到 com.trycua.driver。建议用 `open -n -g -a CuaDriver --args serve`(启动 daemon)或重新运行安装脚本走 bundle 路径。", - "settings.cua.installer.macosOpenSettings": "打开系统设置", - "settings.cua.installer.logLabel": "安装日志", - "settings.cua.installer.stage.starting": "准备安装…", - "settings.cua.installer.stage.downloading": "下载官方安装脚本…", - "settings.cua.installer.stage.installing": "正在安装…", - "settings.cua.installer.stage.startingDaemon": "启动 daemon…", - "settings.cua.installer.stage.checkingUpdate": "检查更新…", - "settings.cua.installer.stage.applyingUpdate": "应用更新…", - "settings.cua.installer.errors.invoke": "无法调用后端 CUA 安装器命令。", - "settings.cua.installer.errors.unknown": "未知错误", - "settings.cua.installer.errors.exception": "异常:{message}", - "settings.cua.installer.errors.daemonStart": - "启动 daemon 失败。请检查 cua-driver 是否已正确安装。", - // CUA-028: 检查/应用更新是非错误结果——按 info(蓝)或 success(绿)渲染。 - "settings.cua.installer.update.available": - "检测到新版本,请点击「检查更新」旁的 apply 按钮应用。\n\n{log}", - "settings.cua.installer.update.upToDate": "已是最新版本。\n\n{log}", - "settings.cua.installer.update.applied": "更新成功:{version}。\n\n{log}", - "settings.cua.installer.update.failed": "更新失败。\n\n{log}", - "settings.navCua": "CUA 驱动", } as const satisfies Record; diff --git a/crates/agent-ui/src/lib/cua/formatCuaError.ts b/crates/agent-ui/src/lib/cua/formatCuaError.ts deleted file mode 100644 index 776a93fad..000000000 --- a/crates/agent-ui/src/lib/cua/formatCuaError.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Locale } from "@liveagent/app/i18n/config"; - -export type CuaErrorPayload = { - kind: string; - message: string; - params?: Record | null; -}; - -/** - * 把 Rust CUA 错误按 locale 翻译成人类可读消息。 - * - * 后端仅返回稳定的 `kind` + i18n 模板参数 + 英文兜底;这里照 key 查 i18n - * map,并按 `{xxx}` 占位填参数(CUA-006)。模板语法与现有 i18n 一致: - * 见 `sharedTranslations.ts`。 - * - * 如果 `t` 函数查不到对应 key(理论不会发生,前端 i18n 完整覆盖),回到 - * `error.message`(英文)保证用户至少能看到原因。 - */ -export function formatCuaError( - error: CuaErrorPayload | null | undefined, - t: (key: string, locale: Locale) => string, - locale: Locale, -): string { - if (!error) return ""; - const template = t(error.kind, locale); - if (!template || template === error.kind) { - // fallback: 整段透传英文 message - return error.message; - } - if (!error.params) return template; - return interpolate(template, error.params); -} - -function interpolate(template: string, params: Record): string { - return template.replace(/\{(\w+)\}/g, (match, name: string) => { - const value = params[name]; - if (value === undefined || value === null) return match; - if (Array.isArray(value)) return value.join(", "); - if (typeof value === "string" || typeof value === "number") return String(value); - return JSON.stringify(value); - }); -} - -/** - * Tauri 2 收到 Rust 侧的 `Err(E)` 后会把 `E` JSON 化返回; - * `window.__TAURI_INTERNALS__.invoke` 拒绝时附的是原生 JS `Error`, - * `message` 是 JSON 字符串。在另外一些路径上调用方直接拿到了对象本身。 - * 两种 shape 都要认,按类型嗅探后归一化为 `CuaErrorPayload | null`。 - */ -export function normalizeCuaError(input: unknown): CuaErrorPayload | null { - if (input == null) return null; - if (typeof input === "object" && typeof (input as CuaErrorPayload).kind === "string") { - return input as CuaErrorPayload; - } - if (input instanceof Error) { - return parseErrorMessage(input.message); - } - if (typeof input === "string") { - return parseErrorMessage(input); - } - return null; -} - -function parseErrorMessage(raw: string): CuaErrorPayload | null { - const trimmed = raw.trim(); - if (!trimmed.startsWith("{")) return null; - try { - const parsed = JSON.parse(trimmed) as { kind?: unknown; message?: unknown }; - if (typeof parsed.kind === "string" && typeof parsed.message === "string") { - return parsed as CuaErrorPayload; - } - } catch { - // 不是 JSON,忽略。 - } - return null; -} - -/** - * 把任意错误输入收敛成人类可读文本:先尝试解析成结构化错误后翻译; - * 退到 `String(input)` 用于 mock / 测试 fixture。 - */ -export function formatUnknownCuaError( - input: unknown, - t: (key: string, locale: Locale) => string, - locale: Locale, -): string { - const structured = normalizeCuaError(input); - if (structured) { - return formatCuaError(structured, t, locale); - } - if (input instanceof Error) return input.message; - if (typeof input === "string") return input; - return String(input); -} diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index 640f2654e..9c7dc6056 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -67,7 +67,6 @@ import type { CloseWindowBehavior, CodexRequestFormat, CommandSafetyMode, - CuaSettings, CustomProvider, CustomSettings, EffectivePromptSettings, @@ -1594,7 +1593,6 @@ export function getDefaultSettings(): AppSettings { theme: "light", locale: detectSystemLocale(), closeWindowBehavior: "minimize", - cua: normalizeCuaSettings({}), }; } @@ -1636,27 +1634,9 @@ export function normalizeSettings(input?: Partial | null): AppSetti theme: normalizeTheme(obj.theme), locale: normalizeLocale(locale), closeWindowBehavior: normalizeCloseWindowBehavior(obj.closeWindowBehavior), - cua: normalizeCuaSettings(obj.cua ?? defaults.cua), }; } -export function normalizeCuaSettings(input: unknown): CuaSettings { - const obj = (input && typeof input === "object" ? input : {}) as Partial; - const enabled = obj.enabled === true; - const allowedOwners = Array.isArray(obj.allowedOwners) - ? obj.allowedOwners - .filter((s): s is string => typeof s === "string") - .map((s) => s.trim()) - .filter((s) => s.length > 0) - : []; - const auditLogLimit = - typeof obj.auditLogLimit === "number" && Number.isFinite(obj.auditLogLimit) - ? Math.max(0, Math.min(1000, Math.floor(obj.auditLogLimit))) - : 100; - const trustMode = obj.trustMode === true; - return { enabled, allowedOwners, auditLogLimit, trustMode }; -} - export function updateSystem(prev: AppSettings, patch: Partial): AppSettings { return normalizeSettings({ ...prev, diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index 638b01d9b..882f24f98 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -582,28 +582,6 @@ export type AppSettings = { locale: Locale; /** Desktop-only: close title-bar X to hide to tray or exit the application. */ closeWindowBehavior: CloseWindowBehavior; - /** - * CUA (Computer Use Agent) 驱动配置。决定 `cua_*` 工具组是否在 Agent - * 工具注册表中挂载——总开关 = 工具是否对模型可见;白名单决定输入 / - * 焦点 / 点击能否进入目标应用。 - * - * 桌面端 MVP:存 localStorage(与 skills / chatRuntimeControls / 主题 - * 同源),不写 SQLite。`liveagent_lib` 后端持一份镜像(启动时由 - * `cua_set_config` 同步),运行期修改立即下发。 - */ - cua: CuaSettings; -}; - -export type CuaSettings = { - enabled: boolean; - /** 允许的目标应用名(owner)列表;非空时仅这些 owner 的输入/点击会被放行。 */ - allowedOwners: string[]; - /** 审计日志上限(最近 N 条),默认 100。0 视作关闭。 */ - auditLogLimit: number; - /** 「信任模式」开关:开启后 `group:cua` 工具在前端不再弹审批(用户 - * 自担风险)。关闭时由 `toolPolicy.ts` 走默认 `ask`(CUA-reviewer - * 要求:默认逐次审批,显式 trust 才免审)。 */ - trustMode: boolean; }; export const CODEX_REQUEST_FORMAT_LABELS: Record = { diff --git a/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx b/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx index 6ac575057..34ebef5bf 100644 --- a/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx +++ b/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx @@ -8,6 +8,7 @@ import { Button } from "@liveagent/ui/components/ui/button"; import { useLocale } from "@liveagent/ui/i18n/index"; import { rankFuzzySearchResults } from "@liveagent/ui/lib/shared/fuzzySearch"; import { useMemo } from "react"; +import { effectiveServerPolicyDefault } from "../../contracts/mcpServerDefaults"; import { McpServerCard } from "./McpServerCard"; export { McpServerEditModal } from "./McpServerEditModal"; @@ -88,12 +89,18 @@ export function McpServersForm(props: McpServersFormProps) { searchQuery={query} setSettings={setSettings} onEdit={() => onEditServer?.(server, idx)} - policy={settings.system.toolPolicies?.[serverPolicyKey(server.id)] ?? "allow"} + policy={ + settings.system.toolPolicies?.[serverPolicyKey(server.id)] ?? + effectiveServerPolicyDefault(server.id) + } onPolicyChange={(next) => setSettings((prev) => { const current = { ...(prev.system.toolPolicies ?? {}) }; const key = serverPolicyKey(server.id); - if (next === "allow") delete current[key]; + // 只有回到该 server 的缺省值才删 key——对普通 server 缺省是 + // allow,对硬编码为 ask 的 server(cua-driver)则相反:显式 + // 存下 "allow" 才能盖过缺省,删掉反而会退回 ask。 + if (next === effectiveServerPolicyDefault(server.id)) delete current[key]; else current[key] = next; return updateSystem(prev, { toolPolicies: Object.keys(current).length > 0 ? current : undefined, diff --git a/crates/agent-ui/src/pages/settings/CuaInstaller.ts b/crates/agent-ui/src/pages/settings/CuaInstaller.ts deleted file mode 100644 index b6d93161b..000000000 --- a/crates/agent-ui/src/pages/settings/CuaInstaller.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * CUA Driver 安装器共享 TS 类型。Agent-UI 跨 desktop / web 共用。 - * - * 后端字段命名约定:Rust 侧全部用 camelCase,serde 自动 rename;前端拿到 - * 的是扁平结构。`stage` 是稳定字符串 literal,前端按 i18n key 翻译。 - */ - -export type InstallerStage = - | "starting" - | "downloading" - | "installing" - | "startingDaemon" - | "completed" - | "failed" - | "cancelled"; - -export type InstallerProgressEvent = { - stage: InstallerStage; - message: string; - logTail?: string; - percent?: number; -}; - -export type CuaDriverDetection = { - installed: boolean; - version?: string; - path?: string; - platform: string; - appBundleInstalled?: boolean; - daemonRunning: boolean; - doctorOutput?: string; - error?: string; -}; - -export type CuaInstallResult = { - success: boolean; - log: string; - installedVersion?: string; - daemonStarted: boolean; - error?: string; -}; - -export type CuaUpdateResult = { - updateAvailable: boolean; - log: string; - newVersion?: string; - error?: string; -}; - -export type InstallCommand = { - program: string; - args: string[]; - description: string; - needsSudo: boolean; -}; - -export type InstallPreview = { - platform: string; - command: InstallCommand; - linuxAptAvailable?: boolean; - linuxMissingPackages?: string[]; -}; - -/** - * Tauri 端 `cua_install_progress` 事件名。 - * 桌面端通过 `@tauri-apps/api/event` 的 `listen` 订阅。 - */ -export const CUA_INSTALL_PROGRESS_EVENT = "cua_install_progress"; - -/** - * CUA Driver 安装器服务——由 host(桌面端)注入。 - * 与 `CuaService` 合并使用;WebUI 不会提供。 - */ -export type CuaInstallerService = { - detectDriver: () => Promise; - installDriver: () => Promise; - updateDriver: (apply: boolean) => Promise; - startDriverDaemon: () => Promise; - getInstallPreview: () => Promise; - /** - * 订阅后端 `cua_install_progress` 事件。可选;只有桌面端实现会提供。 - * 返回 unlisten 句柄,用于在 React 卸载时清理。 - */ - subscribeProgress?: (handler: (event: InstallerProgressEvent) => void) => Promise<() => void>; -}; diff --git a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx b/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx deleted file mode 100644 index ac562faf5..000000000 --- a/crates/agent-ui/src/pages/settings/CuaInstallerPanel.tsx +++ /dev/null @@ -1,677 +0,0 @@ -/** - * CUA Driver 安装器面板。 - * - * 显示当前 cua-driver 状态(安装 / 版本 / daemon);提供安装 / 重启 - * daemon / 更新三组操作;安装进度通过 `subscribeProgress` 回调实时反映。 - * - * MVP 范围:检测 + 安装 + 启动 daemon + 简单更新检查;复杂的更新策略 - * / 多通道 / 高级诊断留给后续迭代。 - */ -import { - AlertTriangle, - CheckCircle2, - ChevronDown, - CloudDownload, - ExternalLink, - Play, - RefreshCw, -} from "@liveagent/ui/components/IconSet"; -import { Button } from "@liveagent/ui/components/ui/button"; -import { useLocale } from "@liveagent/ui/i18n/index"; -import { cn } from "@liveagent/ui/lib/shared/utils"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; - -import type { - CuaDriverDetection, - CuaInstallerService, - CuaUpdateResult, - InstallerProgressEvent, - InstallPreview, -} from "./CuaInstaller"; -import { SettingsGroup } from "./shared"; - -export type CuaInstallerPanelProps = { - service: CuaInstallerService; - /** 来自后端的当前 OS 标签("macos" / "windows" / "linux")。 */ - platform: string; -}; - -type InstallFlowState = - | { kind: "idle" } - | { kind: "running"; stage: string; message: string; percent: number | null; logTail: string } - | { kind: "success"; message: string; log?: string } - | { kind: "info"; message: string; log?: string } - | { kind: "error"; errorText: string; log?: string }; - -function statusTone(detection: CuaDriverDetection | null): { - badge: string; - labelKey: string; -} { - if (!detection) { - return { - badge: "bg-muted text-muted-foreground", - labelKey: "settings.cua.installer.statusUnknown", - }; - } - if (!detection.installed) { - return { - badge: "bg-rose-500/10 text-rose-600 dark:text-rose-400", - labelKey: "settings.cua.installer.statusNotInstalled", - }; - } - if (!detection.daemonRunning) { - return { - badge: "bg-amber-500/10 text-amber-700 dark:text-amber-400", - labelKey: "settings.cua.installer.statusInstalledNotRunning", - }; - } - return { - badge: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", - labelKey: "settings.cua.installer.statusInstalledRunning", - }; -} - -function formatPercent(value: number | null | undefined): number { - if (value == null) return 0; - if (value < 0) return 0; - if (value > 100) return 100; - return Math.round(value); -} - -function permissionDeepLink(platform: string): string | null { - if (platform === "macos") { - // x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility - return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; - } - return null; -} - -/** - * 通用占位符替换。`t(key)` 拿到模板,再把 `{name}` 替换为传入值。 - * 与现有 i18n 模式保持一致(见 formatCuaError.interpolate)。 - */ -function fillTemplate( - template: string, - params: Record, -): string { - return template.replace(/\{(\w+)\}/g, (match, name: string) => { - const value = params[name]; - if (value === undefined || value === null) return match; - return String(value); - }); -} - -export function CuaInstallerPanel(props: CuaInstallerPanelProps) { - const { service, platform } = props; - const { t } = useLocale(); - const [detection, setDetection] = useState(null); - const [preview, setPreview] = useState(null); - const [loading, setLoading] = useState(false); - const [flow, setFlow] = useState({ kind: "idle" }); - const [logExpanded, setLogExpanded] = useState(false); - const [errorBanner, setErrorBanner] = useState(null); - const unlistenRef = useRef<(() => void) | null>(null); - const chevronRef = useRef(null); - - // CUA-044: WKWebView (Tauri WebView) 在 hidden / 隐藏式面板里会丢 className - // 切换的 layout flush——Tailwind rotate-180 编译后的 transform: rotate(180deg) - // 已经写入 stylesheet 但下一帧之前没有触发同步 layout,导致视觉不旋转。 - // useLayoutEffect 在 React commit 后、浏览器 paint 前同步运行;显式读 - // offsetWidth 强制一次同步 reflow,让 className 切换被立即提交。 - // (不再使用 inline style.transform——之前几轮的 inline style 路径在 - // WKWebView 中会被丢弃,className 走样式表 pipeline 更稳。) - // biome-ignore lint/correctness/useExhaustiveDependencies: logExpanded is the trigger — re-run after every expand/collapse so the WebView reflows and the rotate-180 class actually paints. - useLayoutEffect(() => { - const el = chevronRef.current; - if (!el) return; - void el.offsetWidth; - }, [logExpanded]); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const [next, pv] = await Promise.all([service.detectDriver(), service.getInstallPreview()]); - setDetection(next); - setPreview(pv); - } finally { - setLoading(false); - } - }, [service]); - - useEffect(() => { - void refresh(); - }, [refresh]); - - // 监听 install 进度事件——通过 service.subscribeProgress 桥接,避免 - // agent-ui 直接依赖 @tauri-apps/api。 - useEffect(() => { - let cancelled = false; - (async () => { - if (!service.subscribeProgress) return; - try { - const unlisten = await service.subscribeProgress((payload: InstallerProgressEvent) => { - setFlow((current) => { - if (current.kind !== "running") return current; - return { - kind: "running", - stage: payload.stage, - message: payload.message, - percent: payload.percent ?? null, - logTail: payload.logTail ?? current.logTail, - }; - }); - if (payload.stage === "failed") { - setFlow({ - kind: "error", - errorText: payload.message, - log: payload.logTail, - }); - } - }); - if (cancelled) { - unlisten(); - } else { - unlistenRef.current = unlisten; - } - } catch (err) { - console.warn("[cua-installer] failed to subscribe progress event", err); - } - })(); - return () => { - cancelled = true; - if (unlistenRef.current) { - try { - unlistenRef.current(); - } catch { - // ignore - } - unlistenRef.current = null; - } - }; - }, [service]); - - const startInstall = useCallback(async () => { - setErrorBanner(null); - setFlow({ - kind: "running", - stage: "starting", - message: t("settings.cua.installer.stage.starting"), - percent: 0, - logTail: "", - }); - try { - const result = await service.installDriver(); - if (!result) { - setFlow({ - kind: "error", - errorText: t("settings.cua.installer.errors.invoke"), - }); - return; - } - if (result.success) { - setFlow({ - kind: "success", - message: fillTemplate(t("settings.cua.installer.installSuccess"), { - version: result.installedVersion ?? "—", - daemon: result.daemonStarted - ? t("settings.cua.installer.daemonStarted") - : t("settings.cua.installer.daemonNotStarted"), - }), - log: result.log, - }); - } else { - setFlow({ - kind: "error", - errorText: result.error ?? t("settings.cua.installer.errors.unknown"), - log: result.log, - }); - } - await refresh(); - } catch (err) { - setFlow({ - kind: "error", - errorText: errorTextFromUnknown(err, t), - }); - } - }, [service, refresh, t]); - - const restartDaemon = useCallback(async () => { - setErrorBanner(null); - setFlow({ - kind: "running", - stage: "startingDaemon", - message: t("settings.cua.installer.stage.startingDaemon"), - percent: 90, - logTail: "", - }); - try { - const ok = await service.startDriverDaemon(); - if (ok) { - setFlow({ kind: "idle" }); - } else { - setFlow({ - kind: "error", - errorText: t("settings.cua.installer.errors.daemonStart"), - }); - } - await refresh(); - } catch (err) { - setFlow({ - kind: "error", - errorText: errorTextFromUnknown(err, t), - }); - } - }, [service, refresh, t]); - - const checkUpdate = useCallback(async () => { - setErrorBanner(null); - setFlow({ - kind: "running", - stage: "starting", - message: t("settings.cua.installer.stage.checkingUpdate"), - percent: 0, - logTail: "", - }); - try { - const result = await service.updateDriver(false); - if (!result) { - setFlow({ - kind: "error", - errorText: t("settings.cua.installer.errors.invoke"), - }); - return; - } - if (result.updateAvailable) { - // 检测到新版本:语义上是 info(蓝),不是失败。 - setFlow({ - kind: "info", - message: fillTemplate(t("settings.cua.installer.update.available"), { - log: result.log.slice(0, 2000), - }), - log: result.log.slice(0, 2000), - }); - } else { - // 已是最新:语义上是 success(绿),不是失败。 - setFlow({ - kind: "success", - message: fillTemplate(t("settings.cua.installer.update.upToDate"), { - log: result.log.slice(0, 2000), - }), - log: result.log.slice(0, 2000), - }); - } - } catch (err) { - setFlow({ - kind: "error", - errorText: errorTextFromUnknown(err, t), - }); - } - }, [service, t]); - - const applyUpdate = useCallback(async () => { - setErrorBanner(null); - setFlow({ - kind: "running", - stage: "installing", - message: t("settings.cua.installer.stage.applyingUpdate"), - percent: 50, - logTail: "", - }); - try { - const result = (await service.updateDriver(true)) as CuaUpdateResult | null; - if (!result) { - setFlow({ - kind: "error", - errorText: t("settings.cua.installer.errors.invoke"), - }); - return; - } - // updateDriver(apply=true) 由后端走 spawn_blocking 跑 update --apply。 - // 必须按 result.error / result.updateAvailable / result.newVersion 三态区分: - // - error 非空 → error (红) - // - updateAvailable=true → success (绿) 「更新成功」 - // - updateAvailable=false → success (绿) 「已是最新版本」 - // 旧实现只看 newVersion,已是最新版本时也会被误标红「更新失败」(CUA-032)。 - const logSlice = result.log.slice(0, 2000); - if (result.error) { - setFlow({ - kind: "error", - errorText: fillTemplate(t("settings.cua.installer.update.failed"), { - log: logSlice, - }), - log: logSlice, - }); - } else if (result.updateAvailable && result.newVersion) { - setFlow({ - kind: "success", - message: fillTemplate(t("settings.cua.installer.update.applied"), { - version: result.newVersion, - log: logSlice, - }), - log: logSlice, - }); - } else { - // 后端报告 updateAvailable=false、error=null:已是最新版本,按 success 渲染。 - setFlow({ - kind: "success", - message: fillTemplate(t("settings.cua.installer.update.upToDate"), { - log: logSlice, - }), - log: logSlice, - }); - } - await refresh(); - } catch (err) { - setFlow({ - kind: "error", - errorText: errorTextFromUnknown(err, t), - }); - } - }, [service, refresh, t]); - - const dismissFlow = useCallback(() => { - setFlow({ kind: "idle" }); - setLogExpanded(false); - setErrorBanner(null); - }, []); - - const tone = useMemo(() => statusTone(detection), [detection]); - const isRunning = flow.kind === "running"; - const macosPermissionsPath = permissionDeepLink(platform); - - return ( - -
-
- - {detection?.installed && detection.daemonRunning ? ( - - ) : ( - - )} - {t(tone.labelKey)} - - {detection?.installed ? ( - - {detection.version - ? fillTemplate(t("settings.cua.installer.versionLabel"), { - version: detection.version, - }) - : t("settings.cua.installer.versionUnknown")} - - ) : null} - {detection?.path ? ( - - {detection.path} - - ) : null} - -
- -

- {t("settings.cua.installer.description")} -

- - {/* Action buttons */} -
- {!detection?.installed ? ( - - ) : null} - {detection?.installed && !detection.daemonRunning ? ( - - ) : null} - {detection?.installed ? ( - - ) : null} - {detection?.installed ? ( - - ) : null} -
- - {/* Install preview info — Linux deps / command description */} - {preview && !detection?.installed ? ( -
-

- {`${preview.command.program} ${preview.command.args - .map((a) => (a.includes(" ") ? JSON.stringify(a) : a)) - .join(" ")}`} -

- {preview.linuxMissingPackages && preview.linuxMissingPackages.length > 0 ? ( -

- {fillTemplate(t("settings.cua.installer.linuxMissingDeps"), { - packages: preview.linuxMissingPackages.join(", "), - })} -

- ) : null} -
- ) : null} - - {/* macOS permissions card */} - {platform === "macos" && detection?.installed ? ( -
-

{t("settings.cua.installer.macosPermissionsTitle")}

-

- {t("settings.cua.installer.macosPermissionsBody")} -

- {macosPermissionsPath ? ( - - {t("settings.cua.installer.macosOpenSettings")} - - - ) : null} -
- ) : null} - - {/* Running / progress */} - {isRunning ? ( -
-
-
-
-

- {flow.kind === "running" ? flow.message : ""} -

-
- ) : null} - - {/* Terminal-style log — error/info/success 都允许展开日志,便于看完整输出。 */} - {(isRunning && flow.kind === "running" && flow.logTail) || - flow.kind === "error" || - flow.kind === "info" || - flow.kind === "success" ? ( -
- - {logExpanded ? ( -
-                {flow.kind === "running"
-                  ? flow.logTail
-                  : flow.kind === "error"
-                    ? (flow.log ?? flow.errorText)
-                    : flow.kind === "info" || flow.kind === "success"
-                      ? (flow.log ?? flow.message)
-                      : ""}
-              
- ) : null} -
- ) : null} - - {/* Final state messages */} - {flow.kind === "success" ? ( -
-

{flow.message}

- -
- ) : null} - - {flow.kind === "info" ? ( -
-

{flow.message}

- -
- ) : null} - - {flow.kind === "error" ? ( -
-

{flow.errorText}

- -
- ) : null} - - {errorBanner ? ( -
- {errorBanner} -
- ) : null} -
- - ); -} - -function errorTextFromUnknown(err: unknown, t: (key: string) => string): string { - if (err instanceof Error) { - return fillTemplate(t("settings.cua.installer.errors.exception"), { message: err.message }); - } - if (typeof err === "string") return err; - return t("settings.cua.installer.errors.unknown"); -} diff --git a/crates/agent-ui/src/pages/settings/CuaSection.tsx b/crates/agent-ui/src/pages/settings/CuaSection.tsx deleted file mode 100644 index ff5b773e7..000000000 --- a/crates/agent-ui/src/pages/settings/CuaSection.tsx +++ /dev/null @@ -1,431 +0,0 @@ -import { Eye, EyeOff, MonitorSmartphone, Trash2 } from "@liveagent/ui/components/IconSet"; -import { Input } from "@liveagent/ui/components/ui/input"; -import { useLocale } from "@liveagent/ui/i18n/index"; -import { type CuaErrorPayload, formatCuaError } from "@liveagent/ui/lib/cua/formatCuaError"; -import { cn } from "@liveagent/ui/lib/shared/utils"; -import { errorMessageWithFallback } from "@liveagent/ui/lib/shared/value"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { CuaInstallerService } from "./CuaInstaller"; -import { CuaInstallerPanel } from "./CuaInstallerPanel"; -import { AgentActivationSwitch, SettingsGroup, SettingsRow } from "./shared"; - -export type CuaSettings = { - enabled: boolean; - allowedOwners: string[]; - auditLogLimit: number; - /** 「信任模式」开关:开启后 `group:cua` 工具在前端不再弹审批 - * (CUA-reviewer 要求:默认逐次审批)。 */ - trustMode: boolean; -}; - -export type CuaAuditEntry = { - timestamp: string; - operation: string; - ok: boolean; - /** 失败原因——结构化 payload(kind + params + 英文 message),由 UI - * 按当前 locale 翻译(CUA-006)。 */ - error?: CuaErrorPayload; - detail?: unknown; -}; - -export type CuaStatus = { - config: CuaSettings; - platform: string; - /** cua-driver 是否可用(installed + daemon running)。与 config.enabled - * 独立——开关在前端 UI / backend CuaStore,driver 在 installer 路径。 */ - available: boolean; - /** 后端 CuaRuntimeConfig.sandboxOffline 镜像。当前命令安全模式是 - * sandboxOffline 时为 true;UI 据此展示离线指示器与禁用状态。 */ - sandboxOffline?: boolean; - recent: CuaAuditEntry[]; -}; - -export type CuaService = { - fetchStatus: () => Promise; - setConfig: (config: CuaSettings) => Promise; - clearAudit: () => Promise; - /** 来自后端的平台标签("macos" / "windows" / "linux")。 */ - platformLabel: string; -} & Partial; - -export type CuaSectionProps = { - settings: CuaSettings; - setSettings: (updater: (prev: CuaSettings) => CuaSettings) => void; - /** 与 Rust 后端 CuaStore 通信的薄包装。设置面板挂载时由 host 注入。 */ - cuaService: CuaService; -}; - -type PlatformNote = { - label: string; - permissionKeys: string[]; - tone: string; - supported: boolean; -}; - -const PLATFORM_NOTES: Record = { - macos: { - label: "macOS", - permissionKeys: [ - "settings.cua.platformNotes.macos.accessibility", - "settings.cua.platformNotes.macos.screenRecording", - "settings.cua.platformNotes.macos.dragAndDrop", - ], - tone: "text-emerald-600 dark:text-emerald-400", - supported: true, - }, - windows: { - label: "Windows", - permissionKeys: ["settings.cua.platformNotes.unsupported"], - tone: "text-amber-600 dark:text-amber-400", - supported: false, - }, - linux: { - label: "Linux", - permissionKeys: ["settings.cua.platformNotes.unsupported"], - tone: "text-amber-600 dark:text-amber-400", - supported: false, - }, -}; - -function joinOwners(list: string[]): string { - return list - .map((s) => s.trim()) - .filter((s) => s.length > 0) - .join(", "); -} - -function parseOwners(text: string): string[] { - return text - .split(/[\n,]/) - .map((s) => s.trim()) - .filter((s) => s.length > 0); -} - -function formatTimestamp(iso: string): string { - try { - return new Date(iso).toLocaleString(); - } catch { - return iso; - } -} - -function statusLabel( - ok: boolean, - op: string, - error: CuaErrorPayload | undefined, - t: (key: string, locale: import("@liveagent/ui/i18n/index").Locale) => string, - locale: import("@liveagent/ui/i18n/index").Locale, -): string { - if (ok) return `${op} ✓`; - // CUA-006:失败原因从 Rust 拿到的是结构化 payload,按当前 locale 翻。 - // 后端 message 是英文兜底,正常都能查到 key;万一 fallback 到 message。 - const detail = error ? formatCuaError(error, t, locale) : ""; - return detail ? `${op} ✗ · ${detail}` : `${op} ✗`; -} - -export function CuaSection(props: CuaSectionProps) { - const { settings, setSettings, cuaService } = props; - const { t, locale } = useLocale(); - const [status, setStatus] = useState(null); - const [loadError, setLoadError] = useState(null); - const [ownersDraft, setOwnersDraft] = useState(joinOwners(settings.allowedOwners)); - const [persisting, setPersisting] = useState(false); - const lastSyncedSettingsRef = useRef(settings); - - const platform = status?.platform ?? cuaService.platformLabel ?? "macos"; - const platformNote = PLATFORM_NOTES[platform] ?? PLATFORM_NOTES.macos; - - // 初次挂载:拉一次后端状态。 - useEffect(() => { - let cancelled = false; - cuaService - .fetchStatus() - .then((s) => { - if (cancelled) return; - if (s) setStatus(s); - }) - .catch((err: unknown) => { - if (cancelled) return; - setLoadError(errorMessageWithFallback(err, t("settings.cua.errors.fetch"))); - }); - return () => { - cancelled = true; - }; - }, [cuaService, t]); - - // 当 settings 来自 props(被外部 reset 之类)时,把 owners draft 同步过去。 - useEffect(() => { - if (settings !== lastSyncedSettingsRef.current) { - setOwnersDraft(joinOwners(settings.allowedOwners)); - lastSyncedSettingsRef.current = settings; - } - }, [settings]); - - const persist = useCallback( - async (next: CuaSettings) => { - setPersisting(true); - try { - const updated = await cuaService.setConfig(next); - if (updated) { - setStatus(updated); - } - lastSyncedSettingsRef.current = next; - } catch (err) { - setLoadError(errorMessageWithFallback(err, t("settings.cua.errors.save"))); - } finally { - setPersisting(false); - } - }, - [cuaService, t], - ); - - const handleEnabledToggle = useCallback( - (value: boolean) => { - const next: CuaSettings = { ...settings, enabled: value }; - setSettings(() => next); - void persist(next); - }, - [settings, persist, setSettings], - ); - - const handleAuditLimitChange = useCallback( - (value: number) => { - const next: CuaSettings = { ...settings, auditLogLimit: value }; - setSettings(() => next); - void persist(next); - }, - [settings, persist, setSettings], - ); - - const handleTrustModeToggle = useCallback( - (value: boolean) => { - const next: CuaSettings = { ...settings, trustMode: value }; - setSettings(() => next); - void persist(next); - }, - [settings, persist, setSettings], - ); - - const handleOwnersCommit = useCallback(() => { - const next: CuaSettings = { - ...settings, - allowedOwners: parseOwners(ownersDraft), - }; - setSettings(() => next); - setOwnersDraft(joinOwners(next.allowedOwners)); - void persist(next); - }, [ownersDraft, settings, persist, setSettings]); - - const handleClearAudit = useCallback(async () => { - const updated = await cuaService.clearAudit(); - if (updated) setStatus(updated); - }, [cuaService]); - - const auditCount = status?.recent.length ?? 0; - const previewEntries = useMemo(() => (status?.recent ?? []).slice(-10).reverse(), [status]); - const sandboxOffline = status?.sandboxOffline === true; - - return ( -
- {sandboxOffline ? ( -
- {t("settings.cua.sandboxOfflineBanner")} -
- ) : null} - - handleEnabledToggle(!settings.enabled)} - title={t("settings.cua.enable")} - data-testid="cua-enable-switch" - data-cua-enabled={settings.enabled ? "true" : "false"} - disabled={sandboxOffline} - /> - } - /> - - - {platformNote.label} -
- } - /> -
-
    - {platformNote.permissionKeys.map((key) => ( -
  • - - {t(key)} -
  • - ))} -
-
- - - - handleTrustModeToggle(!settings.trustMode)} - title={t("settings.cua.trustMode")} - data-testid="cua-trust-mode-switch" - data-cua-trust-mode={settings.trustMode ? "true" : "false"} - /> - } - /> -
-

- {t("settings.cua.trustModeHint")} -

-
-
- - -
-

- {t("settings.cua.allowedOwnersDesc")} -

-
-
- setOwnersDraft(event.target.value)} - onBlur={handleOwnersCommit} - placeholder="Finder, Safari, Terminal" - className="font-mono text-xs" - data-testid="cua-allowed-owners-input" - /> -
- {t("settings.cua.allowedOwnersHint")} -
-
-
- - - - { - const value = Number(event.target.value); - if (Number.isFinite(value)) { - handleAuditLimitChange(value); - } - }} - className="w-24 text-xs" - data-testid="cua-audit-limit-input" - /> - - {t("settings.cua.auditCurrent").replace("{count}", String(auditCount))} - -
- } - /> -
- {previewEntries.length === 0 ? ( -

{t("settings.cua.auditEmpty")}

- ) : ( -
    - {previewEntries.map((entry, idx) => ( -
  • - - {entry.ok ? : } - - - {formatTimestamp(entry.timestamp)} - - - {statusLabel(entry.ok, entry.operation, entry.error, t, locale)} - -
  • - ))} -
- )} -
- -
-
-
- - {loadError ? ( -
- {loadError} -
- ) : null} - {persisting ? ( -
{t("settings.cua.saving")}
- ) : null} - - {cuaService.detectDriver && - cuaService.installDriver && - cuaService.updateDriver && - cuaService.startDriverDaemon && - cuaService.getInstallPreview ? ( - - ) : null} -
- ); -} diff --git a/crates/agent-ui/src/pages/settings/SettingsPage.tsx b/crates/agent-ui/src/pages/settings/SettingsPage.tsx index 80defe170..6ce52057a 100644 --- a/crates/agent-ui/src/pages/settings/SettingsPage.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsPage.tsx @@ -7,7 +7,6 @@ import { Clock3, Cloud, Cpu, - Hand, Key, Mic, Settings2, @@ -18,7 +17,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { SettingsSectionDefinition, UiExtensionRegistry } from "../../contracts/registry"; import { AgentsSection } from "./AgentsSection"; import { CronSection } from "./CronSection"; -import { CuaSection } from "./CuaSection"; import { HooksSection } from "./HooksSection"; import { MemoryPanel } from "./memory/MemoryPanel"; import { ProvidersSection } from "./ProvidersSection"; @@ -46,7 +44,6 @@ export function SettingsPage(props: SettingsPageProps) { hiddenSections = [], sttSettingsService, onSttProviderChange, - cuaService, } = props; const [pendingProviderId, setPendingProviderId] = useState(initialProviderId); const [sttSelectedProvider, setSttSelectedProvider] = useState( @@ -193,27 +190,6 @@ export function SettingsPage(props: SettingsPageProps) { icon: , render: () => , }, - ...(cuaService - ? [ - { - id: "cua", - groupKey: "settings.groupConnectivity", - groupOrder: 40, - order: 30, - labelKey: "settings.navCua", - icon: , - render: () => ( - - setSettings((prev) => ({ ...prev, cua: updater(prev.cua) })) - } - cuaService={cuaService} - /> - ), - }, - ] - : []), ...extension.sections, ], [ From 584c38e1f02007a67cc3a7c01973f217503bb629 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 13:15:38 +0800 Subject: [PATCH 22/28] =?UTF-8?q?feat(cua):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20CUA=20=E5=88=86=E5=8C=BA=EF=BC=8C=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E4=BB=A5=E5=BC=80=E5=85=B3=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在设置的「连接」分组下新增 CUA 分区(桌面端专属,经 agent-gui 的 settingsExtension 注册,WebUI 不挂载——探测/安装/授权都依赖 Tauri 命令)。布局对齐 RemoteSection:头部状态胶囊 + 总开关,下面卡片式 分组。 总开关 = cua-driver 这个 MCP server 的启用状态,没有第二份配置: - 打开且条目不存在时按 manifest 的 mcp_invocation 新建条目 - 关闭时只置 enabled=false 而不删除,避免丢掉用户在 MCP Hub 里改过 的 args / 超时 / 权限策略 后端新增 services/cua_driver 与 commands/integration/cua_driver: - cua_driver_probe:在 PATH 与 ~/.local/bin 等候选路径找二进制(GUI 进程从 Finder 启动时 PATH 不含官方安装脚本的默认落点),再问 `cua-driver manifest` 要 MCP 调用方式,不硬编码 ["mcp"] - cua_driver_install_command:只返回将要执行的命令全文,不执行 - cua_driver_install:转调官方 install.sh / install.ps1,输出经 cua_driver_install_progress 事件流式回传 - cua_driver_permissions_status / _grant:读取与申请 macOS TCC 安装是从网络下载并执行 shell 脚本,因此**必须**经用户确认:UI 先展示 cua_driver_install_command 返回的完整命令与来源域名,用户点确认后才 执行;也可以复制命令自行到终端跑。不提供静默安装路径。 macOS 上刻意不带 --direct:那会让 MCP 进程沿用 LiveAgent 的 TCC 归属, 要求宿主自己去拿辅助功能与屏幕录制授权。默认模式经 CuaDriver.app 的 守护进程代理,授权归它。 对话气泡显示优化:动态 MCP 工具此前全落进 default 分支(扳手图标 / other 分类),名字也是 `mcp_cua-driver_get_desktop_state` 这样的原始 长串。现在拆成 `cua-driver · get_desktop_state`,并按来源给图标—— cua-driver 用 Hand + terminal accent(它在真实点击、输入、关闭应用), 其余 MCP 工具用 Plug。 --- .../web/src/pages/settings/types.ts | 3 +- .../src/commands/integration/cua_driver.rs | 62 +++ .../src-tauri/src/commands/integration/mod.rs | 1 + .../agent-gui/src-tauri/src/commands/mod.rs | 1 + crates/agent-gui/src-tauri/src/lib.rs | 5 + .../src-tauri/src/services/cua_driver/mod.rs | 460 ++++++++++++++++++ .../agent-gui/src-tauri/src/services/mod.rs | 1 + .../agent-ui-adapters/settingsExtension.tsx | 15 +- crates/agent-gui/src/pages/settings/types.ts | 1 + .../assistant-bubble/assistantBubbleUtils.ts | 36 ++ .../src/i18n/translations/enUSSettings.ts | 40 ++ .../src/i18n/translations/zhCNSettings.ts | 39 ++ .../src/pages/settings/CuaDriverSection.tsx | 442 +++++++++++++++++ .../src/pages/settings/SettingsShell.tsx | 4 +- 14 files changed, 1105 insertions(+), 5 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs create mode 100644 crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs create mode 100644 crates/agent-ui/src/pages/settings/CuaDriverSection.tsx diff --git a/crates/agent-gateway/web/src/pages/settings/types.ts b/crates/agent-gateway/web/src/pages/settings/types.ts index d04298452..c8a27424b 100644 --- a/crates/agent-gateway/web/src/pages/settings/types.ts +++ b/crates/agent-gateway/web/src/pages/settings/types.ts @@ -15,7 +15,8 @@ export type SectionId = | "hooks" | "cron" | "devices" - | "remote"; + | "remote" + | "cua"; export type SettingsPageProps = { settings: AppSettings; diff --git a/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs b/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs new file mode 100644 index 000000000..5467a7631 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs @@ -0,0 +1,62 @@ +//! `cua-driver` 引导命令桥。 +//! +//! 只覆盖「装没装 / 装一下 / 授权了没」这段引导;真正的计算机操作能力 +//! 走通用 MCP 链路(`cua-driver mcp` 是一个普通 stdio MCP server,由 +//! `commands/integration/mcp.rs` 驱动,工具从 `tools/list` 发现)。 +//! +//! 放在 `integration/` 而不是自成一域,正是因为它属于 MCP 接入的一部分。 + +use tauri::AppHandle; + +use crate::services::cua_driver::{ + self, CuaDriverPermissions, CuaDriverProbe, InstallCommandPreview, +}; + +/// 探测二进制位置、版本与 MCP 调用方式。未安装返回 `installed: false`, +/// 不是错误。只读,无副作用。 +#[tauri::command(rename_all = "camelCase")] +pub async fn cua_driver_probe() -> Result { + tauri::async_runtime::spawn_blocking(cua_driver::probe) + .await + .map_err(|error| format!("cua_driver_probe join failed: {error}")) +} + +/// 返回将要执行的安装命令**全文**,不执行。 +/// +/// UI 必须先把 `display` 展示给用户并取得显式确认,才允许调 +/// `cua_driver_install`:那条命令会从网络下载一段 shell 脚本并直接 +/// 执行,用户有权先看清楚。 +#[tauri::command(rename_all = "camelCase")] +pub fn cua_driver_install_command() -> InstallCommandPreview { + cua_driver::install_command_preview() +} + +/// 执行官方安装脚本,进度经 `cua_driver_install_progress` 事件流式回传。 +/// +/// 前置条件由 UI 保证:用户已看过 `cua_driver_install_command` 的输出 +/// 并确认。这里不做二次弹窗——后端没有 UI 上下文,弹不出可信的确认。 +#[tauri::command(rename_all = "camelCase")] +pub async fn cua_driver_install(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || cua_driver::install(&app)) + .await + .map_err(|error| format!("cua_driver_install join failed: {error}"))? +} + +/// 读取 macOS 的 Accessibility / Screen Recording 授权状态。只读, +/// 不触发系统授权弹窗。非 macOS 返回 `supported: false`。 +#[tauri::command(rename_all = "camelCase")] +pub async fn cua_driver_permissions_status() -> Result { + tauri::async_runtime::spawn_blocking(cua_driver::permissions_status) + .await + .map_err(|error| format!("cua_driver_permissions_status join failed: {error}")) +} + +/// 触发上游的授权引导:拉起 CuaDriver.app 并请求两项权限。会弹系统 +/// 对话框——授权归属 CuaDriver.app(而非 LiveAgent),这是上游推荐的 +/// 唯一正确路径。 +#[tauri::command(rename_all = "camelCase")] +pub async fn cua_driver_permissions_grant() -> Result { + tauri::async_runtime::spawn_blocking(cua_driver::permissions_grant) + .await + .map_err(|error| format!("cua_driver_permissions_grant join failed: {error}"))? +} diff --git a/crates/agent-gui/src-tauri/src/commands/integration/mod.rs b/crates/agent-gui/src-tauri/src/commands/integration/mod.rs index ceb9e695a..c2cb68413 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/mod.rs @@ -1,3 +1,4 @@ +pub mod cua_driver; pub mod gateway; pub mod mcp; pub mod memory; diff --git a/crates/agent-gui/src-tauri/src/commands/mod.rs b/crates/agent-gui/src-tauri/src/commands/mod.rs index 9e1fbc982..ac3f8dcc1 100644 --- a/crates/agent-gui/src-tauri/src/commands/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/mod.rs @@ -28,6 +28,7 @@ pub use history_commands::chat_history; pub use history_commands::history_db; pub use history_commands::subagent_store; +pub use integration_commands::cua_driver; pub use integration_commands::gateway; pub use integration_commands::mcp; pub use integration_commands::memory; diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 26f12066f..5d34c3d73 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -313,6 +313,11 @@ macro_rules! app_invoke_handler { // 无障碍树。与「操作用户电脑」无关——那条链路走 MCP Hub。 commands::ax::cua_window_ready, commands::ax::cua_refresh_a11y, + commands::cua_driver::cua_driver_probe, + commands::cua_driver::cua_driver_install_command, + commands::cua_driver::cua_driver_install, + commands::cua_driver::cua_driver_permissions_status, + commands::cua_driver::cua_driver_permissions_grant, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, diff --git a/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs b/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs new file mode 100644 index 000000000..6223c5c5f --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs @@ -0,0 +1,460 @@ +//! `cua-driver` 的探测 / 安装 / 权限查询。 +//! +//! 计算机操作能力本身**不经过这里**——`cua-driver mcp` 是一个标准的 +//! stdio MCP server,由 `commands/integration/mcp.rs` 那套通用 MCP +//! client 驱动,工具由 `tools/list` 自动发现。这个模块只负责它前面那 +//! 一小段引导:用户机器上有没有这个二进制、装在哪、要不要装、macOS +//! 的 TCC 授权给了没有。 +//! +//! 设计原则是**把活都推给上游**。版本检查、下载、解压、更新、授权引导 +//! 上游 CLI 全都有(`install.sh` / `update --apply` / `permissions +//! grant` / `doctor`),这里不重新实现,只做三件事: +//! +//! 1. 找到二进制(GUI 进程的 PATH 通常不含 `~/.local/bin`,必须补候选路径); +//! 2. 问 `cua-driver manifest` 要 MCP 调用方式,而不是硬编码 `["mcp"]`; +//! 3. 需要安装时,转调官方安装脚本并把输出流式转发给前端。 +//! +//! macOS 上刻意**不**使用 `mcp --direct`:那会让 MCP 进程沿用宿主 +//! (LiveAgent.app)的 TCC 归属,等于要求 LiveAgent 自己去拿 +//! Accessibility 与 Screen Recording 授权。默认模式经 CuaDriver.app +//! 的守护进程代理,授权归它,宿主不需要任何 TCC 权限。 + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; +use tauri::{AppHandle, Emitter}; + +/// 单次外部命令的等待上限。`manifest` / `permissions status` 都在 1 秒 +/// 内返回;留足余量给冷启动的守护进程握手。 +const PROBE_TIMEOUT: Duration = Duration::from_secs(15); + +/// 安装脚本的进度事件名。前端 `CuaDriverSetupCard` 监听它滚动日志。 +pub const INSTALL_PROGRESS_EVENT: &str = "cua_driver_install_progress"; + +/// 官方安装脚本来源。展示给用户看的就是这个域名——必须与实际执行的 +/// URL 一致,否则确认对话框就是在骗人。 +const INSTALL_SCRIPT_URL_UNIX: &str = "https://cua.ai/driver/install.sh"; +const INSTALL_SCRIPT_URL_WINDOWS: &str = "https://cua.ai/driver/install.ps1"; + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaDriverProbe { + pub installed: bool, + /// 二进制绝对路径。写进 MCP server 配置的就是它——不用裸名字, + /// 因为 MCP 子进程继承的是 GUI 进程那份窄 PATH。 + pub path: Option, + pub version: Option, + /// `manifest.mcp_invocation` 给出的调用方式。上游若改了子命令, + /// 这里跟着变,不需要我们发版。 + pub mcp_command: Option, + pub mcp_args: Vec, + /// 探测失败的原因(未安装是正常状态,不算错误,此时为 None)。 + pub error: Option, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CuaDriverPermissions { + /// 只有 macOS 有 TCC 门槛;其他平台恒 false,前端据此隐藏整段。 + pub supported: bool, + pub accessibility: bool, + pub screen_recording: bool, + /// 授权归属的 bundle id(正常是 `com.trycua.driver`)。守护进程没起 + /// 来时上游会报 unknown,此时两个布尔值不可信。 + pub attributed_to: Option, + pub daemon_running: bool, + pub error: Option, +} + +/// 安装命令预览。**只描述,不执行**——UI 必须先把 `display` 原样展示 +/// 给用户确认,才允许调 `install`。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallCommandPreview { + pub program: String, + pub args: Vec, + /// 可直接粘进终端的完整命令。用户也可以选择自己去终端跑这一条。 + pub display: String, + /// 脚本来源 URL,用于在确认文案里点明「这会从网络下载并执行脚本」。 + pub source_url: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgress { + /// `stdout` | `stderr` | `done` | `failed` + pub stream: String, + pub line: String, +} + +// ───────── 探测 ───────── + +/// 在 PATH 与平台候选目录里找 `cua-driver`。 +/// +/// 必须自己 walk 而不是靠 `Command::new("cua-driver")`:macOS 上从 +/// Finder / Dock 启动的 GUI 进程拿到的是 launchd 的默认 PATH,不含 +/// `~/.local/bin`,而那正是官方安装脚本的默认落点。 +fn find_binary() -> Option { + if let Some(found) = find_in_path("cua-driver") { + return Some(found); + } + candidate_paths().into_iter().find(|p| p.is_file()) +} + +fn find_in_path(binary: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(binary); + if candidate.is_file() { + return Some(candidate); + } + #[cfg(target_os = "windows")] + { + let with_exe = dir.join(format!("{binary}.exe")); + if with_exe.is_file() { + return Some(with_exe); + } + } + } + None +} + +fn candidate_paths() -> Vec { + let home = dirs::home_dir(); + let mut out: Vec = Vec::new(); + + #[cfg(not(target_os = "windows"))] + { + if let Some(home) = home.as_ref() { + out.push(home.join(".local/bin/cua-driver")); + out.push(home.join(".cua/bin/cua-driver")); + } + out.push(PathBuf::from("/usr/local/bin/cua-driver")); + out.push(PathBuf::from("/opt/homebrew/bin/cua-driver")); + } + + #[cfg(target_os = "macos")] + { + // 装了 CuaDriver.app 但没建 PATH 软链的情况。 + out.push(PathBuf::from( + "/Applications/CuaDriver.app/Contents/MacOS/cua-driver", + )); + } + + #[cfg(target_os = "windows")] + { + if let Some(home) = home.as_ref() { + out.push(home.join(".local\\bin\\cua-driver.exe")); + out.push(home.join("AppData\\Local\\Programs\\cua-driver\\cua-driver.exe")); + } + } + + let _ = &home; + out +} + +fn run_capture(program: &Path, args: &[&str]) -> Result { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to spawn {}: {error}", program.display()))?; + + let status = match child + .wait_timeout(PROBE_TIMEOUT) + .map_err(|error| format!("wait failed: {error}"))? + { + Some(status) => status, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "{} {} timed out after {}s", + program.display(), + args.join(" "), + PROBE_TIMEOUT.as_secs() + )); + } + }; + + let output = child + .wait_with_output() + .map_err(|error| format!("failed to collect output: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if !status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // 有些子命令(如 permissions status)业务失败也走非零退出但仍 + // 打了有效 JSON;把 stdout 一并带回去,让调用方决定怎么解析。 + return Err(format!( + "exit {}: {}", + status.code().unwrap_or(-1), + if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + } + )); + } + Ok(stdout) +} + +/// 探测安装状态。未安装不是错误——返回 `installed: false, error: None`。 +pub fn probe() -> CuaDriverProbe { + let Some(path) = find_binary() else { + return CuaDriverProbe::default(); + }; + + let mut probe = CuaDriverProbe { + installed: true, + path: Some(path.to_string_lossy().into_owned()), + ..Default::default() + }; + + match run_capture(&path, &["manifest"]) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(manifest) => { + probe.version = manifest + .get("binary_version") + .and_then(Value::as_str) + .map(str::to_owned); + let invocation = manifest.get("mcp_invocation"); + probe.mcp_command = invocation + .and_then(|v| v.get("command")) + .and_then(Value::as_str) + .map(str::to_owned); + probe.mcp_args = invocation + .and_then(|v| v.get("args")) + .and_then(Value::as_array) + .map(|args| { + args.iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + } + Err(error) => probe.error = Some(format!("failed to parse manifest: {error}")), + }, + Err(error) => probe.error = Some(error), + } + + // manifest 没给出调用方式(老版本 / 解析失败)时回落到已知形态。 + // 刻意不加 `--direct`:见模块头注释。 + if probe.mcp_command.is_none() { + probe.mcp_command = probe.path.clone(); + probe.mcp_args = vec!["mcp".to_string()]; + } + + probe +} + +// ───────── 权限(macOS) ───────── + +pub fn permissions_status() -> CuaDriverPermissions { + if !cfg!(target_os = "macos") { + return CuaDriverPermissions::default(); + } + let Some(path) = find_binary() else { + return CuaDriverPermissions { + supported: true, + error: Some("cua-driver not installed".into()), + ..Default::default() + }; + }; + + // 守护进程状态单独问一次:`permissions status` 在守护进程没起来时 + // 只会报 unknown,不区分「没装」和「没跑」,对用户不可读。 + let daemon_running = run_capture(&path, &["status"]) + .map(|out| out.contains("is running")) + .unwrap_or(false); + + match run_capture(&path, &["permissions", "status", "--json"]) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(payload) => CuaDriverPermissions { + supported: true, + accessibility: payload + .get("accessibility") + .and_then(Value::as_bool) + .unwrap_or(false), + screen_recording: payload + .get("screen_recording") + .and_then(Value::as_bool) + .unwrap_or(false), + attributed_to: payload + .get("source") + .and_then(|source| source.get("bundle_id")) + .and_then(Value::as_str) + .map(str::to_owned), + daemon_running, + error: None, + }, + Err(error) => CuaDriverPermissions { + supported: true, + daemon_running, + error: Some(format!("failed to parse permissions payload: {error}")), + ..Default::default() + }, + }, + Err(error) => CuaDriverPermissions { + supported: true, + daemon_running, + error: Some(error), + ..Default::default() + }, + } +} + +/// 触发上游的授权引导。会弹系统对话框并把 CuaDriver.app 拉起来, +/// 归属正确的 bundle identity——这是唯一正确的授权路径,只读的 +/// `permissions status` 永远不会触发它。 +pub fn permissions_grant() -> Result { + if !cfg!(target_os = "macos") { + return Ok(CuaDriverPermissions::default()); + } + let path = find_binary().ok_or_else(|| "cua-driver not installed".to_string())?; + run_capture(&path, &["permissions", "grant"])?; + Ok(permissions_status()) +} + +// ───────── 安装 ───────── + +/// 描述将要执行的安装命令。**不执行任何东西。** +/// +/// 存在的理由就是让 UI 能在动手之前把命令原文摆到用户面前:这条命令 +/// 会从网络拉一段 shell 脚本直接执行,用户有权在看到全文之后再决定。 +pub fn install_command_preview() -> InstallCommandPreview { + if cfg!(target_os = "windows") { + let inner = format!("irm {INSTALL_SCRIPT_URL_WINDOWS} | iex"); + InstallCommandPreview { + program: "powershell".into(), + args: vec!["-NoProfile".into(), "-Command".into(), inner.clone()], + display: format!("powershell -NoProfile -Command \"{inner}\""), + source_url: INSTALL_SCRIPT_URL_WINDOWS.into(), + } + } else { + let inner = format!("$(curl -fsSL {INSTALL_SCRIPT_URL_UNIX})"); + InstallCommandPreview { + program: "/bin/bash".into(), + args: vec!["-c".into(), inner.clone()], + display: format!("/bin/bash -c \"{inner}\""), + source_url: INSTALL_SCRIPT_URL_UNIX.into(), + } + } +} + +/// 执行官方安装脚本,把 stdout / stderr 逐行 emit 给前端。 +/// +/// 调用方(Tauri command)必须确保用户已经在看到 +/// `install_command_preview().display` 之后显式确认过。 +pub fn install(app: &AppHandle) -> Result { + let preview = install_command_preview(); + let mut child = Command::new(&preview.program) + .args(&preview.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to launch installer: {error}"))?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let pump = |handle: Option>, stream: &'static str| { + let app = app.clone(); + handle.map(|reader| { + std::thread::spawn(move || { + use std::io::BufRead; + for line in std::io::BufReader::new(reader).lines().map_while(Result::ok) { + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallProgress { + stream: stream.to_string(), + line, + }, + ); + } + }) + }) + }; + let out_pump = pump( + stdout.map(|s| Box::new(s) as Box), + "stdout", + ); + let err_pump = pump( + stderr.map(|s| Box::new(s) as Box), + "stderr", + ); + + let status = child + .wait() + .map_err(|error| format!("installer wait failed: {error}"))?; + if let Some(handle) = out_pump { + let _ = handle.join(); + } + if let Some(handle) = err_pump { + let _ = handle.join(); + } + + if !status.success() { + let message = format!("installer exited with {}", status.code().unwrap_or(-1)); + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallProgress { + stream: "failed".into(), + line: message.clone(), + }, + ); + return Err(message); + } + + let probe = probe(); + let _ = app.emit( + INSTALL_PROGRESS_EVENT, + InstallProgress { + stream: "done".into(), + line: probe + .version + .clone() + .map(|version| format!("cua-driver {version}")) + .unwrap_or_else(|| "installed".into()), + }, + ); + Ok(probe) +} + +use wait_timeout::ChildExt; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_preview_never_executes_and_matches_its_source_url() { + let preview = install_command_preview(); + // 展示给用户的命令必须真的包含那个 URL——确认对话框的全部意义 + // 就在于「看到的即将执行的」。 + assert!(preview.display.contains(&preview.source_url)); + assert!(preview.args.iter().any(|arg| arg.contains(&preview.source_url))); + } + + #[test] + fn probe_reports_not_installed_without_error() { + // 未安装是正常状态,不该被前端当成故障红条渲染。 + let probe = CuaDriverProbe::default(); + assert!(!probe.installed); + assert!(probe.error.is_none()); + } + + #[test] + fn candidate_paths_cover_the_official_install_location() { + let paths = candidate_paths(); + assert!( + paths.iter().any(|p| p.to_string_lossy().contains(".local")), + "官方安装脚本默认落在 ~/.local/bin,GUI 进程的 PATH 通常不含它" + ); + } +} diff --git a/crates/agent-gui/src-tauri/src/services/mod.rs b/crates/agent-gui/src-tauri/src/services/mod.rs index bb39f9755..c08ec1ae2 100644 --- a/crates/agent-gui/src-tauri/src/services/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod automation; pub mod chat_run_ledger; +pub mod cua_driver; pub mod gateway; pub mod gateway_bridge; pub mod memory; diff --git a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx index 043ba2d4b..5c5c306ad 100644 --- a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx @@ -1,5 +1,6 @@ -import { Archive, Info, Keyboard } from "@liveagent/ui/components/IconSet"; +import { Archive, Hand, Info, Keyboard } from "@liveagent/ui/components/IconSet"; import type { SettingsSectionDefinition, UiExtensionSlots } from "@liveagent/ui/contracts/registry"; +import { CuaDriverSection } from "@liveagent/ui/pages/settings/CuaDriverSection"; import { isMacOsTauri, MacOsTitleBarSpacer } from "../components/MacOsTitleBarSpacer"; import { AboutSection } from "../pages/settings/AboutSection"; import { BackupSyncSection } from "../pages/settings/BackupSyncSection"; @@ -23,6 +24,18 @@ export function createSettingsExtension(props: SettingsPageProps): { mainLeading: , }, sections: [ + // CUA 接入引导。桌面端专属:探测 / 安装 / 授权都要 Tauri + // 后端命令,WebUI 下这些 invoke 会直接抛错,所以不在共享的 + // SettingsPage 里注册,而是走桌面 extension。 + { + id: "cua", + groupKey: "settings.groupConnectivity", + groupOrder: 40, + order: 30, + labelKey: "settings.navCua", + icon: , + render: () => , + }, { id: "shortcuts", groupKey: "settings.groupOther", diff --git a/crates/agent-gui/src/pages/settings/types.ts b/crates/agent-gui/src/pages/settings/types.ts index 3b448a2b3..c969bf54f 100644 --- a/crates/agent-gui/src/pages/settings/types.ts +++ b/crates/agent-gui/src/pages/settings/types.ts @@ -17,6 +17,7 @@ export type SectionId = | "hooks" | "cron" | "remote" + | "cua" | "about"; export type SettingsPageProps = { diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts index ed745bfa8..e1637cdea 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts +++ b/crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts @@ -23,6 +23,7 @@ import { FilePenLine, FileText, FolderTree, + Hand, type IconComponent, ImageIcon, Link2, @@ -43,6 +44,14 @@ export function getToolMeta(name: string): { if (isTaskToolName(name)) { return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" }; } + // 动态 MCP 工具此前全落进 default 分支(扳手 / other),与内置工具混在 + // 一起看不出来源。给它们一个专属图标;cua-driver 再单独区分——它的工具 + // 是在真实点击、输入、关闭应用,值得比「又一个 MCP 工具」更醒目。 + if (isDynamicMcpToolName(name)) { + return isCuaDriverToolName(name) + ? { Icon: Hand, accent: "var(--tool-bash-accent)", category: "cua" } + : { Icon: Plug, accent: "var(--tool-list-accent)", category: "mcp" }; + } switch (name) { case "Bash": case "ManagedProcess": @@ -209,8 +218,35 @@ export function isAgentToolName(name: string) { return name === "Agent"; } +/** + * 拆开动态 MCP 工具名。命名规则见 `mcpTools.ts`: + * `mcp__`。 + * + * 两段本身都可能含下划线,所以按第一个 `_` 切分是启发式而非精确解析。 + * 实践中 server id 是 kebab-case(sanitize 保留 `-`),切分正确;即便切 + * 错也只影响标题渲染,不参与任何判定或 key。 + */ +export function parseDynamicMcpToolName(name: string): { serverId: string; tool: string } | null { + const trimmed = name.trim(); + if (!isDynamicMcpToolName(trimmed)) return null; + const rest = trimmed.slice("mcp_".length); + const separator = rest.indexOf("_"); + if (separator <= 0 || separator === rest.length - 1) return null; + return { serverId: rest.slice(0, separator), tool: rest.slice(separator + 1) }; +} + +/** cua-driver 的工具会真实操作用户的机器,值得在气泡里一眼可辨。 */ +export function isCuaDriverToolName(name: string) { + return parseDynamicMcpToolName(name)?.serverId === "cua-driver"; +} + export function getToolDisplayName(name: string) { if (name === "SshManager") return "SSHManager"; + // `mcp_cua-driver_get_desktop_state` 这样的原始名在气泡标题里又长又 + // 难读。拆成 `cua-driver · get_desktop_state`:server 仍然可见(同时 + // 挂多个 MCP 时需要区分),但工具本身成为视觉重心。 + const parsed = parseDynamicMcpToolName(name); + if (parsed) return `${parsed.serverId} · ${parsed.tool}`; return name; } diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index b0c77270a..5e3759352 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1184,4 +1184,44 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.hooksActiveHooks": "Active", "settings.hooksInactiveHooks": "Inactive", "settings.hooksSelectedEvent": "Selected Event", + "settings.navCua": "CUA", + "settings.cuaDriver.title": "CUA", + "settings.cuaDriver.subtitle": "Computer Use Agent — let the AI operate this machine directly", + "settings.cuaDriver.description": + "With cua-driver connected, the AI can list windows, take screenshots, click, type, invoke native menus, and drive the browser. It connects as an ordinary MCP server — its ~60 tools are discovered automatically via tools/list, and day-to-day management lives on its MCP Hub server card. The driver comes from", + "settings.cuaDriver.groupDriver": "Driver", + "settings.cuaDriver.recheck": "Re-check", + "settings.cuaDriver.detected": "cua-driver installed", + "settings.cuaDriver.detectedWithVersion": "cua-driver {version} installed", + "settings.cuaDriver.notInstalledTitle": "cua-driver not found", + "settings.cuaDriver.notInstalledDesc": + "LiveAgent can run the official installer for you, or install it yourself and hit Re-check above.", + "settings.cuaDriver.install": "Install driver", + "settings.cuaDriver.installing": "Installing…", + "settings.cuaDriver.confirmTitle": "About to run an installer script", + "settings.cuaDriver.confirmDesc": + "The command below downloads a script from {url} and runs it immediately. Review it first — you can also copy it and run it in your own terminal.", + "settings.cuaDriver.confirmRun": "Run it", + "settings.cuaDriver.confirmCancel": "Cancel", + "settings.cuaDriver.permissionsTitle": "macOS permissions", + "settings.cuaDriver.permissionsDesc": + "Accessibility and Screen Recording are required. The grants belong to CuaDriver.app, not LiveAgent — the button lets it request them under its own identity.", + "settings.cuaDriver.permissionsGranted": "Granted, attributed to {bundleId}.", + "settings.cuaDriver.grantPermissions": "Grant permissions", + "settings.cuaDriver.statusGranted": "Granted", + "settings.cuaDriver.enableTitle": "Enable CUA", + "settings.cuaDriver.enableDesc": + "Turning this on connects it over MCP; its ~60 automation tools become visible to the AI.", + "settings.cuaDriver.enabledDesc": + "Connected over MCP. Arguments and the permission policy can be tuned on the cua-driver card in MCP Hub.", + "settings.cuaDriver.enableNeedsDriver": "Install cua-driver first.", + "settings.cuaDriver.enable": "Enable CUA", + "settings.cuaDriver.disable": "Disable CUA", + "settings.cuaDriver.policyNote": + "This server defaults to asking every time. Its tools really click, type, and quit applications; once connected you can switch it to always-allow or deny on its MCP Hub server card.", + "settings.cuaDriver.groupAbout": "About", + "settings.cuaDriver.statusActive": "Enabled", + "settings.cuaDriver.statusIdle": "Disabled", + "settings.cuaDriver.statusNotInstalled": "Driver missing", + "settings.cuaDriver.permissionsPending": "Not granted yet", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index b194612cc..f40963148 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1129,4 +1129,43 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.hooksActiveHooks": "已启用", "settings.hooksInactiveHooks": "已禁用", "settings.hooksSelectedEvent": "当前事件", + "settings.navCua": "CUA", + "settings.cuaDriver.title": "CUA", + "settings.cuaDriver.subtitle": "Computer Use Agent —— 让 AI 直接操作这台电脑", + "settings.cuaDriver.description": + "接入 cua-driver 后,AI 可以枚举窗口、截屏、点击、输入、调用原生菜单并控制浏览器。它以一个普通 MCP server 的形式接入,约 60 个工具由 tools/list 自动发现,日常管理在 MCP Hub 的 server 卡片上。驱动来自", + "settings.cuaDriver.groupDriver": "驱动", + "settings.cuaDriver.recheck": "重新检测", + "settings.cuaDriver.detected": "已安装 cua-driver", + "settings.cuaDriver.detectedWithVersion": "已安装 cua-driver {version}", + "settings.cuaDriver.notInstalledTitle": "未检测到 cua-driver", + "settings.cuaDriver.notInstalledDesc": + "可以让 LiveAgent 运行官方安装脚本,也可以自行安装后点右上角「重新检测」。", + "settings.cuaDriver.install": "安装驱动", + "settings.cuaDriver.installing": "安装中…", + "settings.cuaDriver.confirmTitle": "即将运行安装脚本", + "settings.cuaDriver.confirmDesc": + "下面这条命令会从 {url} 下载一段脚本并立即执行。请先确认命令内容;你也可以复制它自行到终端运行。", + "settings.cuaDriver.confirmRun": "确认运行", + "settings.cuaDriver.confirmCancel": "取消", + "settings.cuaDriver.permissionsTitle": "macOS 系统授权", + "settings.cuaDriver.permissionsDesc": + "需要「辅助功能」与「屏幕录制」权限。授权对象是 CuaDriver.app 而不是 LiveAgent —— 点右侧按钮由它以自己的身份发起请求。", + "settings.cuaDriver.permissionsGranted": "已授权,归属 {bundleId}。", + "settings.cuaDriver.grantPermissions": "前往授权", + "settings.cuaDriver.statusGranted": "已授权", + "settings.cuaDriver.enableTitle": "启用 CUA", + "settings.cuaDriver.enableDesc": "开启后接入 MCP,约 60 个操作工具随即对 AI 可见。", + "settings.cuaDriver.enabledDesc": + "已接入 MCP。参数与权限策略可在 MCP Hub 的 cua-driver 卡片里调整。", + "settings.cuaDriver.enableNeedsDriver": "需要先安装 cua-driver。", + "settings.cuaDriver.enable": "启用 CUA", + "settings.cuaDriver.disable": "停用 CUA", + "settings.cuaDriver.policyNote": + "该 server 默认逐次询问(ask)。这些工具会真实点击、输入、关闭应用,接入后可在 MCP Hub 的 server 卡片里改为始终允许或禁止。", + "settings.cuaDriver.groupAbout": "关于", + "settings.cuaDriver.statusActive": "已启用", + "settings.cuaDriver.statusIdle": "未启用", + "settings.cuaDriver.statusNotInstalled": "未安装驱动", + "settings.cuaDriver.permissionsPending": "尚未授权", } as const satisfies Record; diff --git a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx new file mode 100644 index 000000000..8fee2a3db --- /dev/null +++ b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx @@ -0,0 +1,442 @@ +import { type McpServerConfig, updateMcp } from "@liveagent/app/lib/settings"; +import type { SettingsSectionProps } from "@liveagent/app/pages/settings/types"; +import { invoke } from "@liveagent/app/shims/tauriCore"; +import { listen } from "@liveagent/app/shims/tauriEvent"; +import { + AlertTriangle, + Check, + Circle, + Download, + ExternalLink, + Hand, + type IconComponent, + Loader2, + Package, + Plug, + RefreshCw, + Shield, +} from "@liveagent/ui/components/IconSet"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { cn } from "@liveagent/ui/lib/shared/utils"; +import { AgentActivationSwitch } from "@liveagent/ui/pages/settings/shared"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "../../components/ui/button"; + +/** 与 RemoteSection 同款的卡片标题行,保持两节视觉一致。 */ +function SectionCardHeader({ icon: Icon, title }: { icon: IconComponent; title: string }) { + return ( +
+ + {title} +
+ ); +} + +/** + * CUA(Computer Use Agent)的接入引导,驱动用 trycua/cua 的 cua-driver。 + * + * 能力本身没有任何专属代码路径——`cua-driver mcp` 就是一个普通 stdio MCP + * server,接进来之后约 60 个工具由 `tools/list` 自动发现,调用、审批、 + * 截图渲染全部复用既有 MCP 链路。这一节只解决它前面那三个一次性问题: + * 装了吗、macOS 授权了吗、server 配置怎么填。 + * + * 装完并接入之后,日常管理(启停、改策略、改参数)都在 MCP Hub 的 + * server 卡片上,这一节只保留状态展示与「去 MCP Hub 管理」的指引。 + * + * 仅桌面端注册(见 `SettingsPage`):这些命令依赖 Tauri 后端。 + */ + +export const CUA_DRIVER_SERVER_ID = "cua-driver"; + +const DEFAULT_TIMEOUT_MS = 60_000; +const INSTALL_PROGRESS_EVENT = "cua_driver_install_progress"; +const MAX_LOG_LINES = 200; +const UPSTREAM_REPO_URL = "https://github.com/trycua/cua"; + +type Probe = { + installed: boolean; + path?: string | null; + version?: string | null; + mcpCommand?: string | null; + mcpArgs?: string[]; + error?: string | null; +}; + +type Permissions = { + supported: boolean; + accessibility: boolean; + screenRecording: boolean; + attributedTo?: string | null; + daemonRunning: boolean; + error?: string | null; +}; + +type InstallPreview = { + program: string; + args: string[]; + display: string; + sourceUrl: string; +}; + +type InstallProgress = { stream: string; line: string }; + +function serverConfigFrom(probe: Probe): McpServerConfig { + return { + id: CUA_DRIVER_SERVER_ID, + description: "trycua/cua — CUA 驱动(跨平台)", + docsUrl: UPSTREAM_REPO_URL, + enabled: true, + transport: "stdio", + // 绝对路径而非裸命令:MCP 子进程继承的是 GUI 进程那份窄 PATH, + // 通常不含 ~/.local/bin —— 官方安装脚本的默认落点。 + command: probe.mcpCommand || probe.path || "cua-driver", + // 刻意不带 `--direct`:那会让 MCP 进程沿用 LiveAgent 的 TCC 归属, + // 等于要求 LiveAgent 自己去拿辅助功能与屏幕录制授权。默认模式经 + // CuaDriver.app 的守护进程代理,授权归它。 + args: probe.mcpArgs?.length ? probe.mcpArgs : ["mcp"], + url: "", + timeoutMs: DEFAULT_TIMEOUT_MS, + }; +} + +export function CuaDriverSection(props: SettingsSectionProps) { + const { settings, setSettings } = props; + const { t } = useLocale(); + + const [probe, setProbe] = useState(null); + const [permissions, setPermissions] = useState(null); + const [preview, setPreview] = useState(null); + const [confirmingInstall, setConfirmingInstall] = useState(false); + const [installing, setInstalling] = useState(false); + const [granting, setGranting] = useState(false); + const [checking, setChecking] = useState(true); + const [log, setLog] = useState([]); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + // 总开关的真实状态就是这个 MCP server 的启用状态——没有第二份配置。 + const enabled = settings.mcp.servers.some( + (server) => server.id.trim().toLowerCase() === CUA_DRIVER_SERVER_ID && server.enabled, + ); + + const refresh = useCallback(async () => { + setChecking(true); + try { + const next = await invoke("cua_driver_probe"); + if (!mountedRef.current) return; + setProbe(next); + if (next.installed) { + try { + const perms = await invoke("cua_driver_permissions_status"); + if (mountedRef.current) setPermissions(perms); + } catch { + // 权限查询失败不该拖垮整节:它只是补充信息。 + if (mountedRef.current) setPermissions(null); + } + } else if (mountedRef.current) { + setPermissions(null); + } + } catch (err) { + if (mountedRef.current) { + setProbe({ installed: false }); + setError(String(err)); + } + } finally { + if (mountedRef.current) setChecking(false); + } + }, []); + + useEffect(() => { + mountedRef.current = true; + void refresh(); + return () => { + mountedRef.current = false; + }; + }, [refresh]); + + useEffect(() => { + if (!installing) return; + let dispose: (() => void) | undefined; + void listen(INSTALL_PROGRESS_EVENT, (event) => { + setLog((prev) => [...prev, event.payload.line].slice(-MAX_LOG_LINES)); + }).then((unlisten) => { + dispose = unlisten; + }); + return () => dispose?.(); + }, [installing]); + + const installed = probe?.installed === true; + const permissionsPending = + permissions?.supported === true && (!permissions.accessibility || !permissions.screenRecording); + + async function beginInstall() { + setError(null); + try { + setPreview(await invoke("cua_driver_install_command")); + setConfirmingInstall(true); + } catch (err) { + setError(String(err)); + } + } + + async function confirmInstall() { + setConfirmingInstall(false); + setInstalling(true); + setLog([]); + setError(null); + try { + await invoke("cua_driver_install"); + await refresh(); + } catch (err) { + if (mountedRef.current) setError(String(err)); + } finally { + if (mountedRef.current) setInstalling(false); + } + } + + async function grantPermissions() { + setGranting(true); + setError(null); + try { + const next = await invoke("cua_driver_permissions_grant"); + if (mountedRef.current) setPermissions(next); + } catch (err) { + if (mountedRef.current) setError(String(err)); + } finally { + if (mountedRef.current) setGranting(false); + } + } + + /** + * 总开关 = 「cua-driver 这个 MCP server 是否启用」。 + * + * 关闭时只把 `enabled` 置 false 而不删除条目:用户可能在 MCP Hub 里 + * 改过 args、超时或权限策略,删掉再加回来会静默丢掉这些自定义。 + * 打开时若条目还不存在才新建。 + */ + function toggleEnabled(next: boolean) { + if (next && !probe?.installed) return; + setSettings((prev) => { + const index = prev.mcp.servers.findIndex( + (server) => server.id.trim().toLowerCase() === CUA_DRIVER_SERVER_ID, + ); + if (index < 0) { + if (!next || !probe) return prev; + return updateMcp(prev, { servers: [...prev.mcp.servers, serverConfigFrom(probe)] }); + } + return updateMcp(prev, { + servers: prev.mcp.servers.map((server, idx) => + idx === index ? { ...server, enabled: next } : server, + ), + }); + }); + } + + const statusText = !installed + ? t("settings.cuaDriver.statusNotInstalled") + : enabled + ? t("settings.cuaDriver.statusActive") + : t("settings.cuaDriver.statusIdle"); + + return ( +
+
+
+
+ +
+
+

{t("settings.cuaDriver.title")}

+

{t("settings.cuaDriver.subtitle")}

+
+
+ +
+
+ {enabled ? ( + + ) : ( + + )} + {statusText} +
+ + toggleEnabled(!enabled)} + /> +
+
+ +
+
+ + +
+ +
+
+
+ {installed ? ( + + ) : ( + + )} + {installed + ? probe?.version + ? t("settings.cuaDriver.detectedWithVersion").replace("{version}", probe.version) + : t("settings.cuaDriver.detected") + : t("settings.cuaDriver.notInstalledTitle")} +
+

+ {probe?.path ?? t("settings.cuaDriver.notInstalledDesc")} +

+
+ {installed ? null : ( + + )} +
+ + {/* 安装确认:把即将执行的命令原文摆出来。这条命令会从网络下载一段 + shell 脚本并直接执行,用户有权先看清楚再决定,也可以复制到自己 + 的终端里跑。绝不自动安装。 */} + {confirmingInstall && preview ? ( +
+

+ + {t("settings.cuaDriver.confirmTitle")} +

+

+ {t("settings.cuaDriver.confirmDesc").replace("{url}", preview.sourceUrl)} +

+
+              {preview.display}
+            
+
+ + +
+
+ ) : null} + + {log.length > 0 ? ( +
+            {log.join("\n")}
+          
+ ) : null} + + {error ? ( +

+ + {error} +

+ ) : null} +
+ + {/* macOS TCC。授权归 CuaDriver.app 而非 LiveAgent —— 这是刻意选择的 + 代理模式,宿主不需要任何辅助功能 / 屏幕录制权限。 */} + {installed && permissions?.supported ? ( +
+ +
+
+
+ {permissionsPending ? ( + + ) : ( + + )} + {permissionsPending + ? t("settings.cuaDriver.permissionsPending") + : t("settings.cuaDriver.statusGranted")} +
+

+ {permissionsPending + ? t("settings.cuaDriver.permissionsDesc") + : t("settings.cuaDriver.permissionsGranted").replace( + "{bundleId}", + permissions.attributedTo ?? "com.trycua.driver", + )} +

+
+ {permissionsPending ? ( + + ) : null} +
+
+ ) : null} + +
+ +

+ {t("settings.cuaDriver.description")}{" "} + + trycua/cua + + +

+

+ {t("settings.cuaDriver.policyNote")} +

+
+
+ ); +} diff --git a/crates/agent-ui/src/pages/settings/SettingsShell.tsx b/crates/agent-ui/src/pages/settings/SettingsShell.tsx index 366ddd06b..96cefae2b 100644 --- a/crates/agent-ui/src/pages/settings/SettingsShell.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsShell.tsx @@ -283,9 +283,7 @@ export function SettingsShell(props: SettingsShellProps) { )} data-anim-suspended={isDocumentHidden ? "true" : undefined} style={ - isDocumentHidden - ? { animation: "none", opacity: 1, transform: "none" } - : undefined + isDocumentHidden ? { animation: "none", opacity: 1, transform: "none" } : undefined } >
Date: Wed, 26 Aug 2026 13:28:58 +0800 Subject: [PATCH 23/28] =?UTF-8?q?feat(cua):=20=E5=8F=82=E6=95=B0=E6=94=B6?= =?UTF-8?q?=E5=BD=92=E8=AE=BE=E7=BD=AE=E9=A1=B5=EF=BC=8CMCP=20Hub=20?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E6=9D=A1=E7=9B=AE=EF=BC=8C=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=8E=92=E9=99=A4=20LiveAgent=20=E8=87=AA=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处改动: 1. MCP Hub 不再列出 cua-driver。它的 MCP 条目是「设置 → CUA」总开关的 实现细节——command 由 cua-driver manifest 决定、args 不该随意改。两个 入口都能写同一份配置只会让人搞不清哪边说了算(在 Hub 里删掉条目,CUA 那节的开关会莫名变成关)。过滤走 contracts/mcpServerDefaults.ts 的 isHubHiddenServerId,列表与计数徽章共用同一口径。 2. 参数改到设置页的 CUA 分区,新增「行为」卡片:工具审批策略(复用 ToolPolicyToggle 与 server:cua-driver 策略键,与 Hub 卡片同一套语义)、 单次调用超时、MCP 启动命令(只读展示,说明它由 manifest 提供)。 3. 默认排除 LiveAgent 自身窗口。 cua-driver 没有「排除某个 app」的原生机制——capability manifest 是 工具/资源白名单,且在代理模式下归 CuaDriver.app 的守护进程管,宿主 控制不了。所以这道闸开在宿主侧(lib/tools/cuaSelfGuard.ts),双向拦: - 入参:pid 命中宿主、或 window_id 在已学到的宿主窗口集合里,直接 拒绝并返回一句模型能读懂的说明,让它换目标而非原地重试。 - 出参:list_windows / list_apps / get_accessibility_tree 的 JSON 结果 里剔除宿主记录,并顺手记下它的 window_id 供入参拦截使用。不剔的话 模型下一步就会拿着这些 id 来敲门。 window_id 与 pid 的对应关系只有 cua-driver 知道,因此宿主 window_id 是在出参过滤时学到的;在第一次窗口枚举之前模型手里本就没有 window_id。 坐标类参数无法反查归属,由出参过滤间接覆盖——拿不到宿主窗口的 bounds 就算不出坐标。 为什么默认关:让模型操作宿主界面等于让它能点掉自己的审批弹窗(绕过 审批)、改写这份权限设置、或者直接把应用关了。新增 system.cuaAllowSelfTargeting(默认 false,持久化进 system_settings 白名单)可解除,正当场景只有一个——用 LiveAgent 自动化测试 LiveAgent。 新增 cua_driver_self_identity 命令提供宿主 pid。过滤放在前端而非 Rust 侧 的 mcp_call_tool:后者是所有 MCP server 共用的通道,不该塞 cua 专属逻辑。 --- .../src/commands/config/settings/mod.rs | 5 + .../src/commands/config/settings/system.rs | 12 ++ .../src/commands/config/settings/tests.rs | 7 +- .../src/commands/integration/cua_driver.rs | 9 +- crates/agent-gui/src-tauri/src/lib.rs | 1 + .../src-tauri/src/services/cua_driver/mod.rs | 22 +++ .../src/lib/tools/builtinRegistry.ts | 3 + .../agent-gui/src/lib/tools/cuaSelfGuard.ts | 147 ++++++++++++++++ crates/agent-gui/src/lib/tools/mcpTools.ts | 44 ++++- .../src/pages/chat/runtime/useSendChatTurn.ts | 1 + .../chat/turns/runAgentConversationTurn.ts | 4 + .../test/tools/cua-self-guard.test.mjs | 76 +++++++++ .../src/contracts/mcpServerDefaults.ts | 19 +++ .../src/i18n/translations/enUSSettings.ts | 13 ++ .../src/i18n/translations/zhCNSettings.ts | 12 ++ crates/agent-ui/src/lib/settings/index.ts | 2 + crates/agent-ui/src/lib/settings/types.ts | 9 + .../agent-ui/src/pages/mcp-hub/McpHubPage.tsx | 13 +- .../src/pages/mcp-hub/McpServersForm.tsx | 42 +++-- .../src/pages/settings/CuaDriverSection.tsx | 161 +++++++++++++++++- 20 files changed, 571 insertions(+), 31 deletions(-) create mode 100644 crates/agent-gui/src/lib/tools/cuaSelfGuard.ts create mode 100644 crates/agent-gui/test/tools/cua-self-guard.test.mjs diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index 405617681..5ffb91bf7 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -48,6 +48,11 @@ const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjec const SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY: &str = "archivedWorkspaceProjectPaths"; const SYSTEM_WORKSPACE_RESOURCE_SETTINGS_KEY: &str = "workspaceResourceSettings"; const SYSTEM_SYSTEM_PROXY_KEY: &str = "systemProxy"; +// CUA 自指开关。默认 false —— cua-driver 的工具默认看不到、也点不到 +// LiveAgent 自己的窗口:让模型操作宿主界面等于让它能点掉自己的审批弹窗、 +// 改自己的设置、关掉自己。置 true 才解除(用 LiveAgent 自动化测试 +// LiveAgent 这类场景需要)。 +const SYSTEM_CUA_ALLOW_SELF_TARGETING_KEY: &str = "cuaAllowSelfTargeting"; const DEFAULT_WORKSPACE_PROJECT_ID: &str = "default-project"; const DEFAULT_WORKSPACE_PROJECT_NAME: &str = "Default Project"; pub(crate) const PROVIDER_API_KEY_UPDATES_FIELD: &str = "providerApiKeyUpdates"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index f61d59558..b82a95ecd 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -463,6 +463,17 @@ fn system_value_with_defaults(raw: Option, default_workdir: &str) -> Valu SYSTEM_COMMAND_SAFETY_MODE_KEY.to_string(), normalize_command_safety_mode_value(system.get(SYSTEM_COMMAND_SAFETY_MODE_KEY)), ); + // 缺省 false:安全侧的开关,任何非 true 的值(缺失、null、字符串) + // 都收敛成「不允许自指」。 + system.insert( + SYSTEM_CUA_ALLOW_SELF_TARGETING_KEY.to_string(), + Value::Bool( + system + .get(SYSTEM_CUA_ALLOW_SELF_TARGETING_KEY) + .and_then(Value::as_bool) + .unwrap_or(false), + ), + ); Value::Object(system) } @@ -572,6 +583,7 @@ fn save_system_with_default_workdir( SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_WORKSPACE_RESOURCE_SETTINGS_KEY, SYSTEM_SYSTEM_PROXY_KEY, + SYSTEM_CUA_ALLOW_SELF_TARGETING_KEY, ] { let value = system.get(key).cloned().unwrap_or(Value::Null); tx.execute( diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 006647637..748a75c08 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1185,13 +1185,14 @@ mod tests { }; let loaded = load_system(&conn).expect("load system"); - assert_eq!(row_count, 12); + assert_eq!(row_count, 13); assert_eq!( keys, vec![ SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY.to_string(), SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_COMMAND_SAFETY_MODE_KEY.to_string(), + SYSTEM_CUA_ALLOW_SELF_TARGETING_KEY.to_string(), SYSTEM_EXECUTION_MODE_KEY.to_string(), SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY.to_string(), @@ -1207,6 +1208,7 @@ mod tests { loaded, Some(json!({ "activeWorkspaceProjectId": DEFAULT_WORKSPACE_PROJECT_ID, + "cuaAllowSelfTargeting": false, "executionMode": "tools", "hiddenWorkspaceProjectPaths": [], "missingWorkspaceProjectPaths": [], @@ -1506,6 +1508,7 @@ mod tests { loaded, Some(json!({ "activeWorkspaceProjectId": DEFAULT_WORKSPACE_PROJECT_ID, + "cuaAllowSelfTargeting": false, "executionMode": "tools", "hiddenWorkspaceProjectPaths": [], "missingWorkspaceProjectPaths": [], @@ -1560,6 +1563,7 @@ mod tests { loaded, Some(json!({ "activeWorkspaceProjectId": DEFAULT_WORKSPACE_PROJECT_ID, + "cuaAllowSelfTargeting": false, "executionMode": "tools", "hiddenWorkspaceProjectPaths": [], "missingWorkspaceProjectPaths": [], @@ -1631,6 +1635,7 @@ mod tests { loaded, json!({ "activeWorkspaceProjectId": DEFAULT_WORKSPACE_PROJECT_ID, + "cuaAllowSelfTargeting": false, "executionMode": "tools", "hiddenWorkspaceProjectPaths": [], "missingWorkspaceProjectPaths": [], diff --git a/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs b/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs index 5467a7631..080506085 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/cua_driver.rs @@ -9,7 +9,7 @@ use tauri::AppHandle; use crate::services::cua_driver::{ - self, CuaDriverPermissions, CuaDriverProbe, InstallCommandPreview, + self, CuaDriverPermissions, CuaDriverProbe, InstallCommandPreview, SelfIdentity, }; /// 探测二进制位置、版本与 MCP 调用方式。未安装返回 `installed: false`, @@ -60,3 +60,10 @@ pub async fn cua_driver_permissions_grant() -> Result SelfIdentity { + cua_driver::self_identity() +} diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 5d34c3d73..ef2bf4da3 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -318,6 +318,7 @@ macro_rules! app_invoke_handler { commands::cua_driver::cua_driver_install, commands::cua_driver::cua_driver_permissions_status, commands::cua_driver::cua_driver_permissions_grant, + commands::cua_driver::cua_driver_self_identity, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, diff --git a/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs b/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs index 6223c5c5f..dbeb6ccc1 100644 --- a/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs @@ -253,6 +253,28 @@ pub fn probe() -> CuaDriverProbe { probe } +// ───────── 宿主自身身份 ───────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SelfIdentity { + pub pid: u32, + pub bundle_id: Option, +} + +/// LiveAgent 自己的进程身份,供前端把 cua-driver 的视野裁掉宿主窗口。 +/// +/// 让模型操作 LiveAgent 自己的界面是危险的自指:它能点掉自己的审批弹窗、 +/// 改自己的权限策略、或者直接把自己关了。过滤在前端做(Rust 侧的 +/// `mcp_call_tool` 是所有 MCP server 共用的通道,不该塞 cua 专属逻辑), +/// 这里只提供比对用的事实。 +pub fn self_identity() -> SelfIdentity { + SelfIdentity { + pid: std::process::id(), + bundle_id: option_env!("TAURI_BUNDLE_IDENTIFIER").map(str::to_owned), + } +} + // ───────── 权限(macOS) ───────── pub fn permissions_status() -> CuaDriverPermissions { diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index b59b165f3..5a500eafb 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -186,6 +186,8 @@ type BuildBuiltinBaseToolRegistryParams = { applyMcpOps?: (ops: McpSettingsOp[]) => void; onMcpLoadError?: (message: string) => void; mcpLoadFailureMode?: "continue" | "throw"; + /** 允许 CUA 工具把 LiveAgent 自己当作操作目标;默认 false,见 cuaSelfGuard.ts。 */ + cuaAllowSelfTargeting?: boolean; memoryToolMode?: "rw" | "ro"; remoteWebTunnelsEnabled?: boolean; tunnelProjectPathKey?: string; @@ -297,6 +299,7 @@ async function buildBaseBuiltinToolBundles( servers: enabledServers, onLoadError: params.onMcpLoadError, loadFailureMode: params.mcpLoadFailureMode, + cuaAllowSelfTargeting: params.cuaAllowSelfTargeting, }); baseBundles.push(mcpBusinessBundle); } diff --git a/crates/agent-gui/src/lib/tools/cuaSelfGuard.ts b/crates/agent-gui/src/lib/tools/cuaSelfGuard.ts new file mode 100644 index 000000000..d9ea8e61c --- /dev/null +++ b/crates/agent-gui/src/lib/tools/cuaSelfGuard.ts @@ -0,0 +1,147 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * 把 LiveAgent 自己从 cua-driver 的视野里摘掉。 + * + * 为什么需要:cua-driver 看到的是整个桌面,其中包括 LiveAgent 自己的窗 + * 口。让模型操作宿主界面是危险的自指——它能点掉自己的审批弹窗(等于绕过 + * 审批)、改自己的权限策略、或者直接把自己关了。上游没有「排除某个 app」 + * 的机制(capability manifest 是工具/资源白名单,且在代理模式下归 + * CuaDriver.app 的守护进程管),所以这道闸只能开在宿主侧。 + * + * 两个方向都要拦: + * - **入参**:调用直接以宿主 pid / window_id 为目标时拒绝,返回一句模型 + * 能读懂的说明,让它换目标而不是原地重试。 + * - **出参**:`list_windows` / `list_apps` / `get_accessibility_tree` 的 + * 结果里剔除宿主记录。不剔的话模型下一步就会拿着这些 id 来敲门,白白 + * 撞上入参拦截。 + * + * window_id 与 pid 的对应关系只有 cua-driver 知道,所以宿主的 window_id + * 是在出参过滤时顺手学到的(见 `learnedSelfWindowIds`)。在第一次窗口枚 + * 举之前,模型手里本来也不会有 window_id,拦不住也无从利用。 + * + * `cuaAllowSelfTargeting` 置 true 可整体关掉这道闸——用 LiveAgent 自动化 + * 测试 LiveAgent 时需要。默认关闭。 + */ + +const SELF_TARGET_REFUSAL = + "该目标是 LiveAgent 自身的窗口,已被拒绝:让模型操作宿主界面可以绕过工具审批、" + + "改写权限设置或直接关闭应用。请改为操作其他应用。(如确需自动化 LiveAgent 本身," + + "在「设置 → CUA」中打开「允许操作 LiveAgent 自身」。)"; + +type SelfIdentity = { pid: number; bundleId?: string | null }; + +let selfIdentityPromise: Promise | null = null; + +function loadSelfIdentity(): Promise { + selfIdentityPromise ??= invoke("cua_driver_self_identity").catch(() => null); + return selfIdentityPromise; +} + +/** 出参过滤时学到的宿主 window_id。进程级缓存,无需持久化。 */ +const learnedSelfWindowIds = new Set(); + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * 入参检查。返回拒绝理由,或 null 表示放行。 + * + * 只看 `pid` / `window_id` 两个字段:cua-driver 的工具全用它们寻址(见 + * `list_windows` 的 per-record 字段),坐标类参数(x/y)无法反查归属, + * 由出参过滤间接覆盖——模型拿不到宿主窗口的 bounds 就算不出坐标。 + */ +export function refuseSelfTargetedCall( + args: Record | undefined, + selfPid: number | null, +): string | null { + if (!args) return null; + const pid = readNumber(args.pid); + if (selfPid !== null && pid === selfPid) return SELF_TARGET_REFUSAL; + const windowId = readNumber(args.window_id); + if (windowId !== null && learnedSelfWindowIds.has(windowId)) return SELF_TARGET_REFUSAL; + return null; +} + +/** + * 从一条 JSON 文本结果里剔除宿主记录,并顺手记下宿主的 window_id。 + * + * 结果不是 JSON(截图、纯文本报告)时原样返回:这类载荷里没有可供寻址的 + * 记录,剔无可剔。 + */ +export function stripSelfFromJsonText(text: string, selfPid: number | null): string { + if (selfPid === null) return text; + const trimmed = text.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return text; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return text; + } + + let changed = false; + + const visit = (node: unknown): unknown => { + if (Array.isArray(node)) { + const kept = node.filter((entry) => { + const record = asRecord(entry); + if (!record) return true; + if (readNumber(record.pid) !== selfPid) return true; + const windowId = readNumber(record.window_id); + if (windowId !== null) learnedSelfWindowIds.add(windowId); + changed = true; + return false; + }); + return kept.map(visit); + } + const record = asRecord(node); + if (!record) return node; + const next: Record = {}; + for (const [key, value] of Object.entries(record)) next[key] = visit(value); + return next; + }; + + const result = visit(parsed); + return changed ? JSON.stringify(result) : text; +} + +/** 供测试重置进程级缓存。 */ +export function resetCuaSelfGuardCaches() { + selfIdentityPromise = null; + learnedSelfWindowIds.clear(); +} + +export type CuaSelfGuard = { + /** 调用前检查;返回拒绝理由或 null。 */ + refuse: (args: Record | undefined) => string | null; + /** 结果文本过滤。 */ + strip: (text: string) => string; +}; + +/** + * 取得当前生效的守卫。`allowSelfTargeting` 为 true,或宿主身份查不到 + * (非桌面端)时返回 null——调用方据此完全跳过这层。 + */ +export async function resolveCuaSelfGuard( + allowSelfTargeting: boolean, +): Promise { + if (allowSelfTargeting) return null; + const identity = await loadSelfIdentity(); + const selfPid = identity ? readNumber(identity.pid) : null; + if (selfPid === null) return null; + return { + refuse: (args) => refuseSelfTargetedCall(args, selfPid), + strip: (text) => stripSelfFromJsonText(text, selfPid), + }; +} + +export const CUA_SELF_TARGET_REFUSAL = SELF_TARGET_REFUSAL; diff --git a/crates/agent-gui/src/lib/tools/mcpTools.ts b/crates/agent-gui/src/lib/tools/mcpTools.ts index 368375807..0027904c2 100644 --- a/crates/agent-gui/src/lib/tools/mcpTools.ts +++ b/crates/agent-gui/src/lib/tools/mcpTools.ts @@ -5,10 +5,12 @@ import type { ToolCall, ToolResultMessage, } from "@earendil-works/pi-ai"; +import { CUA_DRIVER_SERVER_ID } from "@liveagent/ui/contracts/mcpServerDefaults"; import { invoke } from "@tauri-apps/api/core"; import type { McpServerConfig } from "../settings"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; +import { type CuaSelfGuard, resolveCuaSelfGuard } from "./cuaSelfGuard"; import { createToolRunId, invokeWithAbort, @@ -97,6 +99,12 @@ export async function createMcpTools(params: { servers: McpServerConfig[]; onLoadError?: (message: string) => void; loadFailureMode?: "continue" | "throw"; + /** + * 允许 cua-driver 的工具看到并操作 LiveAgent 自己的窗口。默认 false + * ——自指操作能绕过工具审批、改写权限设置、关闭应用本身。见 + * `cuaSelfGuard.ts`。 + */ + cuaAllowSelfTargeting?: boolean; }): Promise< BuiltinToolBundle<{ /** Maps the safe tool name (used by LLM) to the underlying MCP server/tool. */ @@ -105,6 +113,12 @@ export async function createMcpTools(params: { > { const servers = params.servers ?? []; const enabledServers = servers.filter((s) => s.enabled); + // 只有真的挂了 cua-driver 才去问宿主 pid,别的组合零开销。 + const cuaSelfGuard: CuaSelfGuard | null = enabledServers.some( + (server) => server.id?.trim().toLowerCase() === CUA_DRIVER_SERVER_ID, + ) + ? await resolveCuaSelfGuard(params.cuaAllowSelfTargeting === true) + : null; const invalid: Array<{ label: string; reason: string }> = []; for (const s of enabledServers) { @@ -256,6 +270,22 @@ export async function createMcpTools(params: { }; } + // 自指闸门:拦在发出调用之前,宿主窗口的 pid / window_id 直接拒绝。 + if (cuaSelfGuard && mapped.serverId.trim().toLowerCase() === CUA_DRIVER_SERVER_ID) { + const refusal = cuaSelfGuard.refuse(toolCall.arguments); + if (refusal) { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: refusal }], + details: { serverId: mapped.serverId, tool: mapped.toolName, blocked: "self_target" }, + isError: true, + timestamp: now, + }; + } + } + try { return await withMcpServerCallLock( mapped.serverId, @@ -285,11 +315,23 @@ export async function createMcpTools(params: { { onAbort: () => requestRuntimeCancel(runId) }, ); + // 出参过滤:把宿主自己的记录从窗口 / 应用枚举里摘掉,顺手记下 + // 它的 window_id 供后续入参拦截使用。 + const rawContent = res?.content ?? [{ type: "text", text: "" }]; + const content = + cuaSelfGuard && mapped.serverId.trim().toLowerCase() === CUA_DRIVER_SERVER_ID + ? rawContent.map((block) => + block.type === "text" + ? { ...block, text: cuaSelfGuard.strip(block.text) } + : block, + ) + : rawContent; + return { role: "toolResult", toolCallId: toolCall.id, toolName: toolCall.name, - content: res?.content ?? [{ type: "text", text: "" }], + content, details: { serverId: mapped.serverId, serverLabel: mapped.serverLabel, diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index 1170a3d53..f32ed1505 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -1698,6 +1698,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { agentTemplates: settings.agents, getMcpSettings: getEffectiveMcpSettings, getToolPolicies, + getCuaAllowSelfTargeting: () => settings.system.cuaAllowSelfTargeting === true, commandSafetyMode: effectiveCommandSafetyMode, planModeEnabled: effectivePlanModeEnabled, applyMcpOps: (ops) => { diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 5f59f752a..f28a7b2b6 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -302,6 +302,8 @@ export type RunAgentConversationTurnParams = { getMcpSettings: () => AppSettings["mcp"]; /** 工具审批策略的实时读取(权威 settingsRef,非 turn 级快照),缺省视为空表。 */ getToolPolicies?: () => AppSettings["system"]["toolPolicies"]; + /** 允许 CUA 工具操作 LiveAgent 自身;默认 false,见 lib/tools/cuaSelfGuard.ts。 */ + getCuaAllowSelfTargeting?: () => boolean; /** 命令执行方式(turn 级快照):ask 全量审批 / auto 按策略 / sandbox(±断网)。 */ commandSafetyMode?: AppSettings["system"]["commandSafetyMode"]; /** Plan mode(turn 级快照):真时本轮只注入只读工具 + ExitPlanMode 提交闸门。 */ @@ -393,6 +395,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP agentTemplates, getMcpSettings, getToolPolicies, + getCuaAllowSelfTargeting, commandSafetyMode, planModeEnabled, applyMcpOps, @@ -645,6 +648,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP sshManagerRemoteAllowed, onSshSessionsChanged, onTunnelsChanged, + cuaAllowSelfTargeting: getCuaAllowSelfTargeting?.() === true, onMcpLoadError: (message) => { const warning = `MCP 工具加载失败,已跳过并继续对话:${message || "未知错误"}`; console.warn(warning); diff --git a/crates/agent-gui/test/tools/cua-self-guard.test.mjs b/crates/agent-gui/test/tools/cua-self-guard.test.mjs new file mode 100644 index 000000000..c38ae7444 --- /dev/null +++ b/crates/agent-gui/test/tools/cua-self-guard.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const guard = loader.loadModule("src/lib/tools/cuaSelfGuard.ts"); + +const { refuseSelfTargetedCall, stripSelfFromJsonText, resetCuaSelfGuardCaches } = guard; + +const SELF_PID = 4242; +const OTHER_PID = 99; + +test.beforeEach(() => resetCuaSelfGuardCaches()); + +test("宿主 pid 的调用被拒绝,其他 pid 放行", () => { + assert.ok(refuseSelfTargetedCall({ pid: SELF_PID }, SELF_PID)); + assert.equal(refuseSelfTargetedCall({ pid: OTHER_PID }, SELF_PID), null); + assert.equal(refuseSelfTargetedCall({}, SELF_PID), null); + assert.equal(refuseSelfTargetedCall(undefined, SELF_PID), null); +}); + +test("拿不到宿主 pid 时不拦截——宁可不拦,也不误伤正常目标", () => { + assert.equal(refuseSelfTargetedCall({ pid: SELF_PID }, null), null); +}); + +test("窗口枚举结果剔除宿主记录", () => { + const payload = JSON.stringify({ + windows: [ + { window_id: 1, pid: SELF_PID, app_name: "LiveAgent" }, + { window_id: 2, pid: OTHER_PID, app_name: "Safari" }, + ], + }); + const stripped = JSON.parse(stripSelfFromJsonText(payload, SELF_PID)); + assert.deepEqual( + stripped.windows.map((w) => w.pid), + [OTHER_PID], + ); +}); + +test("过滤时学到的 window_id 让后续按 window_id 的调用也被拦下", () => { + // 过滤之前拦不住:window_id 与 pid 的对应关系只有 cua-driver 知道。 + assert.equal(refuseSelfTargetedCall({ window_id: 1 }, SELF_PID), null); + + stripSelfFromJsonText( + JSON.stringify([{ window_id: 1, pid: SELF_PID }, { window_id: 2, pid: OTHER_PID }]), + SELF_PID, + ); + + assert.ok(refuseSelfTargetedCall({ window_id: 1 }, SELF_PID)); + assert.equal(refuseSelfTargetedCall({ window_id: 2 }, SELF_PID), null); +}); + +test("嵌套结构里的宿主记录同样被剔除", () => { + const payload = JSON.stringify({ + desktop: { apps: [{ pid: SELF_PID }, { pid: OTHER_PID }] }, + }); + const stripped = JSON.parse(stripSelfFromJsonText(payload, SELF_PID)); + assert.deepEqual(stripped.desktop.apps, [{ pid: OTHER_PID }]); +}); + +test("非 JSON 载荷与无宿主记录的载荷原样返回", () => { + const plain = "Screenshot captured: 1920x1080"; + assert.equal(stripSelfFromJsonText(plain, SELF_PID), plain); + + const malformed = "{not json"; + assert.equal(stripSelfFromJsonText(malformed, SELF_PID), malformed); + + // 没有命中就不该重新序列化——避免无谓地改写模型看到的原文格式。 + const clean = JSON.stringify({ windows: [{ window_id: 2, pid: OTHER_PID }] }); + assert.equal(stripSelfFromJsonText(clean, SELF_PID), clean); +}); + +test("拿不到宿主 pid 时不过滤", () => { + const payload = JSON.stringify([{ pid: SELF_PID }]); + assert.equal(stripSelfFromJsonText(payload, null), payload); +}); diff --git a/crates/agent-ui/src/contracts/mcpServerDefaults.ts b/crates/agent-ui/src/contracts/mcpServerDefaults.ts index 34715c7b7..d3da0b0c9 100644 --- a/crates/agent-ui/src/contracts/mcpServerDefaults.ts +++ b/crates/agent-ui/src/contracts/mcpServerDefaults.ts @@ -32,3 +32,22 @@ export function hardcodedServerPolicyDefault(serverId: string): ToolPolicy | und export function effectiveServerPolicyDefault(serverId: string): ToolPolicy { return hardcodedServerPolicyDefault(serverId) ?? "allow"; } + +/** cua-driver 的 MCP server id。设置页与 MCP Hub 都要认它。 */ +export const CUA_DRIVER_SERVER_ID = "cua-driver"; + +/** + * 由专属设置页托管、不在 MCP Hub 里露面的 server。 + * + * cua-driver 的 MCP 条目是「设置 → CUA」那个开关的实现细节,不是用户 + * 手动维护的对象:command 由 `cua-driver manifest` 决定、args 不该随意 + * 改、权限策略与超时都在 CUA 那一节里调。在 Hub 里再列一遍只会制造两 + * 个都能改同一份配置的入口,改完还互相看不见对方的语义(比如在 Hub 里 + * 删掉条目,CUA 那节的开关会莫名变成关)。 + */ +const HUB_HIDDEN_SERVER_IDS: ReadonlySet = new Set([CUA_DRIVER_SERVER_ID]); + +/** 该 server 是否由专属设置页托管(MCP Hub 应当隐藏它)。 */ +export function isHubHiddenServerId(serverId: string): boolean { + return HUB_HIDDEN_SERVER_IDS.has(serverId.trim().toLowerCase()); +} diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 5e3759352..60d41128e 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1224,4 +1224,17 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.statusIdle": "Disabled", "settings.cuaDriver.statusNotInstalled": "Driver missing", "settings.cuaDriver.permissionsPending": "Not granted yet", + "settings.cuaDriver.groupBehavior": "Behavior", + "settings.cuaDriver.policyTitle": "Tool approval", + "settings.cuaDriver.policyDesc": + 'CUA tools really click, type, and quit applications. Every call asks by default; "allow" stops interrupting you, "deny" disables the whole group.', + "settings.cuaDriver.allowSelfTitle": "Allow targeting LiveAgent itself", + "settings.cuaDriver.allowSelfDesc": + "Off by default: LiveAgent's own windows are stripped from CUA's enumerations and cannot be targeted — otherwise the model could dismiss its own approval prompts, rewrite these settings, or quit the app. Turn it on only to automate LiveAgent with LiveAgent.", + "settings.cuaDriver.timeoutLabel": "Per-call timeout (ms)", + "settings.cuaDriver.timeoutHint": + "Capped at 600000. Launching an app or waiting for a window can take a while.", + "settings.cuaDriver.invocationLabel": "MCP launch command", + "settings.cuaDriver.invocationHint": + "Supplied by `cua-driver manifest` and tracked automatically across driver upgrades; no manual upkeep.", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index f40963148..c10bf3388 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1168,4 +1168,16 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.statusIdle": "未启用", "settings.cuaDriver.statusNotInstalled": "未安装驱动", "settings.cuaDriver.permissionsPending": "尚未授权", + "settings.cuaDriver.groupBehavior": "行为", + "settings.cuaDriver.policyTitle": "工具审批", + "settings.cuaDriver.policyDesc": + "CUA 的工具会真实点击、输入、关闭应用。默认每次调用都询问;改为「允许」即不再打断,「拒绝」则整组停用。", + "settings.cuaDriver.allowSelfTitle": "允许操作 LiveAgent 自身", + "settings.cuaDriver.allowSelfDesc": + "默认关闭:LiveAgent 自己的窗口不会出现在 CUA 的枚举结果里,也不能被直接操作——否则模型可以点掉自己的审批弹窗、改写这里的设置、甚至关掉应用。只有需要用 LiveAgent 自动化测试 LiveAgent 时才打开。", + "settings.cuaDriver.timeoutLabel": "单次调用超时(毫秒)", + "settings.cuaDriver.timeoutHint": "上限 600000。启动应用、等待窗口就绪这类操作可能需要更长时间。", + "settings.cuaDriver.invocationLabel": "MCP 启动命令", + "settings.cuaDriver.invocationHint": + "由 cua-driver manifest 提供,随驱动升级自动跟进,不需要手动维护。", } as const satisfies Record; diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index 9c7dc6056..4d1d9ed9b 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -1343,6 +1343,8 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { executionMode: normalizeExecutionMode(obj.executionMode), workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), + // 安全侧开关:任何非 true 的值都收敛成 false。 + cuaAllowSelfTargeting: obj.cuaAllowSelfTargeting === true, commandSafetyMode: normalizeCommandSafetyMode(obj.commandSafetyMode), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups), diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index 882f24f98..1b927f491 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -258,6 +258,15 @@ export type SystemSettings = { * 可选:旧快照缺失该字段时视为空表(全部走默认),保证零回归。 */ toolPolicies?: Record; + /** + * 允许 CUA 工具把 LiveAgent 自己当作操作目标。缺省 false。 + * + * 关闭时(默认)宿主的窗口既不会出现在 cua-driver 的枚举结果里,也不 + * 能被直接寻址——模型操作宿主界面等于能点掉自己的审批弹窗、改写这份 + * 权限设置、或者直接关掉应用。打开它的正当场景只有一个:用 LiveAgent + * 自动化测试 LiveAgent。实现见 `lib/tools/cuaSelfGuard.ts`。 + */ + cuaAllowSelfTargeting?: boolean; commandSafetyMode: CommandSafetyMode; workspaceProjects: WorkspaceProject[]; workspaceProjectGroups: WorkspaceProjectGroup[]; diff --git a/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx b/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx index 7e6524cb4..5ae0a8678 100644 --- a/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx +++ b/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx @@ -5,11 +5,12 @@ import { Badge } from "@liveagent/ui/components/ui/badge"; import { useLocale } from "@liveagent/ui/i18n/index"; import { McpRegistryBrowser } from "@liveagent/ui/pages/mcp-hub/McpRegistryBrowser"; import { McpServerEditModal, McpServersForm } from "@liveagent/ui/pages/mcp-hub/McpServersForm"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { HubHeader } from "../../components/hub/HubChrome"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { Tabs, TabsContent } from "../../components/ui/tabs"; +import { isHubHiddenServerId } from "../../contracts/mcpServerDefaults"; import { McpImportView } from "./McpImportView"; type McpHubPageProps = { @@ -39,8 +40,14 @@ export function McpHubPage(props: McpHubPageProps) { import: "", }); - const serverCount = settings.mcp.servers.length; - const enabledCount = settings.mcp.servers.filter((server) => server.enabled).length; + // 与 McpServersForm 的列表口径一致:由专属设置页托管的 server 不计入 + // Hub 的徽章,否则会出现「0 个 server 却显示 1/1 已启用」。 + const visibleServers = useMemo( + () => settings.mcp.servers.filter((server) => !isHubHiddenServerId(server.id)), + [settings.mcp.servers], + ); + const serverCount = visibleServers.length; + const enabledCount = visibleServers.filter((server) => server.enabled).length; const activeSearchQuery = searchQueries[view]; const searchPlaceholder = view === "store" diff --git a/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx b/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx index 34ebef5bf..2a4c33054 100644 --- a/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx +++ b/crates/agent-ui/src/pages/mcp-hub/McpServersForm.tsx @@ -8,7 +8,10 @@ import { Button } from "@liveagent/ui/components/ui/button"; import { useLocale } from "@liveagent/ui/i18n/index"; import { rankFuzzySearchResults } from "@liveagent/ui/lib/shared/fuzzySearch"; import { useMemo } from "react"; -import { effectiveServerPolicyDefault } from "../../contracts/mcpServerDefaults"; +import { + effectiveServerPolicyDefault, + isHubHiddenServerId, +} from "../../contracts/mcpServerDefaults"; import { McpServerCard } from "./McpServerCard"; export { McpServerEditModal } from "./McpServerEditModal"; @@ -32,25 +35,30 @@ type McpServersFormProps = { export function McpServersForm(props: McpServersFormProps) { const { settings, setSettings, query, onAddServer, onEditServer } = props; const { t } = useLocale(); - const servers = settings.mcp.servers; + // 由专属设置页托管的 server 不在 Hub 里露面(当前是 cua-driver,归 + // 「设置 → CUA」管)。过滤后仍需拿到原始下标:McpServerCard 的编辑 / + // 删除都按 settings.mcp.servers 的位置写回。 + const servers = useMemo( + () => + settings.mcp.servers + .map((server, idx) => ({ server, idx })) + .filter(({ server }) => !isHubHiddenServerId(server.id)), + [settings.mcp.servers], + ); const serverCount = servers.length; const filtered = useMemo(() => { - return rankFuzzySearchResults( - servers.map((server, idx) => ({ server, idx })), - query, - ({ server }) => [ - server.id, - server.description, - server.docsUrl, - server.command, - server.url, - server.transport, - ...(server.args ?? []), - ...Object.keys(server.env ?? {}), - ...Object.keys(server.headers ?? {}), - ], - ); + return rankFuzzySearchResults(servers, query, ({ server }) => [ + server.id, + server.description, + server.docsUrl, + server.command, + server.url, + server.transport, + ...(server.args ?? []), + ...Object.keys(server.env ?? {}), + ...Object.keys(server.headers ?? {}), + ]); }, [query, servers]); return ( diff --git a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx index 8fee2a3db..8bfc90a5a 100644 --- a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx +++ b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx @@ -1,11 +1,18 @@ -import { type McpServerConfig, updateMcp } from "@liveagent/app/lib/settings"; +import { + type McpServerConfig, + type ToolPolicy, + updateMcp, + updateSystem, +} from "@liveagent/app/lib/settings"; import type { SettingsSectionProps } from "@liveagent/app/pages/settings/types"; import { invoke } from "@liveagent/app/shims/tauriCore"; import { listen } from "@liveagent/app/shims/tauriEvent"; +import { ToolPolicyToggle } from "@liveagent/ui/components/hub/ToolPolicyToggle"; import { AlertTriangle, Check, Circle, + Clock3, Download, ExternalLink, Hand, @@ -14,8 +21,16 @@ import { Package, Plug, RefreshCw, + Settings2, Shield, + ShieldOff, + Terminal, } from "@liveagent/ui/components/IconSet"; +import { Input } from "@liveagent/ui/components/ui/input"; +import { + CUA_DRIVER_SERVER_ID as CUA_SERVER_ID, + effectiveServerPolicyDefault, +} from "@liveagent/ui/contracts/mcpServerDefaults"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { AgentActivationSwitch } from "@liveagent/ui/pages/settings/shared"; @@ -37,13 +52,16 @@ function SectionCardHeader({ icon: Icon, title }: { icon: IconComponent; title: * * 能力本身没有任何专属代码路径——`cua-driver mcp` 就是一个普通 stdio MCP * server,接进来之后约 60 个工具由 `tools/list` 自动发现,调用、审批、 - * 截图渲染全部复用既有 MCP 链路。这一节只解决它前面那三个一次性问题: - * 装了吗、macOS 授权了吗、server 配置怎么填。 + * 截图渲染全部复用既有 MCP 链路。 * - * 装完并接入之后,日常管理(启停、改策略、改参数)都在 MCP Hub 的 - * server 卡片上,这一节只保留状态展示与「去 MCP Hub 管理」的指引。 + * 但它的 MCP 条目**不在 MCP Hub 里露面**(见 + * `contracts/mcpServerDefaults.ts` 的 `isHubHiddenServerId`):那条目是 + * 这里总开关的实现细节,command 由 `cua-driver manifest` 决定、args 不该 + * 随意改。两个入口都能写同一份配置只会让人搞不清哪边说了算——所以启停、 + * 审批策略、超时、自指闸门全部收在这一节。 * - * 仅桌面端注册(见 `SettingsPage`):这些命令依赖 Tauri 后端。 + * 仅桌面端注册(见 agent-gui 的 `settingsExtension`):探测 / 安装 / + * 授权都依赖 Tauri 命令。 */ export const CUA_DRIVER_SERVER_ID = "cua-driver"; @@ -115,9 +133,21 @@ export function CuaDriverSection(props: SettingsSectionProps) { const mountedRef = useRef(true); // 总开关的真实状态就是这个 MCP server 的启用状态——没有第二份配置。 - const enabled = settings.mcp.servers.some( - (server) => server.id.trim().toLowerCase() === CUA_DRIVER_SERVER_ID && server.enabled, + const serverEntry = settings.mcp.servers.find( + (server) => server.id.trim().toLowerCase() === CUA_DRIVER_SERVER_ID, ); + const enabled = serverEntry?.enabled === true; + + const policyKey = `server:${CUA_SERVER_ID}`; + const policy: ToolPolicy = + settings.system.toolPolicies?.[policyKey] ?? effectiveServerPolicyDefault(CUA_SERVER_ID); + const allowSelfTargeting = settings.system.cuaAllowSelfTargeting === true; + + // 超时用受控草稿:边打字边写回会把 "6" 这种中间态存成 6ms。 + const [timeoutDraft, setTimeoutDraft] = useState(() => String(serverEntry?.timeoutMs ?? 60_000)); + useEffect(() => { + setTimeoutDraft(String(serverEntry?.timeoutMs ?? 60_000)); + }, [serverEntry?.timeoutMs]); const refresh = useCallback(async () => { setChecking(true); @@ -232,6 +262,42 @@ export function CuaDriverSection(props: SettingsSectionProps) { }); } + function setPolicy(next: ToolPolicy) { + setSettings((prev) => { + const current = { ...(prev.system.toolPolicies ?? {}) }; + // 与 McpServersForm 同一条规则:等于该 server 的缺省值才删 key。 + // cua-driver 的缺省是 ask,所以「始终允许」必须显式落库。 + if (next === effectiveServerPolicyDefault(CUA_SERVER_ID)) delete current[policyKey]; + else current[policyKey] = next; + return updateSystem(prev, { + toolPolicies: Object.keys(current).length > 0 ? current : undefined, + }); + }); + } + + function setAllowSelfTargeting(next: boolean) { + setSettings((prev) => updateSystem(prev, { cuaAllowSelfTargeting: next })); + } + + function commitTimeout() { + const parsed = Number.parseInt(timeoutDraft.trim(), 10); + const next = + Number.isFinite(parsed) && parsed > 0 + ? Math.min(parsed, 600_000) + : (serverEntry?.timeoutMs ?? 60_000); + setTimeoutDraft(String(next)); + if (!serverEntry || next === serverEntry.timeoutMs) return; + setSettings((prev) => + updateMcp(prev, { + servers: prev.mcp.servers.map((server) => + server.id.trim().toLowerCase() === CUA_DRIVER_SERVER_ID + ? { ...server, timeoutMs: next } + : server, + ), + }), + ); + } + const statusText = !installed ? t("settings.cuaDriver.statusNotInstalled") : enabled @@ -419,6 +485,85 @@ export function CuaDriverSection(props: SettingsSectionProps) {
) : null} + {/* 参数全在这里改,MCP Hub 不再列出 cua-driver 这一条——两个入口都能 + 写同一份配置只会让人搞不清哪边说了算。 */} +
+ + +
+
+
{t("settings.cuaDriver.policyTitle")}
+

+ {t("settings.cuaDriver.policyDesc")} +

+
+ +
+ + {/* 自指闸门。默认关闭:模型操作宿主界面能点掉自己的审批弹窗、改写 + 这份设置、甚至关掉应用。 */} +
+
+
+ + {t("settings.cuaDriver.allowSelfTitle")} +
+

+ {t("settings.cuaDriver.allowSelfDesc")} +

+
+ setAllowSelfTargeting(!allowSelfTargeting)} + /> +
+ +
+ + setTimeoutDraft(event.target.value)} + onBlur={commitTimeout} + placeholder="60000" + className="w-40 font-mono text-[13px]" + /> +

+ {t("settings.cuaDriver.timeoutHint")} +

+
+ +
+
+ + {t("settings.cuaDriver.invocationLabel")} +
+
+            {serverEntry
+              ? [serverEntry.command, ...serverEntry.args].join(" ")
+              : [probe?.mcpCommand ?? "cua-driver", ...(probe?.mcpArgs ?? ["mcp"])].join(" ")}
+          
+

+ {t("settings.cuaDriver.invocationHint")} +

+
+
+

From 83fbb6716d8311920ed95e9aef786abe9e42b3da Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 13:30:53 +0800 Subject: [PATCH 24/28] =?UTF-8?q?fix(cua):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=96=87=E6=A1=88=E6=94=B9=E4=B8=BA=E7=94=A8=E6=88=B7=E8=A7=86?= =?UTF-8?q?=E8=A7=92=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=9A=B4=E9=9C=B2=20MCP=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CUA 之于用户是「让 AI 操作这台电脑」这一个能力,走 MCP 接入是它的实现 方式——设置页里提「接入 MCP」「tools/list 自动发现」「去 MCP Hub 的 server 卡片调整」既没有可操作性(Hub 里已经看不到这条目),又要求用户 先理解一层与目标无关的协议。 改为按能力描述:约 60 项操作能力、开关即可用、审批与其他参数就在下方。 顺带把 policyNote 从「该 server 默认 ask」重写为对后果的直白提示——点击、 输入、关闭应用都不可撤销。代码注释保留 MCP 说明,那是给维护者看的。 --- crates/agent-ui/src/i18n/translations/enUSSettings.ts | 10 +++++----- crates/agent-ui/src/i18n/translations/zhCNSettings.ts | 11 +++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 60d41128e..d64cf56b0 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1188,7 +1188,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.title": "CUA", "settings.cuaDriver.subtitle": "Computer Use Agent — let the AI operate this machine directly", "settings.cuaDriver.description": - "With cua-driver connected, the AI can list windows, take screenshots, click, type, invoke native menus, and drive the browser. It connects as an ordinary MCP server — its ~60 tools are discovered automatically via tools/list, and day-to-day management lives on its MCP Hub server card. The driver comes from", + "Once enabled, the AI can list windows, take screenshots, click, type, invoke native menus, and drive the browser — around 60 capabilities, all provided by the driver. The driver comes from", "settings.cuaDriver.groupDriver": "Driver", "settings.cuaDriver.recheck": "Re-check", "settings.cuaDriver.detected": "cua-driver installed", @@ -1211,14 +1211,14 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.statusGranted": "Granted", "settings.cuaDriver.enableTitle": "Enable CUA", "settings.cuaDriver.enableDesc": - "Turning this on connects it over MCP; its ~60 automation tools become visible to the AI.", + "Turn this on to let the AI operate this computer — around 60 capabilities become available.", "settings.cuaDriver.enabledDesc": - "Connected over MCP. Arguments and the permission policy can be tuned on the cua-driver card in MCP Hub.", + "The AI can operate this computer. Approval behaviour and other settings are below.", "settings.cuaDriver.enableNeedsDriver": "Install cua-driver first.", "settings.cuaDriver.enable": "Enable CUA", "settings.cuaDriver.disable": "Disable CUA", "settings.cuaDriver.policyNote": - "This server defaults to asking every time. Its tools really click, type, and quit applications; once connected you can switch it to always-allow or deny on its MCP Hub server card.", + "Every CUA action lands on this real machine — clicks, keystrokes, and quitting apps cannot be undone. It asks each time by default; switch to always-allow only when you trust the task at hand.", "settings.cuaDriver.groupAbout": "About", "settings.cuaDriver.statusActive": "Enabled", "settings.cuaDriver.statusIdle": "Disabled", @@ -1234,7 +1234,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.timeoutLabel": "Per-call timeout (ms)", "settings.cuaDriver.timeoutHint": "Capped at 600000. Launching an app or waiting for a window can take a while.", - "settings.cuaDriver.invocationLabel": "MCP launch command", + "settings.cuaDriver.invocationLabel": "Driver launch command", "settings.cuaDriver.invocationHint": "Supplied by `cua-driver manifest` and tracked automatically across driver upgrades; no manual upkeep.", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index c10bf3388..ab8762d90 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1133,7 +1133,7 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.title": "CUA", "settings.cuaDriver.subtitle": "Computer Use Agent —— 让 AI 直接操作这台电脑", "settings.cuaDriver.description": - "接入 cua-driver 后,AI 可以枚举窗口、截屏、点击、输入、调用原生菜单并控制浏览器。它以一个普通 MCP server 的形式接入,约 60 个工具由 tools/list 自动发现,日常管理在 MCP Hub 的 server 卡片上。驱动来自", + "启用后,AI 可以枚举窗口、截屏、点击、输入、调用原生菜单并控制浏览器,约 60 项操作能力全部由驱动提供。驱动来自", "settings.cuaDriver.groupDriver": "驱动", "settings.cuaDriver.recheck": "重新检测", "settings.cuaDriver.detected": "已安装 cua-driver", @@ -1155,14 +1155,13 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.grantPermissions": "前往授权", "settings.cuaDriver.statusGranted": "已授权", "settings.cuaDriver.enableTitle": "启用 CUA", - "settings.cuaDriver.enableDesc": "开启后接入 MCP,约 60 个操作工具随即对 AI 可见。", - "settings.cuaDriver.enabledDesc": - "已接入 MCP。参数与权限策略可在 MCP Hub 的 cua-driver 卡片里调整。", + "settings.cuaDriver.enableDesc": "开启后 AI 即可操作这台电脑,约 60 项操作能力随之可用。", + "settings.cuaDriver.enabledDesc": "AI 已可以操作这台电脑。下方可调整审批方式与其他参数。", "settings.cuaDriver.enableNeedsDriver": "需要先安装 cua-driver。", "settings.cuaDriver.enable": "启用 CUA", "settings.cuaDriver.disable": "停用 CUA", "settings.cuaDriver.policyNote": - "该 server 默认逐次询问(ask)。这些工具会真实点击、输入、关闭应用,接入后可在 MCP Hub 的 server 卡片里改为始终允许或禁止。", + "CUA 的每一次操作都会真实作用于这台电脑——点击、输入、关闭应用都不可撤销。默认逐次询问,请只在信任当前任务时改为始终允许。", "settings.cuaDriver.groupAbout": "关于", "settings.cuaDriver.statusActive": "已启用", "settings.cuaDriver.statusIdle": "未启用", @@ -1177,7 +1176,7 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "默认关闭:LiveAgent 自己的窗口不会出现在 CUA 的枚举结果里,也不能被直接操作——否则模型可以点掉自己的审批弹窗、改写这里的设置、甚至关掉应用。只有需要用 LiveAgent 自动化测试 LiveAgent 时才打开。", "settings.cuaDriver.timeoutLabel": "单次调用超时(毫秒)", "settings.cuaDriver.timeoutHint": "上限 600000。启动应用、等待窗口就绪这类操作可能需要更长时间。", - "settings.cuaDriver.invocationLabel": "MCP 启动命令", + "settings.cuaDriver.invocationLabel": "驱动启动命令", "settings.cuaDriver.invocationHint": "由 cua-driver manifest 提供,随驱动升级自动跟进,不需要手动维护。", } as const satisfies Record; From d616c5633ba4692448ab73c59f0ae37bbc01189d Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 13:33:56 +0800 Subject: [PATCH 25/28] =?UTF-8?q?fix(cua):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=A9=B1=E5=8A=A8=E5=90=AF=E5=8A=A8=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 那一栏对用户没有可操作性——命令由 cua-driver manifest 决定,改不得也 不需要看。留着只是把一层实现细节摆在能力页上。 同步删除 invocationLabel / invocationHint 两个 i18n key。 --- .../src/i18n/translations/enUSSettings.ts | 3 --- .../src/i18n/translations/zhCNSettings.ts | 3 --- .../src/pages/settings/CuaDriverSection.tsx | 16 ---------------- 3 files changed, 22 deletions(-) diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index d64cf56b0..21024fd74 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1234,7 +1234,4 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.timeoutLabel": "Per-call timeout (ms)", "settings.cuaDriver.timeoutHint": "Capped at 600000. Launching an app or waiting for a window can take a while.", - "settings.cuaDriver.invocationLabel": "Driver launch command", - "settings.cuaDriver.invocationHint": - "Supplied by `cua-driver manifest` and tracked automatically across driver upgrades; no manual upkeep.", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index ab8762d90..8a9cd416e 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1176,7 +1176,4 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "默认关闭:LiveAgent 自己的窗口不会出现在 CUA 的枚举结果里,也不能被直接操作——否则模型可以点掉自己的审批弹窗、改写这里的设置、甚至关掉应用。只有需要用 LiveAgent 自动化测试 LiveAgent 时才打开。", "settings.cuaDriver.timeoutLabel": "单次调用超时(毫秒)", "settings.cuaDriver.timeoutHint": "上限 600000。启动应用、等待窗口就绪这类操作可能需要更长时间。", - "settings.cuaDriver.invocationLabel": "驱动启动命令", - "settings.cuaDriver.invocationHint": - "由 cua-driver manifest 提供,随驱动升级自动跟进,不需要手动维护。", } as const satisfies Record; diff --git a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx index 8bfc90a5a..5c27b608a 100644 --- a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx +++ b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx @@ -24,7 +24,6 @@ import { Settings2, Shield, ShieldOff, - Terminal, } from "@liveagent/ui/components/IconSet"; import { Input } from "@liveagent/ui/components/ui/input"; import { @@ -547,21 +546,6 @@ export function CuaDriverSection(props: SettingsSectionProps) { {t("settings.cuaDriver.timeoutHint")}

- -
-
- - {t("settings.cuaDriver.invocationLabel")} -
-
-            {serverEntry
-              ? [serverEntry.command, ...serverEntry.args].join(" ")
-              : [probe?.mcpCommand ?? "cua-driver", ...(probe?.mcpArgs ?? ["mcp"])].join(" ")}
-          
-

- {t("settings.cuaDriver.invocationHint")} -

-
From 354af6aec15f3476c05ca7dd2cf7621f1229a065 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 13:37:35 +0800 Subject: [PATCH 26/28] =?UTF-8?q?fix(cua):=20=E6=8E=88=E6=9D=83=E8=8A=82?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=AB=8B=E5=8D=B3=E6=B8=B2=E6=9F=93=EF=BC=8C?= =?UTF-8?q?=E9=9D=9E=20macOS=20=E4=B8=8D=E5=86=8D=E5=87=BA=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个问题同一个根因:授权那一节的存在与否取决于 cua_driver_permissions_status 的返回,而那趟查询要 spawn 子进程、还可能等 CuaDriver.app 的守护进程握手。 - 进设置页要等一阵卡片才「长出来」,整页跳一下 - Windows / Linux 上同样要等这趟查询回来,才知道不该显示 改法: 1. cua_driver_probe 直接带 permissions_required(只有 macOS 为 true)。 前端据此立即决定要不要渲染整节——Windows / Linux 整节不存在,macOS 上骨架随第一帧就位,只有里面的状态文字在等查询。 2. 两个探测并行发,不再串行等 probe 再问权限。 3. permissions_status 内部的两次子进程调用(daemon status 与 permissions status --json)彼此无依赖,改并行。 4. 授权查询失败不清空已有结果,改为显示「无法读取授权状态」并保留授权 按钮可点——授权引导本身就是修复手段,锁住只会让用户卡住。之前 grant 之后重查若临时失败,会把刚拿到的授权显示成「未授权」。 --- .../src-tauri/src/services/cua_driver/mod.rs | 37 +++++++-- .../src/i18n/translations/enUSSettings.ts | 2 + .../src/i18n/translations/zhCNSettings.ts | 2 + .../src/pages/settings/CuaDriverSection.tsx | 77 +++++++++++++------ 4 files changed, 87 insertions(+), 31 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs b/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs index dbeb6ccc1..e997f8f64 100644 --- a/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/cua_driver/mod.rs @@ -51,6 +51,10 @@ pub struct CuaDriverProbe { /// 这里跟着变,不需要我们发版。 pub mcp_command: Option, pub mcp_args: Vec, + /// 本平台是否存在需要用户处理的系统授权门槛。只有 macOS 有 TCC, + /// Windows / Linux 恒 false —— 前端据此**立即**决定要不要渲染授权 + /// 那一节,不必等 `permissions_status` 那趟子进程回来。 + pub permissions_required: bool, /// 探测失败的原因(未安装是正常状态,不算错误,此时为 None)。 pub error: Option, } @@ -206,12 +210,16 @@ fn run_capture(program: &Path, args: &[&str]) -> Result { /// 探测安装状态。未安装不是错误——返回 `installed: false, error: None`。 pub fn probe() -> CuaDriverProbe { let Some(path) = find_binary() else { - return CuaDriverProbe::default(); + return CuaDriverProbe { + permissions_required: cfg!(target_os = "macos"), + ..Default::default() + }; }; let mut probe = CuaDriverProbe { installed: true, path: Some(path.to_string_lossy().into_owned()), + permissions_required: cfg!(target_os = "macos"), ..Default::default() }; @@ -291,11 +299,16 @@ pub fn permissions_status() -> CuaDriverPermissions { // 守护进程状态单独问一次:`permissions status` 在守护进程没起来时 // 只会报 unknown,不区分「没装」和「没跑」,对用户不可读。 - let daemon_running = run_capture(&path, &["status"]) - .map(|out| out.contains("is running")) - .unwrap_or(false); - - match run_capture(&path, &["permissions", "status", "--json"]) { + // + // 两次调用各要 spawn 一个进程、并可能等守护进程握手,串起来就是用户 + // 盯着空白等两轮。彼此无依赖,并行跑。 + let daemon_path = path.clone(); + let daemon_probe = + std::thread::spawn(move || run_capture(&daemon_path, &["status"]).is_ok_and(|out| out.contains("is running"))); + let status = run_capture(&path, &["permissions", "status", "--json"]); + let daemon_running = daemon_probe.join().unwrap_or(false); + + match status { Ok(raw) => match serde_json::from_str::(&raw) { Ok(payload) => CuaDriverPermissions { supported: true, @@ -463,6 +476,18 @@ mod tests { assert!(preview.args.iter().any(|arg| arg.contains(&preview.source_url))); } + #[test] + fn permissions_required_tracks_the_platform_tcc_gate() { + // 前端靠这一位决定要不要渲染授权那一节;不能等 permissions_status + // 那趟慢查询回来才知道平台,否则卡片会「先没有、后长出来」。 + assert_eq!(probe().permissions_required, cfg!(target_os = "macos")); + assert_eq!( + CuaDriverProbe::default().permissions_required, + false, + "Default 用于「探测彻底失败」的兜底,不该声称有授权门槛" + ); + } + #[test] fn probe_reports_not_installed_without_error() { // 未安装是正常状态,不该被前端当成故障红条渲染。 diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 21024fd74..5ea1eabf3 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -1234,4 +1234,6 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cuaDriver.timeoutLabel": "Per-call timeout (ms)", "settings.cuaDriver.timeoutHint": "Capped at 600000. Launching an app or waiting for a window can take a while.", + "settings.cuaDriver.permissionsChecking": "Checking permissions…", + "settings.cuaDriver.permissionsUnknown": "Could not read permission status", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index 8a9cd416e..ca8f0533a 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -1176,4 +1176,6 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "默认关闭:LiveAgent 自己的窗口不会出现在 CUA 的枚举结果里,也不能被直接操作——否则模型可以点掉自己的审批弹窗、改写这里的设置、甚至关掉应用。只有需要用 LiveAgent 自动化测试 LiveAgent 时才打开。", "settings.cuaDriver.timeoutLabel": "单次调用超时(毫秒)", "settings.cuaDriver.timeoutHint": "上限 600000。启动应用、等待窗口就绪这类操作可能需要更长时间。", + "settings.cuaDriver.permissionsChecking": "正在检查授权状态…", + "settings.cuaDriver.permissionsUnknown": "无法读取授权状态", } as const satisfies Record; diff --git a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx index 5c27b608a..8c7a20d07 100644 --- a/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx +++ b/crates/agent-ui/src/pages/settings/CuaDriverSection.tsx @@ -76,6 +76,8 @@ type Probe = { version?: string | null; mcpCommand?: string | null; mcpArgs?: string[]; + /** 本平台是否有系统授权门槛。只有 macOS 为 true。 */ + permissionsRequired?: boolean; error?: string | null; }; @@ -127,6 +129,7 @@ export function CuaDriverSection(props: SettingsSectionProps) { const [installing, setInstalling] = useState(false); const [granting, setGranting] = useState(false); const [checking, setChecking] = useState(true); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [log, setLog] = useState([]); const [error, setError] = useState(null); const mountedRef = useRef(true); @@ -148,23 +151,26 @@ export function CuaDriverSection(props: SettingsSectionProps) { setTimeoutDraft(String(serverEntry?.timeoutMs ?? 60_000)); }, [serverEntry?.timeoutMs]); + /** + * 两个探测并行发。 + * + * 权限查询要 spawn 子进程、还可能等 CuaDriver.app 的守护进程握手,比 + * probe 慢得多;串行的话用户会先看到驱动信息、隔一会儿授权那节才「长 + * 出来」,整页跳一下。并行之后授权节的骨架随第一帧就位,只有里面的 + * 状态文字在等。 + * + * 授权查询失败不清空已有结果:`permissions grant` 之后重查若临时失败, + * 直接归零会把刚拿到的授权显示成「未授权」。 + */ const refresh = useCallback(async () => { setChecking(true); + setPermissionsLoading(true); + const probeTask = invoke("cua_driver_probe"); + const permissionsTask = invoke("cua_driver_permissions_status").catch(() => null); + try { - const next = await invoke("cua_driver_probe"); - if (!mountedRef.current) return; - setProbe(next); - if (next.installed) { - try { - const perms = await invoke("cua_driver_permissions_status"); - if (mountedRef.current) setPermissions(perms); - } catch { - // 权限查询失败不该拖垮整节:它只是补充信息。 - if (mountedRef.current) setPermissions(null); - } - } else if (mountedRef.current) { - setPermissions(null); - } + const next = await probeTask; + if (mountedRef.current) setProbe(next); } catch (err) { if (mountedRef.current) { setProbe({ installed: false }); @@ -173,6 +179,11 @@ export function CuaDriverSection(props: SettingsSectionProps) { } finally { if (mountedRef.current) setChecking(false); } + + const perms = await permissionsTask; + if (!mountedRef.current) return; + if (perms) setPermissions(perms); + setPermissionsLoading(false); }, []); useEffect(() => { @@ -195,8 +206,12 @@ export function CuaDriverSection(props: SettingsSectionProps) { }, [installing]); const installed = probe?.installed === true; + // 是否渲染授权那一节只看平台与安装状态——两者都在 probe 里,不必等权限 + // 查询回来。Windows / Linux 没有 TCC 门槛,整节不存在。 + const showPermissions = installed && probe?.permissionsRequired === true; + const permissionsKnown = permissions?.supported === true; const permissionsPending = - permissions?.supported === true && (!permissions.accessibility || !permissions.screenRecording); + permissionsKnown && (!permissions.accessibility || !permissions.screenRecording); async function beginInstall() { setError(null); @@ -439,36 +454,48 @@ export function CuaDriverSection(props: SettingsSectionProps) {
{/* macOS TCC。授权归 CuaDriver.app 而非 LiveAgent —— 这是刻意选择的 - 代理模式,宿主不需要任何辅助功能 / 屏幕录制权限。 */} - {installed && permissions?.supported ? ( + 代理模式,宿主不需要任何辅助功能 / 屏幕录制权限。 + + 渲染条件只看 probe 里的平台位:Windows / Linux 没有这道门槛,整 + 节不存在;macOS 上则整节随第一帧就位,只有里面的状态在等查询, + 不会出现「卡片过一会儿才长出来」的跳动。 */} + {showPermissions ? (
- {permissionsPending ? ( + {!permissionsKnown ? ( + + ) : permissionsPending ? ( ) : ( )} - {permissionsPending - ? t("settings.cuaDriver.permissionsPending") - : t("settings.cuaDriver.statusGranted")} + {!permissionsKnown + ? permissionsLoading + ? t("settings.cuaDriver.permissionsChecking") + : t("settings.cuaDriver.permissionsUnknown") + : permissionsPending + ? t("settings.cuaDriver.permissionsPending") + : t("settings.cuaDriver.statusGranted")}

- {permissionsPending + {!permissionsKnown || permissionsPending ? t("settings.cuaDriver.permissionsDesc") : t("settings.cuaDriver.permissionsGranted").replace( "{bundleId}", - permissions.attributedTo ?? "com.trycua.driver", + permissions?.attributedTo ?? "com.trycua.driver", )}

- {permissionsPending ? ( + {permissionsKnown && !permissionsPending ? null : ( - ) : null} + )}
) : null} From 6269f930e9b0004a43752ced90db927222d348ad Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 14:23:52 +0800 Subject: [PATCH 27/28] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3=E7=9C=8B=E6=9D=BF=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue_board.md 和 .github/automation/ 是本地迭代过程产物,不入库。 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index de135aab5..c6821a9ee 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ workspace.zip /.trellis /AGENTS.md /CLAUDE.md +/issue_board.md +/.github/automation/ From 8c07a73dec69b44767dd4a934c90f49741d499c8 Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Wed, 26 Aug 2026 14:52:52 +0800 Subject: [PATCH 28/28] =?UTF-8?q?docs(cua):=20=E8=A1=A5=E5=85=85=20PR=20#6?= =?UTF-8?q?16=20=E7=9A=84=E7=95=8C=E9=9D=A2=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 docs/images/pr616/: - settings-overview.png:系统设置页整体 - cua-section.png:CUA 分区(驱动 / macOS 系统授权 / 行为) - toggle-states.png:「启用 CUA」开关 ON / OFF 对比 沿用 PR #621 的做法,截图入库后由 PR 正文经 raw.githubusercontent 引用, 满足 pr-governance 对「UI 改动必须附截图」的检查。 --- docs/images/pr616/cua-section.png | Bin 0 -> 113121 bytes docs/images/pr616/settings-overview.png | Bin 0 -> 133898 bytes docs/images/pr616/toggle-states.png | Bin 0 -> 28561 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/pr616/cua-section.png create mode 100644 docs/images/pr616/settings-overview.png create mode 100644 docs/images/pr616/toggle-states.png diff --git a/docs/images/pr616/cua-section.png b/docs/images/pr616/cua-section.png new file mode 100644 index 0000000000000000000000000000000000000000..1bba79241962c0772bedfef03031288c69b9f909 GIT binary patch literal 113121 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
5UL(-5#W72@W+*vpxW^TZ*WNoSOx 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Ð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
`9IxhP_hk<)^3$^iOgEB0|e@6sPJH>s7cpw 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 Lt-7#c^X~rx#WgOL literal 0 HcmV?d00001 diff --git a/docs/images/pr616/toggle-states.png b/docs/images/pr616/toggle-states.png new file mode 100644 index 0000000000000000000000000000000000000000..6968b27070a564632819155da5f2b310fb01b9b9 GIT binary patch literal 28561 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.?+3?a%I7;F4DN$wdsLU8xG#eJ>6%(Gq* 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 W=&S0rjn4VcK(y5L)oP!;jQ(GnWBQN) literal 0 HcmV?d00001