From 0c55abd92ddd19da0988066166d631f611b0e6e0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 20:48:58 -0500 Subject: [PATCH 01/18] feat(cua-driver): extend Project Centennial to Windows and Linux --- .github/workflows/ci-rust-windows.yml | 5 + .github/workflows/e2e-rust-linux.yml | 4 +- .../docs/computer-history-preview.md | 82 ++- libs/cua-driver/rust/Cargo.lock | 140 ++++- .../crates/cua-driver-core/src/history.rs | 4 +- .../rust/crates/cua-driver-testkit/src/mcp.rs | 73 ++- .../rust/crates/cua-driver/src/cli.rs | 277 ++++++--- .../crates/cua-driver/src/history_runtime.rs | 207 ++++++- .../rust/crates/cua-driver/src/main.rs | 44 +- .../rust/crates/cua-driver/src/serve.rs | 36 +- .../computer_history_cross_platform_test.rs | 582 ++++++++++++++++++ .../rust/crates/platform-linux/Cargo.toml | 4 + .../rust/crates/platform-linux/src/history.rs | 242 ++++++++ .../rust/crates/platform-linux/src/lib.rs | 3 + .../rust/crates/platform-windows/Cargo.toml | 6 + .../crates/platform-windows/src/history.rs | 255 ++++++++ .../rust/crates/platform-windows/src/lib.rs | 3 + .../tests/uninstall-history-purge-test.sh | 14 + .../uninstall-windows-history-regression.ps1 | 41 ++ libs/cua-driver/scripts/uninstall.ps1 | 31 +- libs/cua-driver/scripts/uninstall.sh | 28 +- scripts/ci/linux/run-rust-e2e.sh | 66 ++ scripts/ci/windows/run-rust-e2e.ps1 | 112 ++++ 23 files changed, 2093 insertions(+), 166 deletions(-) create mode 100644 libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs create mode 100644 libs/cua-driver/rust/crates/platform-linux/src/history.rs create mode 100644 libs/cua-driver/rust/crates/platform-windows/src/history.rs create mode 100644 libs/cua-driver/scripts/tests/uninstall-windows-history-regression.ps1 diff --git a/.github/workflows/ci-rust-windows.yml b/.github/workflows/ci-rust-windows.yml index 63117775fb..f9dc3f170d 100644 --- a/.github/workflows/ci-rust-windows.yml +++ b/.github/workflows/ci-rust-windows.yml @@ -93,6 +93,11 @@ jobs: - name: Run Windows app-name lookup tests working-directory: libs/cua-driver/rust run: "cargo test -p platform-windows launch_uwp::tests:: --lib --locked" + - name: Run Windows Computer History adapter tests + working-directory: libs/cua-driver/rust + run: "cargo test -p platform-windows history::tests:: --lib --locked" + - name: Validate Windows Computer History uninstall ordering + run: .\libs\cua-driver\scripts\tests\uninstall-windows-history-regression.ps1 - name: Prove unknown app launch stays bounded and responsive working-directory: libs/cua-driver/rust run: "cargo test -p cua-driver --test protocol_tools_call_test launch_unknown_app_is_bounded_and_keeps_mcp_session_responsive --locked -- --exact --nocapture" diff --git a/.github/workflows/e2e-rust-linux.yml b/.github/workflows/e2e-rust-linux.yml index beffe05fc8..20c0581c24 100644 --- a/.github/workflows/e2e-rust-linux.yml +++ b/.github/workflows/e2e-rust-linux.yml @@ -70,14 +70,14 @@ jobs: libgtk-3-dev clang pkg-config libdbus-1-dev libpipewire-0.3-dev \ libspa-0.2-dev libei-dev libx11-dev libxi-dev libxtst-dev libxext-dev \ libwebkit2gtk-4.1-dev libssl-dev libxdo-dev \ - libayatana-appindicator3-dev librsvg2-dev ffmpeg + libayatana-appindicator3-dev librsvg2-dev ffmpeg gnome-keyring - name: Run shared Rust behavior matrix env: CUA_E2E_INTERNAL_LANE: shared run: | xvfb-run -a --server-args="-screen 0 1920x1080x24" \ dbus-run-session -- bash -lc \ - "openbox >/tmp/cua-openbox.log 2>&1 & picom --backend xrender --config /dev/null >/tmp/cua-picom.log 2>&1 & sleep 2; scripts/ci/linux/run-rust-e2e.sh" + "eval \"\$(printf test-password | gnome-keyring-daemon --unlock --components=secrets)\"; openbox >/tmp/cua-openbox.log 2>&1 & picom --backend xrender --config /dev/null >/tmp/cua-picom.log 2>&1 & sleep 2; scripts/ci/linux/run-rust-e2e.sh" - name: Upload shared results if: always() uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 diff --git a/libs/cua-driver/docs/computer-history-preview.md b/libs/cua-driver/docs/computer-history-preview.md index 1c63105a20..5deaaa4c55 100644 --- a/libs/cua-driver/docs/computer-history-preview.md +++ b/libs/cua-driver/docs/computer-history-preview.md @@ -1,26 +1,35 @@ -# Try the Computer History macOS preview +# Try the Computer History desktop preview This guide shows you how to enable Cua Driver's encrypted Computer History, inspect it, let an authorized agent read it, and remove it. It applies to -nightly macOS builds that include the experimental preview. +nightly macOS, Windows, and Linux builds that include the experimental preview. Computer History is off by default. The preview records metadata for actions performed through Cua Driver. It does not watch unrelated desktop activity. ## Before you start -- Use macOS. +- Use macOS, Windows, or Linux in an interactive desktop session. - Install a Cua Driver nightly that includes Computer History. -- Grant Cua Driver its usual macOS permissions. +- Grant Cua Driver the usual permissions required for the actions you want it + to perform. Computer History does not add permission to perform an action. +- On Linux, run a desktop Secret Service implementation such as GNOME + Keyring. The preview fails closed if no unlocked Secret Service is available. ## Install or switch to the nightly channel -For a fresh installation, run: +For a fresh macOS or Linux installation, run: ```bash /bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)" -- --channel nightly ``` +For a fresh Windows installation, run in PowerShell: + +```powershell +& ([scriptblock]::Create((irm https://cua.ai/driver/install.ps1))) -Channel nightly +``` + To switch an existing installation, run: ```bash @@ -42,11 +51,11 @@ Run: cua-driver history enable ``` -The command admits the experimental feature, initializes its macOS Keychain -key, verifies an encrypted write and read, and then enables capture. If the -installed daemon must restart, Cua Driver preserves its existing permission -mode, capability manifest, approval flags, compatibility mode, and launch -grants. +The command admits the experimental feature, initializes its device-local +native credential, verifies an encrypted write and read, and then enables +capture. If the installed daemon must restart, Cua Driver preserves its +existing permission mode, capability manifest, approval flags, compatibility +mode, and launch grants. Check the result: @@ -118,7 +127,7 @@ These commands preserve existing encrypted history. ## Delete Computer History -Delete the encrypted store and destroy its exact namespace Keychain key: +Delete the encrypted store and destroy its exact namespace native key: ```bash cua-driver history delete --yes @@ -128,10 +137,23 @@ This is cryptographic deletion. Cua Driver does not claim physical erasure from APFS snapshots, backups, copied ciphertext, SSD wear leveling, or memory that a process already decrypted. -To purge history while uninstalling, run the uninstaller as your login user -with `--purge`. Do not prefix the uninstaller with `sudo`: the script elevates -only protected app removal, while Keychain and history cleanup must remain in -the login user's context. +To purge history while uninstalling on macOS or Linux, run: + +```bash +/bin/bash -c "$(curl -fsSL https://cua.ai/driver/uninstall.sh)" -- --purge +``` + +On Windows, run: + +```powershell +$env:CUA_DRIVER_RS_UNINSTALL_FORCE = '1' +$env:CUA_DRIVER_RS_UNINSTALL_PURGE = '1' +irm https://cua.ai/driver/uninstall.ps1 | iex +``` + +Run the uninstaller as the interactive login user. Do not prefix the Unix +uninstaller with `sudo`: native credential and history cleanup must remain in +that user's context. ## Return to the stable channel @@ -153,7 +175,7 @@ The encrypted event allowlist contains: - time and monotonic sequence; - opaque session and action identifiers; - a fixed Cua capability; -- an optional application bundle identifier and display name; +- an optional fixed-field platform application identifier and display name; - fixed action outcome, delivery, route, evidence, and escalation categories; - lifecycle, access-audit, and writer-health events. @@ -163,13 +185,25 @@ paths, window titles, URLs, or free-form diagnostics. Files use the Cua History Profile: a CBOR Sequence of COSE_Encrypt0 records with CloudEvents JSON inside each encrypted payload. ChaCha20-Poly1305 authenticates -each record. A macOS Keychain-protected namespace key and per-chunk HKDF keys -protect data at rest. There is no plaintext fallback and history performs no -network I/O. +each record. A namespace root key protected by macOS Keychain, Windows +Credential Manager, or Linux Secret Service and per-chunk HKDF keys protect +data at rest. There is no plaintext fallback and history performs no network +I/O. The filesystem can still reveal that a history directory exists, its total -size, and file modification times. The Keychain key is device-bound, so copying -only the encrypted files to another Mac does not provide recovery. +size, and file modification times. The native key is bound to the current +user's credential store, so copying only the encrypted files to another +machine does not provide recovery. + +Default encrypted-store locations are: + +- macOS: `~/Library/Application Support/cua-driver/computer-history`; +- Windows: `%LOCALAPPDATA%\cua-driver\computer-history`; +- Linux: `$XDG_STATE_HOME/cua-driver/computer-history`, or + `~/.local/state/cua-driver/computer-history` when `XDG_STATE_HOME` is unset. + +Local-development installs use `cua-driver-local` instead of `cua-driver` in +both filesystem and native-key namespaces. ## Troubleshooting @@ -182,8 +216,10 @@ relaunch automatically. **`history_key_locked` or `history_key_unavailable`** -Unlock the macOS login session and Keychain, then retry. Capture stays disabled -and Cua Driver does not create plaintext files. +Unlock the login session and its native credential store (Keychain, Credential +Manager, or Secret Service), then retry. On Linux, also confirm that a Secret +Service implementation is running. Capture stays disabled and Cua Driver does +not create plaintext files. **`history_quota_reached`** diff --git a/libs/cua-driver/rust/Cargo.lock b/libs/cua-driver/rust/Cargo.lock index b0b2f65f56..9ea9ebe6b1 100644 --- a/libs/cua-driver/rust/Cargo.lock +++ b/libs/cua-driver/rust/Cargo.lock @@ -15,7 +15,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -140,6 +151,17 @@ dependencies = [ "libc", ] +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -518,6 +540,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -646,6 +677,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cbindgen" version = "0.29.4" @@ -709,7 +749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", "zeroize", @@ -723,7 +763,7 @@ checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.5.2", "poly1305", "zeroize", ] @@ -778,6 +818,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -786,7 +836,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.2", ] [[package]] @@ -2152,6 +2202,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inout" version = "0.2.2" @@ -2246,6 +2306,27 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "keyring" +version = "4.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3141,7 +3222,9 @@ dependencies = [ "dirs", "enumflags2", "evdev", + "getrandom 0.4.2", "image", + "keyring", "libc", "libspa", "memmap2", @@ -3157,6 +3240,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3166,6 +3250,7 @@ dependencies = [ "x11rb", "xkbcommon", "zbus", + "zeroize", ] [[package]] @@ -3217,8 +3302,10 @@ dependencies = [ "cua-driver-contract", "cua-driver-core", "cursor-overlay", + "getrandom 0.4.2", "image", "indexmap", + "keyring", "pip-preview", "serde", "serde_json", @@ -3226,7 +3313,9 @@ dependencies = [ "tiny-skia", "tokio", "tracing", + "uuid", "windows 0.58.0", + "zeroize", ] [[package]] @@ -3724,6 +3813,25 @@ dependencies = [ "syn", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5174,6 +5282,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -5752,6 +5873,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.16.0" diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs index 18634a5fb0..2054fe80a8 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs @@ -1,4 +1,4 @@ -//! Encrypted, metadata-only Computer History for the macOS early preview. +//! Encrypted, metadata-only Computer History. //! //! History is deliberately separate from trajectory recording. It accepts //! only strongly typed, fixed-field events and performs encryption before any @@ -620,7 +620,7 @@ impl HistoryManager { pub fn status(&self) -> HistoryStatus { HistoryStatus { - supported: self.config.platform == "macos", + supported: matches!(self.config.platform.as_str(), "macos" | "windows" | "linux"), admitted: self.config.admitted, enabled: self.enabled.load(Ordering::Acquire), paused: self.paused.load(Ordering::Acquire), diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs index 68e82c727e..dce599e2da 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs @@ -44,12 +44,12 @@ impl McpDriver { /// Returns `None` (with a skip message) if the binary isn't built — the /// caller's test should early-return so an un-built binary skips, not fails. pub fn spawn() -> Option { - Self::spawn_internal(&[], &[], None, false) + Self::spawn_internal(&[], &[], None, false, true) } /// Spawn the driver with a stable recording label for artifact naming. pub fn spawn_named(recording_label: &str) -> Option { - Self::spawn_internal(&[], &[], Some(recording_label), false) + Self::spawn_internal(&[], &[], Some(recording_label), false, true) } /// Spawn a named driver with the native cursor overlay enabled. @@ -58,17 +58,48 @@ impl McpDriver { /// cannot paint over their own pixel oracles. Cursor-specific E2E tests /// opt in through this constructor. pub fn spawn_named_with_overlay(recording_label: &str) -> Option { - Self::spawn_internal(&[], &[], Some(recording_label), true) + Self::spawn_internal(&[], &[], Some(recording_label), true, true) } /// Spawn a named driver with environment variables scoped to this child. pub fn spawn_named_with_env(recording_label: &str, env: &[(&str, &str)]) -> Option { - Self::spawn_internal(env, &[], Some(recording_label), false) + Self::spawn_internal(env, &[], Some(recording_label), false, true) + } + + /// Spawn a transport proxy through an already-running installed daemon. + /// + /// Computer History admission is tied to the installed product path and + /// the daemon authenticates the local CLI peer. Cross-platform lifecycle + /// tests therefore use this constructor instead of the testkit's ephemeral + /// source-tree daemon. + pub fn spawn_daemon_proxy_named(socket: &str, recording_label: &str) -> Option { + if !daemon_socket_is_reachable(socket) { + eprintln!("[testkit] CuaDriver daemon not listening at {socket} — skipping"); + return None; + } + Self::spawn_internal( + &[], + &["mcp", "--socket", socket], + Some(recording_label), + false, + true, + ) + } + + /// Spawn an installed-daemon proxy without allocating behavior artifacts. + /// Used for post-restart continuity checks after the visible trajectory has + /// already been captured by the same test case. + pub fn spawn_daemon_proxy_unrecorded(socket: &str) -> Option { + if !daemon_socket_is_reachable(socket) { + eprintln!("[testkit] CuaDriver daemon not listening at {socket} — skipping"); + return None; + } + Self::spawn_internal(&[], &["mcp", "--socket", socket], None, false, false) } /// Spawn the driver with extra environment variables set on the child. pub fn spawn_with_env(env: &[(&str, &str)]) -> Option { - Self::spawn_internal(env, &[], None, false) + Self::spawn_internal(env, &[], None, false, true) } fn spawn_internal( @@ -76,6 +107,7 @@ impl McpDriver { args: &[&str], recording_label: Option<&str>, overlay_enabled: bool, + prepare_recording: bool, ) -> Option { let mut daemon_env = env.to_vec(); let e2e_unrestricted = std::env::var_os("CUA_E2E_UNRESTRICTED_GUI").is_some(); @@ -167,7 +199,9 @@ impl McpDriver { recording_started_at: None, }; d.initialize(); - d.prepare_e2e_recording(recording_label); + if prepare_recording { + d.prepare_e2e_recording(recording_label); + } Some(d) } @@ -194,14 +228,20 @@ impl McpDriver { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); let socket = std::env::var("CUA_E2E_MACOS_DAEMON_SOCKET") .unwrap_or_else(|_| format!("{home}/Library/Caches/cua-driver/cua-driver.sock")); - if std::os::unix::net::UnixStream::connect(&socket).is_err() { + if !daemon_socket_is_reachable(&socket) { eprintln!( "[testkit] CuaDriver daemon not listening at {socket} — \ run `./scripts/install-local.sh` and `open -n -g -a CuaDriver --args serve`; skipping" ); return None; } - Self::spawn_internal(&[], &["mcp", "--socket", &socket], recording_label, false) + Self::spawn_internal( + &[], + &["mcp", "--socket", &socket], + recording_label, + false, + true, + ) } fn initialize(&mut self) { @@ -396,6 +436,23 @@ impl McpDriver { } } +#[cfg(unix)] +fn daemon_socket_is_reachable(socket: &str) -> bool { + std::os::unix::net::UnixStream::connect(socket).is_ok() +} + +#[cfg(windows)] +fn daemon_socket_is_reachable(socket: &str) -> bool { + use std::os::windows::fs::OpenOptionsExt; + + std::fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(0x0000_0001 | 0x0000_0002) + .open(socket) + .is_ok() +} + impl Drop for McpDriver { fn drop(&mut self) { self.stop_e2e_recording(); diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index 74e38926d3..282a1bc92d 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -72,7 +72,7 @@ pub enum Command { claude_code_compat: bool, /// Repeatable trusted launch grants. grants: Vec, - /// Admit the encrypted macOS Computer History early preview for this + /// Admit the encrypted local Computer History early preview for this /// daemon generation. Capture still requires separate persisted opt-in. experimental_history: bool, }, @@ -634,7 +634,7 @@ pub fn parse_command() -> Command { println!(); println!("experimental options (default: off):"); println!( - " --experimental-history Admit encrypted local Computer History for this daemon (macOS only)." + " --experimental-history Admit encrypted local Computer History for this daemon." ); println!( " Capture remains off until `cua-driver history enable`." @@ -1156,28 +1156,24 @@ pub fn run_describe(tools_list: &serde_json::Value, name: &str) { /// Mirror of Swift `MCPCommand.launchDaemonViaOpen` + /// `waitForDaemon`. Split into one Rust function because we don't /// need the post-launch probe separation Swift has. -#[cfg(target_os = "macos")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchDaemonErrorKind { Failed, Timeout, } -#[cfg(target_os = "macos")] #[derive(Debug)] pub struct LaunchDaemonError { pub kind: LaunchDaemonErrorKind, message: String, } -#[cfg(target_os = "macos")] impl std::fmt::Display for LaunchDaemonError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.message) } } -#[cfg(target_os = "macos")] impl std::error::Error for LaunchDaemonError {} #[cfg(target_os = "macos")] @@ -1193,7 +1189,13 @@ pub fn launch_daemon_and_wait( grants: grants.to_vec(), ..Default::default() }; - launch_daemon_with_state_and_wait(socket_path, timeout_secs, &state, experimental_history) + launch_daemon_with_state_and_wait( + socket_path, + timeout_secs, + &state, + experimental_history, + true, + ) } #[cfg(target_os = "macos")] @@ -1202,6 +1204,7 @@ fn launch_daemon_with_state_and_wait( timeout_secs: u64, state: &crate::history_runtime::DaemonLaunchState, experimental_history: bool, + _allow_managed_restart: bool, ) -> Result<(), LaunchDaemonError> { use std::process::{Command as Cmd, Stdio}; use std::time::{Duration, Instant}; @@ -1281,6 +1284,142 @@ fn launch_daemon_with_state_and_wait( }) } +#[cfg(not(target_os = "macos"))] +pub fn launch_daemon_and_wait( + socket_path: &str, + timeout_secs: u64, + claude_code_compat: bool, + grants: &[String], + experimental_history: bool, +) -> Result<(), LaunchDaemonError> { + let state = crate::history_runtime::DaemonLaunchState { + claude_code_compat, + grants: grants.to_vec(), + ..Default::default() + }; + launch_daemon_with_state_and_wait( + socket_path, + timeout_secs, + &state, + experimental_history, + true, + ) +} + +#[cfg(not(target_os = "macos"))] +fn launch_daemon_with_state_and_wait( + socket_path: &str, + timeout_secs: u64, + state: &crate::history_runtime::DaemonLaunchState, + experimental_history: bool, + allow_managed_restart: bool, +) -> Result<(), LaunchDaemonError> { + use std::process::{Command as Cmd, Stdio}; + use std::time::{Duration, Instant}; + + let executable = std::env::current_exe().map_err(|error| LaunchDaemonError { + kind: LaunchDaemonErrorKind::Failed, + message: format!("current Cua Driver executable is unavailable: {error}"), + })?; + let managed = allow_managed_restart + && socket_path == crate::serve::default_socket_path() + && restart_managed_daemon_if_present(&executable); + if !managed { + let mut command = Cmd::new(&executable); + command + .args(daemon_process_arguments( + socket_path, + state, + experimental_history, + )) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(0x0000_0200 | 0x0000_0008); + } + command.spawn().map_err(|error| LaunchDaemonError { + kind: LaunchDaemonErrorKind::Failed, + message: format!("failed to launch {} serve: {error}", executable.display()), + })?; + } + + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + while Instant::now() < deadline { + if crate::serve::is_daemon_listening(socket_path) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(LaunchDaemonError { + kind: LaunchDaemonErrorKind::Timeout, + message: format!("daemon did not appear on {socket_path} within {timeout_secs}s"), + }) +} + +#[cfg(not(target_os = "macos"))] +fn daemon_process_arguments( + socket_path: &str, + state: &crate::history_runtime::DaemonLaunchState, + experimental_history: bool, +) -> Vec { + let mut args = vec!["serve".to_owned()]; + if socket_path != crate::serve::default_socket_path() { + args.extend(["--socket".to_owned(), socket_path.to_owned()]); + } + append_daemon_launch_state(&mut args, state, experimental_history); + args +} + +#[cfg(target_os = "windows")] +fn restart_managed_daemon_if_present(executable: &std::path::Path) -> bool { + use std::process::{Command as Cmd, Stdio}; + let task = crate::bundle::autostart_task_name(); + let exists = Cmd::new("schtasks.exe") + .args(["/Query", "/TN", task]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + exists + && Cmd::new(executable) + .args(["autostart", "kick"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(target_os = "linux")] +fn restart_managed_daemon_if_present(_executable: &std::path::Path) -> bool { + use std::process::{Command as Cmd, Stdio}; + let unit = if crate::bundle::is_local_installation() { + "cua-driver-local.service" + } else { + "cua-driver.service" + }; + let exists = Cmd::new("systemctl") + .args(["--user", "is-enabled", unit]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + exists + && Cmd::new("systemctl") + .args(["--user", "restart", unit]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + #[cfg(target_os = "macos")] fn daemon_launch_arguments( app_name: &str, @@ -1299,6 +1438,15 @@ fn daemon_launch_arguments( if socket_path != crate::serve::default_socket_path() { args.extend(["--socket".to_owned(), socket_path.to_owned()]); } + append_daemon_launch_state(&mut args, state, experimental_history); + args +} + +fn append_daemon_launch_state( + args: &mut Vec, + state: &crate::history_runtime::DaemonLaunchState, + experimental_history: bool, +) { if let Some(mode) = &state.permission_mode { args.extend(["--permission-mode".to_owned(), mode.clone()]); } @@ -1323,7 +1471,6 @@ fn daemon_launch_arguments( for grant in &state.grants { args.extend(["--grant".to_owned(), grant.clone()]); } - args } /// Run the MCP proxy path: ensure a daemon is up (spawning via @@ -1428,23 +1575,35 @@ where daemon = McpDaemonStartup::Launched; } #[cfg(not(target_os = "macos"))] - let _ = claude_code_compat; - // On Linux / Windows there's no equivalent `open -a CuaDriver` - // mechanism to spawn a daemon attributed to the user's - // interactive session. The caller is expected to have one running - // already (e.g. via `cua-driver autostart enable && kick` on Windows). - #[cfg(not(target_os = "macos"))] { - if let Some(on_startup) = on_startup.take() { - on_startup(McpDaemonStartup::UnsupportedRelaunch, false); + if !crate::history_runtime::preview_admitted_preference() { + if let Some(on_startup) = on_startup.take() { + on_startup(McpDaemonStartup::UnsupportedRelaunch, false); + } + anyhow::bail!( + "no Cua Driver daemon listening on {socket_path}. Start one in \ + your interactive session — on Windows run \ + `cua-driver autostart enable && cua-driver autostart kick`; \ + on Linux run `cua-driver serve &` in the user's session. \ + Then re-run `cua-driver mcp`." + ); } - anyhow::bail!( - "no Cua Driver daemon listening on {socket_path}. Start one in \ - your interactive session — on Windows run \ - `cua-driver autostart enable && cua-driver autostart kick`; \ - on Linux run `cua-driver serve &` in the user's session. \ - Then re-run `cua-driver mcp`." - ); + if let Err(error) = + launch_daemon_and_wait(&socket_path, 10, claude_code_compat, grants, true) + { + if let Some(on_startup) = on_startup.take() { + on_startup( + if error.kind == LaunchDaemonErrorKind::Timeout { + McpDaemonStartup::LaunchTimeout + } else { + McpDaemonStartup::LaunchFailed + }, + false, + ); + } + return Err(error.into()); + } + daemon = McpDaemonStartup::Launched; } } @@ -1532,7 +1691,7 @@ pub fn build_manifest() -> serde_json::Value { { "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Forwarded by the MCP proxy when the client asked for the compat surface." }, { "name": "--embedded", "type": "flag", "description": "Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1." }, { "name": "--host-bundle-id", "type": "string", "description": "Advisory host bundle id label echoed in check_permissions output." } - ,{ "name": "--experimental-history", "type": "flag", "description": "Admit the encrypted macOS Computer History early preview for this daemon launch." } + ,{ "name": "--experimental-history", "type": "flag", "description": "Admit the encrypted local Computer History early preview for this daemon launch." } ] }, { "name": "stop", "description": "Stop a running daemon by sending it a shutdown request.", @@ -1586,7 +1745,7 @@ pub fn build_manifest() -> serde_json::Value { { "name": "subcommand", "type": "positional-string", "description": "enable | disable | pause | resume | status | flush | list [limit] | show | delete --yes" }, { "name": "--socket", "type": "string", "description": "Override the daemon socket path." }, { "name": "--json", "type": "flag", "description": "Emit machine-readable output." }, - { "name": "--yes", "type": "flag", "description": "Confirm irreversible deletion of encrypted chunks and their Keychain key." } + { "name": "--yes", "type": "flag", "description": "Confirm irreversible deletion of encrypted chunks and their native credential-store key." } ] }, { "name": "dump-docs", "description": "Dump every registered tool's docs as one document (markdown by default, JSON with --type json).", @@ -2142,30 +2301,9 @@ pub fn run_history_cmd( process::exit(64); } if subcommand == "delete" && !confirmed { - eprintln!("history delete destroys the encrypted files and their Keychain key. Re-run with --yes."); + eprintln!("history delete destroys the encrypted files and their native credential-store key. Re-run with --yes."); process::exit(64); } - #[cfg(not(target_os = "macos"))] - { - if subcommand == "status" { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "supported": false, - "admitted": false, - "enabled": false, - "paused": false, - "encrypted": true, - "health": "not_admitted" - })) - .unwrap() - ); - return; - } - eprintln!("Computer History early preview is available on macOS only."); - process::exit(1); - } - let socket_path = socket .map(str::to_owned) .unwrap_or_else(crate::serve::default_socket_path); @@ -2175,6 +2313,7 @@ pub fn run_history_cmd( .and_then(|value| value.get("admitted").and_then(serde_json::Value::as_bool)) == Some(true); if !admitted { + #[cfg(target_os = "macos")] if crate::bundle::is_local_installation() { eprintln!( "This local-development daemon is not admitted for Computer History. Restart it with:\n {} serve --experimental-history\nThen run `{} history enable` again.", @@ -2183,9 +2322,8 @@ pub fn run_history_cmd( ); process::exit(1); } - #[cfg(target_os = "macos")] - if let Err(error) = crate::history_runtime::verify_installed_app_for_history() { - eprintln!("history enable: installed app verification failed: {error}"); + if let Err(error) = crate::history_runtime::verify_installed_product_for_history() { + eprintln!("history enable: installed product verification failed: {error}"); process::exit(1); } if crate::serve::is_daemon_listening(&socket_path) { @@ -2205,7 +2343,6 @@ pub fn run_history_cmd( process::exit(1); } enabled_preview_for_this_command = true; - #[cfg(target_os = "macos")] if crate::serve::is_daemon_listening(&socket_path) { if let Err(error) = stop_history_daemon(&socket_path) { let _ = crate::history_runtime::set_preview_admitted_preference(false); @@ -2215,10 +2352,13 @@ pub fn run_history_cmd( process::exit(1); } } - #[cfg(target_os = "macos")] - if let Err(error) = - launch_daemon_with_state_and_wait(&socket_path, 15, &prior_daemon_state, true) - { + if let Err(error) = launch_daemon_with_state_and_wait( + &socket_path, + 15, + &prior_daemon_state, + true, + false, + ) { rollback_history_preview( &socket_path, prior_daemon_was_running, @@ -2228,11 +2368,6 @@ pub fn run_history_cmd( eprintln!("history enable: could not relaunch the installed daemon with preview admission: {error}"); process::exit(1); } - #[cfg(not(target_os = "macos"))] - { - eprintln!("Computer History early preview is available on macOS only."); - process::exit(1); - } } } @@ -2249,7 +2384,6 @@ pub fn run_history_cmd( process::exit(1); } if let Err(error) = ensure_compatible_daemon(&socket_path) { - #[cfg(target_os = "macos")] if enabled_preview_for_this_command { rollback_history_preview( &socket_path, @@ -2295,7 +2429,10 @@ pub fn run_history_cmd( println!("Computer History preview enabled."); println!("Stored fields: time, opaque session/action ids, fixed capability, app name/bundle id, and fixed action outcome metadata."); println!("Never stored: screenshots, typed text, clipboard contents, raw arguments/results, accessibility trees, paths, titles, URLs, or free-form diagnostics."); - println!("Encryption: CBOR Sequence + COSE_Encrypt0 (ChaCha20-Poly1305), with the key protected by macOS Keychain."); + println!( + "Encryption: CBOR Sequence + COSE_Encrypt0 (ChaCha20-Poly1305), with the key protected by {}.", + crate::history_runtime::platform_key_store_name() + ); println!( "Retention: {} days. Quota: {} MiB.", value @@ -2317,7 +2454,6 @@ pub fn run_history_cmd( } } Ok(response) => { - #[cfg(target_os = "macos")] if enabled_preview_for_this_command { rollback_history_preview( &socket_path, @@ -2335,7 +2471,6 @@ pub fn run_history_cmd( process::exit(response.exit_code.unwrap_or(1)); } Err(error) => { - #[cfg(target_os = "macos")] if enabled_preview_for_this_command { rollback_history_preview( &socket_path, @@ -2350,7 +2485,6 @@ pub fn run_history_cmd( } } -#[cfg(target_os = "macos")] fn rollback_history_preview( socket_path: &str, prior_daemon_was_running: bool, @@ -2361,11 +2495,11 @@ fn rollback_history_preview( crate::history_runtime::set_preview_admitted_preference(prior_preview_admitted_preference); let _ = stop_history_daemon(socket_path); if prior_daemon_was_running && !crate::serve::is_daemon_listening(socket_path) { - let _ = launch_daemon_with_state_and_wait(socket_path, 15, prior_daemon_state, false); + let _ = + launch_daemon_with_state_and_wait(socket_path, 15, prior_daemon_state, false, false); } } -#[cfg(target_os = "macos")] fn stop_history_daemon(socket_path: &str) -> Result<(), String> { if !crate::serve::is_daemon_listening(socket_path) { return Ok(()); @@ -4395,7 +4529,6 @@ mod tests { values.iter().map(|value| (*value).to_owned()).collect() } - #[cfg(target_os = "macos")] #[test] fn history_relaunch_preserves_authorization_mode_and_every_grant() { let grants = args(&["capability:a", "capability:b"]); @@ -4408,7 +4541,10 @@ mod tests { claude_code_compat: true, grants, }; + #[cfg(target_os = "macos")] let launch = daemon_launch_arguments("CuaDriver", "/tmp/history-test.sock", &state, true); + #[cfg(not(target_os = "macos"))] + let launch = daemon_process_arguments("history-test.sock", &state, true); assert!(launch .windows(2) .any(|pair| pair == ["--permission-mode", "bounded"])); @@ -4425,12 +4561,12 @@ mod tests { assert!(launch .windows(2) .any(|pair| pair == ["--grant", "capability:b"])); + #[cfg(target_os = "macos")] assert!(launch .windows(2) .any(|pair| pair == ["--socket", "/tmp/history-test.sock"])); } - #[cfg(target_os = "macos")] #[test] fn history_relaunch_preserves_explicit_unrestricted_approval() { let state = crate::history_runtime::DaemonLaunchState { @@ -4438,7 +4574,8 @@ mod tests { dangerously_bypass_approvals: true, ..Default::default() }; - let launch = daemon_launch_arguments("CuaDriver", "/tmp/history-test.sock", &state, true); + let mut launch = vec!["serve".to_owned()]; + append_daemon_launch_state(&mut launch, &state, true); assert!(launch .windows(2) .any(|pair| { pair == ["--permission-mode", "unrestricted"] })); diff --git a/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs b/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs index a5e6b46dd3..d18c9cf3a0 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs @@ -1,4 +1,4 @@ -//! Trusted standalone-daemon assembly for the macOS Computer History preview. +//! Trusted standalone-daemon assembly for the Computer History preview. use std::{ fs, @@ -10,14 +10,15 @@ use std::{ }, }; -#[cfg(target_os = "macos")] use cua_driver_core::history::{ - HistoryConfig, HistoryManager, DEFAULT_QUOTA_BYTES, DEFAULT_RETENTION_DAYS, + ApplicationIdentityProvider, HistoryConfig, HistoryManager, KeyProvider, DEFAULT_QUOTA_BYTES, + DEFAULT_RETENTION_DAYS, }; use cua_driver_core::tool::ToolRegistry; static HISTORY_ADMITTED: AtomicBool = AtomicBool::new(false); static DAEMON_LAUNCH_STATE: OnceLock> = OnceLock::new(); +#[cfg(target_os = "macos")] const RELEASE_TEAM_IDENTIFIER: &str = "YCK386LBJ7"; #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] @@ -32,9 +33,8 @@ pub struct DaemonLaunchState { } pub fn configure_admission(admitted: bool) -> anyhow::Result<()> { - #[cfg(target_os = "macos")] if admitted { - verify_installed_app_for_history()?; + verify_installed_product_for_history()?; } HISTORY_ADMITTED.store(admitted, Ordering::Release); Ok(()) @@ -133,30 +133,27 @@ fn set_preview_admitted_preference_at(path: &Path, admitted: bool) -> anyhow::Re Ok(()) } -#[cfg(target_os = "macos")] pub fn run_offline_purge_if_requested() -> Option { let args: Vec = std::env::args().skip(1).collect(); if args.as_slice() != ["history", "purge-offline", "--yes"] { return None; } - let expected = - PathBuf::from(crate::bundle::app_bundle_path()).join("Contents/MacOS/cua-driver"); let current = std::env::current_exe().ok(); if current .as_deref() - .is_none_or(|current| !is_exact_packaged_helper(current, &expected)) - || verify_installed_app_for_history().is_err() + .is_none_or(|current| verify_history_cli_executable_path(current).is_err()) + || verify_installed_product_for_history().is_err() { eprintln!( - "history_purge_incomplete: purge requires the exact verified installed CuaDriver.app helper" + "history_purge_incomplete: purge requires the exact verified installed Cua Driver helper" ); return Some(1); } - let provider = platform_macos::history::MacosKeychainKeyProvider::default(); + let provider = platform_key_provider(); match cua_driver_core::history::purge_offline( &history_root(), crate::bundle::state_namespace(), - &provider, + provider.as_ref(), ) { Ok(result) => { println!( @@ -172,20 +169,43 @@ pub fn run_offline_purge_if_requested() -> Option { } } +#[cfg(target_os = "macos")] fn is_exact_packaged_helper(current: &Path, expected: &Path) -> bool { fs::canonicalize(current).ok() == fs::canonicalize(expected).ok() && fs::canonicalize(expected).is_ok() } -#[cfg(target_os = "macos")] pub(crate) fn verify_history_cli_executable_path(path: &Path) -> anyhow::Result<()> { - let expected = PathBuf::from(crate::bundle::app_bundle_path()) - .join("Contents/MacOS") - .join(crate::bundle::cli_name()); - if !is_exact_packaged_helper(path, &expected) { - anyhow::bail!("history control peer is not the exact installed Cua Driver helper"); + #[cfg(target_os = "macos")] + { + let expected = PathBuf::from(crate::bundle::app_bundle_path()) + .join("Contents/MacOS") + .join(crate::bundle::cli_name()); + if !is_exact_packaged_helper(path, &expected) { + anyhow::bail!("history control peer is not the exact installed Cua Driver helper"); + } + return verify_installed_app_for_history(); + } + #[cfg(not(target_os = "macos"))] + { + let current = std::env::current_exe() + .map_err(|error| anyhow::anyhow!("current executable is unavailable: {error}"))?; + if !same_canonical_file(path, ¤t) { + anyhow::bail!("history control peer is not the exact installed Cua Driver helper"); + } + verify_installed_non_macos_binary_for_history() + } +} + +pub fn verify_installed_product_for_history() -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + verify_installed_app_for_history() + } + #[cfg(not(target_os = "macos"))] + { + verify_installed_non_macos_binary_for_history() } - verify_installed_app_for_history() } #[cfg(target_os = "macos")] @@ -268,6 +288,7 @@ pub fn verify_installed_app_for_history() -> anyhow::Result<()> { ) } +#[cfg(target_os = "macos")] fn validate_history_app_signature( detail: &str, requirement: &str, @@ -316,11 +337,62 @@ fn validate_history_app_signature( Ok(()) } +#[cfg(not(target_os = "macos"))] +fn verify_installed_non_macos_binary_for_history() -> anyhow::Result<()> { + let current = std::env::current_exe().map_err(|error| { + anyhow::anyhow!("current Cua Driver executable is unavailable: {error}") + })?; + let expected = installed_package_helper_path()?; + if !same_canonical_file(¤t, &expected) { + anyhow::bail!( + "history admission requires the exact executable under the installed package's current pointer" + ); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn installed_package_helper_path() -> anyhow::Result { + #[cfg(target_os = "windows")] + let home = std::env::var_os("USERPROFILE"); + #[cfg(not(target_os = "windows"))] + let home = std::env::var_os("HOME"); + let user_home = home + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("user home is unavailable"))?; + let local = crate::bundle::is_local_installation(); + let product_home = if local { + std::env::var_os("CUA_DRIVER_LOCAL_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| user_home.join(".cua-driver-local")) + } else { + std::env::var_os("CUA_DRIVER_HOME") + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var_os("CUA_DRIVER_RS_HOME").filter(|value| !value.is_empty())) + .map(PathBuf::from) + .unwrap_or_else(|| user_home.join(".cua-driver")) + }; + let executable = if cfg!(target_os = "windows") { + format!("{}.exe", crate::bundle::cli_name()) + } else { + crate::bundle::cli_name().to_owned() + }; + Ok(product_home + .join("packages") + .join("current") + .join(executable)) +} + +#[cfg(not(target_os = "macos"))] +fn same_canonical_file(left: &Path, right: &Path) -> bool { + fs::canonicalize(left).ok() == fs::canonicalize(right).ok() && fs::canonicalize(right).is_ok() +} + fn admission_preference_path() -> PathBuf { history_root().join("admission.json") } -#[cfg(target_os = "macos")] pub fn register_into(registry: &mut ToolRegistry) { if !HISTORY_ADMITTED.load(Ordering::Acquire) { return; @@ -330,32 +402,95 @@ pub fn register_into(registry: &mut ToolRegistry) { root: history_root(), namespace: crate::bundle::state_namespace().to_owned(), admitted: true, - platform: "macos".to_owned(), + platform: platform_name().to_owned(), retention_days: DEFAULT_RETENTION_DAYS, quota_bytes: DEFAULT_QUOTA_BYTES, }, - platform_macos::history::MacosKeychainKeyProvider::shared(), - Some(platform_macos::history::application_identity_provider()), + platform_key_provider(), + Some(platform_application_identity_provider()), ); registry.register_history_tools(manager); } -#[cfg(not(target_os = "macos"))] -pub fn register_into(_registry: &mut ToolRegistry) {} - pub fn register_host_tools(registry: &mut ToolRegistry) { crate::check_update_tool::register_into(registry); register_into(registry); } pub fn history_root() -> PathBuf { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/tmp")); - home.join("Library") - .join("Application Support") - .join(crate::bundle::state_namespace()) - .join("computer-history") + #[cfg(target_os = "macos")] + { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + return home + .join("Library") + .join("Application Support") + .join(crate::bundle::state_namespace()) + .join("computer-history"); + } + #[cfg(target_os = "linux")] + { + let root = std::env::var_os("XDG_STATE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".local/state")) + }) + .unwrap_or_else(|| PathBuf::from("/tmp")); + return root + .join(crate::bundle::state_namespace()) + .join("computer-history"); + } + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("C:/Temp")); + return root + .join(crate::bundle::state_namespace()) + .join("computer-history"); + } +} + +fn platform_name() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } +} + +pub fn platform_key_store_name() -> &'static str { + if cfg!(target_os = "macos") { + "macOS Keychain" + } else if cfg!(target_os = "windows") { + "Windows Credential Manager" + } else { + "the Linux Secret Service" + } +} + +fn platform_key_provider() -> std::sync::Arc { + #[cfg(target_os = "macos")] + return platform_macos::history::MacosKeychainKeyProvider::shared(); + #[cfg(target_os = "windows")] + return platform_windows::history::WindowsCredentialKeyProvider::shared(); + #[cfg(target_os = "linux")] + return platform_linux::history::LinuxSecretServiceKeyProvider::shared(); +} + +fn platform_application_identity_provider() -> std::sync::Arc { + #[cfg(target_os = "macos")] + return platform_macos::history::application_identity_provider(); + #[cfg(target_os = "windows")] + return platform_windows::history::application_identity_provider(); + #[cfg(target_os = "linux")] + return platform_linux::history::application_identity_provider(); } #[cfg(test)] @@ -404,6 +539,7 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] fn offline_purge_identity_guard_requires_the_exact_packaged_helper() { let temp = tempfile::tempdir().unwrap(); let expected = temp.path().join("CuaDriver.app/Contents/MacOS/cua-driver"); @@ -417,6 +553,7 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] fn local_history_accepts_exact_certificate_identity_without_release_entitlements() { validate_history_app_signature( "Identifier=com.trycua.driver.local\nTeamIdentifier=TEAM123", @@ -430,6 +567,7 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] fn history_admission_rejects_adhoc_or_wrong_bundle_identity() { let adhoc = validate_history_app_signature( "Identifier=com.trycua.driver.local\nTeamIdentifier=TEAM123", @@ -452,6 +590,7 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] fn release_history_still_requires_device_protected_keychain_entitlements() { let detail = format!("Identifier=com.trycua.driver\nTeamIdentifier={RELEASE_TEAM_IDENTIFIER}"); diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index ec12db4a00..82e8b1cbbe 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -368,6 +368,7 @@ fn mcp_uses_direct_runtime(socket: Option<&str>, direct: bool) -> anyhow::Result socket, cfg!(target_os = "macos"), direct, + history_runtime::preview_admitted_preference(), ) } @@ -376,6 +377,7 @@ fn mcp_uses_direct_runtime_for( socket: Option<&str>, macos: bool, direct: bool, + history_preview_admitted: bool, ) -> anyhow::Result { if direct && socket.is_some() { anyhow::bail!("--direct and --socket are mutually exclusive"); @@ -390,7 +392,7 @@ fn mcp_uses_direct_runtime_for( // Preserve LaunchServices/TCC attribution for normal macOS clients. Ok(false) } else { - Ok(socket.is_none()) + Ok(socket.is_none() && !history_preview_admitted) } } @@ -400,30 +402,32 @@ mod mcp_runtime_selection_tests { #[test] fn embedded_host_without_private_endpoint_fails_closed() { - let error = mcp_uses_direct_runtime_for(true, None, false, false).unwrap_err(); + let error = mcp_uses_direct_runtime_for(true, None, false, false, false).unwrap_err(); assert!(error.to_string().contains("--socket")); - let error = mcp_uses_direct_runtime_for(true, None, true, false).unwrap_err(); + let error = mcp_uses_direct_runtime_for(true, None, true, false, false).unwrap_err(); assert!(error.to_string().contains("--socket")); } #[test] fn normal_linux_and_windows_stdio_own_the_runtime() { - assert!(mcp_uses_direct_runtime_for(false, None, false, false).unwrap()); - assert!(!mcp_uses_direct_runtime_for(false, Some("service"), false, false).unwrap()); + assert!(mcp_uses_direct_runtime_for(false, None, false, false, false).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, Some("service"), false, false, false).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, None, false, false, true).unwrap()); } #[test] fn normal_macos_stdio_preserves_the_service_boundary() { - assert!(!mcp_uses_direct_runtime_for(false, None, true, false).unwrap()); - assert!(!mcp_uses_direct_runtime_for(false, Some("service"), true, false).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, None, true, false, false).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, Some("service"), true, false, false).unwrap()); } #[test] fn explicit_direct_owns_the_runtime_on_macos_and_in_embedded_hosts() { - assert!(mcp_uses_direct_runtime_for(false, None, true, true).unwrap()); - assert!(mcp_uses_direct_runtime_for(true, None, true, true).unwrap()); - assert!(mcp_uses_direct_runtime_for(true, None, false, true).unwrap()); - let error = mcp_uses_direct_runtime_for(false, Some("service"), true, true).unwrap_err(); + assert!(mcp_uses_direct_runtime_for(false, None, true, true, false).unwrap()); + assert!(mcp_uses_direct_runtime_for(true, None, true, true, false).unwrap()); + assert!(mcp_uses_direct_runtime_for(true, None, false, true, false).unwrap()); + let error = + mcp_uses_direct_runtime_for(false, Some("service"), true, true, false).unwrap_err(); assert!(error.to_string().contains("mutually exclusive")); } } @@ -846,6 +850,9 @@ fn main() { #[cfg(not(target_os = "macos"))] fn main() -> anyhow::Result<()> { + if let Some(code) = history_runtime::run_offline_purge_if_requested() { + std::process::exit(code); + } init_logging(); if let Some(generation) = private_worker::requested_generation() { return private_worker::run(generation, None); @@ -912,7 +919,7 @@ fn main() -> anyhow::Result<()> { no_permissions_gate, claude_code_compat, grants, - experimental_history: _, + experimental_history, } => { configure_startup_permission_mode( permission_mode.as_deref(), @@ -922,6 +929,19 @@ fn main() -> anyhow::Result<()> { &grants, )?; responsibility::reexec_disclaimed_if_needed(); + history_runtime::configure_admission( + experimental_history || history_runtime::preview_admitted_preference(), + )?; + history_runtime::configure_daemon_launch_state( + permission_mode.as_deref(), + dangerously_bypass_approvals, + allow_legacy_existing_profile_approval, + capability_manifest.as_deref(), + approve_capability_manifest, + no_permissions_gate, + claude_code_compat, + &grants, + ); telemetry::capture_start( telemetry::event::SERVE_START_LEGACY, telemetry::Transport::Daemon, diff --git a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs index fdb2913c28..d9afefea8f 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs @@ -220,7 +220,11 @@ fn history_control_response( let Some(history) = registry.history() else { return if operation == "status" { DaemonResponse::ok(serde_json::json!({ - "supported": cfg!(target_os = "macos"), + "supported": cfg!(any( + target_os = "macos", + target_os = "windows", + target_os = "linux" + )), "admitted": false, "enabled": false, "paused": false, @@ -691,9 +695,17 @@ fn authenticate_history_cli_connection(stream: &tokio::net::UnixStream) -> anyho crate::history_runtime::verify_history_cli_executable_path(&path) } -#[cfg(all(unix, not(target_os = "macos")))] -fn authenticate_history_cli_connection(_stream: &tokio::net::UnixStream) -> anyhow::Result<()> { - anyhow::bail!("Computer History control is unavailable on this platform") +#[cfg(target_os = "linux")] +fn authenticate_history_cli_connection(stream: &tokio::net::UnixStream) -> anyhow::Result<()> { + let peer_pid = stream + .peer_cred() + .map_err(|error| anyhow::anyhow!("read history control peer credentials: {error}"))? + .pid() + .ok_or_else(|| anyhow::anyhow!("history control peer PID is unavailable"))?; + let path = std::fs::read_link(format!("/proc/{peer_pid}/exe")).map_err(|error| { + anyhow::anyhow!("history control peer executable is unavailable: {error}") + })?; + crate::history_runtime::verify_history_cli_executable_path(&path) } fn service_authorization_status(trusted_host_connection: bool) -> serde_json::Value { @@ -1569,6 +1581,11 @@ pub async fn run_serve( expected_host_process_id, client_process_id, ); + let trusted_history_cli_connection = client_process_id + .and_then(platform_windows::history::process_executable_path) + .is_some_and(|path| { + crate::history_runtime::verify_history_cli_executable_path(&path).is_ok() + }); let reg = sdk.clone(); let shutdown_tx2 = shutdown_tx.clone(); @@ -1606,7 +1623,10 @@ pub async fn run_serve( ).await; } "history_relaunch_state" => { - let resp = history_relaunch_state_response(&req, false); + let resp = history_relaunch_state_response( + &req, + trusted_history_cli_connection, + ); let _ = writer.write_all( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; @@ -1650,7 +1670,11 @@ pub async fn run_serve( ).await; } "history_control" => { - let resp = history_control_response(®, &req, false); + let resp = history_control_response( + ®, + &req, + trusted_history_cli_connection, + ); let _ = writer.write_all( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs new file mode 100644 index 0000000000..1181df9ae7 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -0,0 +1,582 @@ +//! Packaged Windows/Linux lifecycle gate for encrypted Computer History. +//! +//! This test intentionally uses the installed local product, its authenticated +//! daemon transport, and the platform credential store. It records one real +//! window action, restarts the daemon, hydrates the action from ciphertext, and +//! finally proves disable/preserve plus cryptographic deletion. + +#![cfg(any(target_os = "windows", target_os = "linux"))] + +use std::{ + fs, + io::{BufRead, BufReader, Write}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + thread::sleep, + time::{Duration, Instant}, +}; + +use cua_driver_core::history::KeyProvider; +use cua_driver_testkit::e2e::{ + recording_evidence, write_declaration_from_env, write_result_from_env, CaseResult, CaseSpec, + Delivery, DriverRoute, Observation, OracleKind, Scope, Targeting, TestStatus, +}; +use cua_driver_testkit::{harness_app, spawn_in_job, Driver, McpDriver}; +use serde_json::{json, Value}; + +const RAW_SESSION: &str = "centennial-cross-platform-continuity"; + +fn installed_driver() -> PathBuf { + std::env::var_os("CUA_E2E_INSTALLED_DRIVER_BIN") + .map(PathBuf::from) + .expect("CUA_E2E_INSTALLED_DRIVER_BIN must identify the packaged local driver") +} + +fn daemon_socket() -> String { + std::env::var("CUA_E2E_HISTORY_DAEMON_SOCKET") + .expect("CUA_E2E_HISTORY_DAEMON_SOCKET must identify the packaged daemon") +} + +fn history_root() -> PathBuf { + #[cfg(target_os = "windows")] + { + return PathBuf::from(std::env::var_os("LOCALAPPDATA").expect("LOCALAPPDATA must be set")) + .join("cua-driver-local/computer-history"); + } + #[cfg(target_os = "linux")] + { + let state = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var_os("HOME").expect("HOME must be set")) + .join(".local/state") + }); + state.join("cua-driver-local/computer-history") + } +} + +fn history_cli(subcommand: &str, extra: &[&str]) -> Value { + let mut command = Command::new(installed_driver()); + command.args(["history", subcommand]); + command.args(extra); + command.args(["--json", "--socket", &daemon_socket()]); + command.env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false"); + let output = command + .output() + .unwrap_or_else(|error| panic!("could not run history {subcommand}: {error}")); + assert!( + output.status.success(), + "history {subcommand} failed (status {:?}): stdout={} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "history {subcommand} did not emit JSON: {error}; stdout={}", + String::from_utf8_lossy(&output.stdout) + ) + }) +} + +fn assert_daemon_remains_unrestricted() { + let output = Command::new(installed_driver()) + .args(["status", "--socket", &daemon_socket()]) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .output() + .expect("inspect packaged daemon authorization mode"); + assert!( + output.status.success(), + "daemon status failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("permission mode: unrestricted"), + "history enable changed daemon authorization mode: {stdout}" + ); +} + +fn assert_ready(status: &Value) { + assert_eq!(status["supported"], true, "history unsupported: {status}"); + assert_eq!(status["admitted"], true, "history not admitted: {status}"); + assert_eq!(status["enabled"], true, "history not enabled: {status}"); + assert_eq!( + status["paused"], false, + "history unexpectedly paused: {status}" + ); + assert_eq!( + status["encrypted"], true, + "history is not encrypted: {status}" + ); + assert_eq!(status["health"], "ready", "history is unhealthy: {status}"); + assert_eq!( + status["dropped_events"], 0, + "history dropped events: {status}" + ); +} + +fn query(driver: &mut McpDriver, since_sequence: Option) -> Value { + let mut arguments = json!({"limit": 200}); + if let Some(sequence) = since_sequence { + arguments["since_sequence"] = json!(sequence); + } + let response = driver.call("history_query", arguments); + assert!( + !response.is_error(), + "history_query failed: {} / {}", + response.text(), + response.raw + ); + assert_eq!(response.structured()["metadata_only"], true); + assert_eq!(response.structured()["model_context_disclosure"], true); + response.structured().clone() +} + +fn max_sequence(query: &Value) -> u64 { + query["events"] + .as_array() + .into_iter() + .flatten() + .filter_map(|event| event["data"]["sequence"].as_u64()) + .max() + .unwrap_or(0) +} + +fn fixture_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + return harness_app("harness-electron", "CuaTestHarness.Electron.exe"); + } + #[cfg(target_os = "linux")] + { + harness_app("harness-electron", "CuaTestHarness.Electron") + } +} + +fn launch_fixture(driver: &mut McpDriver) -> (u32, u64, Value) { + let path = fixture_path(); + assert!( + path.exists(), + "Electron fixture is missing at {}", + path.display() + ); + let mut command = Command::new(path); + command + .args([ + "--no-sandbox", + "--disable-gpu", + "--force-renderer-accessibility", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let child = spawn_in_job(&mut command).expect("launch Electron fixture"); + let launch_pid = child.id(); + driver.reaper().push(child); + + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let windows = driver.call("list_windows", json!({"pid": launch_pid})); + assert!( + !windows.is_error(), + "list_windows failed: {}", + windows.text() + ); + if let Some(window) = windows.structured()["windows"] + .as_array() + .and_then(|items| { + items.iter().find(|window| { + window["title"] + .as_str() + .unwrap_or("") + .contains("CuaTestHarness Electron") + }) + }) + { + let pid = window["pid"].as_u64().unwrap_or(u64::from(launch_pid)) as u32; + let window_id = window["window_id"].as_u64().expect("fixture window id"); + driver.reaper().track_pid(pid); + return (pid, window_id, window["bounds"].clone()); + } + assert!( + Instant::now() < deadline, + "Electron fixture opened no usable window" + ); + sleep(Duration::from_millis(200)); + } +} + +fn assert_no_private_fields(value: &Value) { + const FORBIDDEN_KEYS: &[&str] = &[ + "screenshot", + "screenshot_png_b64", + "typed_text", + "clipboard", + "raw_arguments", + "raw_results", + "accessibility_tree", + "path", + "title", + "url", + "diagnostic", + ]; + match value { + Value::Object(object) => { + for (key, child) in object { + assert!( + !FORBIDDEN_KEYS.contains(&key.as_str()), + "history exposed forbidden field {key}" + ); + assert_no_private_fields(child); + } + } + Value::Array(items) => items.iter().for_each(assert_no_private_fields), + _ => {} + } +} + +fn ciphertext_paths(root: &Path) -> Vec { + let chunks = root.join("chunks"); + if !chunks.exists() { + return Vec::new(); + } + fs::read_dir(chunks) + .expect("read history chunk directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("cborseq")) + .collect() +} + +#[cfg(unix)] +fn forged_control_response() -> Value { + use std::os::unix::net::UnixStream; + + let mut stream = UnixStream::connect(daemon_socket()) + .expect("connect test process directly to packaged daemon"); + write_forged_control(&mut stream) +} + +#[cfg(windows)] +fn forged_control_response() -> Value { + use std::os::windows::fs::OpenOptionsExt; + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(0x0000_0001 | 0x0000_0002) + .open(daemon_socket()) + { + Ok(mut pipe) => return write_forged_control(&mut pipe), + Err(error) if Instant::now() < deadline => { + let _ = error; + sleep(Duration::from_millis(50)); + } + Err(error) => panic!("connect test process directly to packaged daemon: {error}"), + } + } +} + +fn write_forged_control(stream: &mut (impl std::io::Read + Write)) -> Value { + let forged = json!({ + "method": "history_control", + "args": {"operation": "status"}, + "observation_origin": "direct", + "client_kind": "cli" + }); + writeln!(stream, "{forged}").expect("write forged CLI control request"); + stream.flush().expect("flush forged CLI control request"); + let mut response = String::new(); + BufReader::new(stream) + .read_line(&mut response) + .expect("read forged CLI control response"); + serde_json::from_str(&response).expect("forged CLI response must be JSON") +} + +fn assert_forged_cli_control_is_rejected() { + let response = forged_control_response(); + assert_eq!(response["ok"], false, "forged CLI control was accepted"); + assert_eq!(response["exit_code"], 77); + assert_eq!(response["error"], "history_control_requires_local_cli"); +} + +fn stop_daemon() { + let output = Command::new(installed_driver()) + .args(["stop", "--socket", &daemon_socket()]) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .output() + .expect("stop packaged daemon"); + assert!( + output.status.success(), + "daemon stop failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +struct DaemonChild(Child); + +impl Drop for DaemonChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn start_daemon() -> DaemonChild { + let child = Command::new(installed_driver()) + .args([ + "serve", + "--socket", + &daemon_socket(), + "--permission-mode", + "unrestricted", + "--dangerously-bypass-approvals", + "--experimental-history", + ]) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("restart packaged daemon"); + let child = DaemonChild(child); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if daemon_is_reachable() { + return child; + } + assert!( + Instant::now() < deadline, + "restarted daemon never became reachable" + ); + sleep(Duration::from_millis(100)); + } +} + +#[cfg(unix)] +fn daemon_is_reachable() -> bool { + std::os::unix::net::UnixStream::connect(daemon_socket()).is_ok() +} + +#[cfg(windows)] +fn daemon_is_reachable() -> bool { + use std::os::windows::fs::OpenOptionsExt; + + fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(0x0000_0001 | 0x0000_0002) + .open(daemon_socket()) + .is_ok() +} + +fn key_references() -> Vec { + #[cfg(target_os = "windows")] + { + platform_windows::history::WindowsCredentialKeyProvider + .references("cua-driver-local") + .expect("inspect Windows Credential Manager history key") + } + #[cfg(target_os = "linux")] + { + platform_linux::history::LinuxSecretServiceKeyProvider + .references("cua-driver-local") + .expect("inspect Secret Service history key") + } +} + +#[test] +#[ignore = "requires an installed local product, native credential store, GUI fixture, and daemon"] +fn encrypted_history_survives_restart_and_cryptographically_purges() { + let started_at = Instant::now(); + let case = CaseSpec::delivered( + format!("{}-computer-history-continuity", std::env::consts::OS), + "electron", + "electron", + "computer_history_continuity", + Targeting::Ax, + Delivery::Foreground, + Scope::Window, + DriverRoute::WindowState, + vec![OracleKind::Protocol], + ); + write_declaration_from_env(&case).expect("write history E2E declaration"); + + assert_forged_cli_control_is_rejected(); + assert!( + key_references().is_empty(), + "runner preflight retained the history key" + ); + assert!( + ciphertext_paths(&history_root()).is_empty(), + "runner preflight retained history ciphertext" + ); + + let enabled = history_cli("enable", &[]); + assert_ready(&enabled); + assert_daemon_remains_unrestricted(); + assert_eq!( + key_references().len(), + 1, + "enable did not create one native key" + ); + + let mut driver = McpDriver::spawn_daemon_proxy_named( + &daemon_socket(), + &format!("{}-computer-history-continuity", std::env::consts::OS), + ) + .expect("start installed daemon proxy"); + let evidence = recording_evidence(driver.recording_dir()); + let status = driver.call("history_status", json!({})); + assert!( + !status.is_error(), + "history_status failed: {}", + status.text() + ); + assert_ready(status.structured()); + let baseline = query(&mut driver, None); + let since_sequence = max_sequence(&baseline).saturating_add(1).max(1); + + let started = driver.call("start_session", json!({"session": RAW_SESSION})); + assert!( + !started.is_error(), + "start_session failed: {}", + started.text() + ); + let (pid, window_id, bounds) = launch_fixture(&mut driver); + driver.start_behavior_recording(); + let requested_x = bounds["x"].as_f64().expect("fixture x") + 18.0; + let requested_y = bounds["y"].as_f64().expect("fixture y") + 12.0; + let moved = driver.call( + "set_window_frame", + json!({ + "pid": pid, + "window_id": window_id, + "x": requested_x, + "y": requested_y, + "width": bounds["width"], + "height": bounds["height"], + "session": RAW_SESSION + }), + ); + assert!( + !moved.is_error(), + "set_window_frame failed: {}", + moved.text() + ); + assert_eq!(moved.action_effect(), Some("confirmed")); + let readback = driver.call("list_windows", json!({"pid": pid})); + let observed = readback.structured()["windows"] + .as_array() + .and_then(|windows| { + windows + .iter() + .find(|window| window["window_id"].as_u64() == Some(window_id)) + }) + .expect("moved fixture window disappeared"); + assert!((observed["bounds"]["x"].as_f64().unwrap() - requested_x).abs() <= 2.0); + assert!((observed["bounds"]["y"].as_f64().unwrap() - requested_y).abs() <= 2.0); + let ended = driver.call("end_session", json!({"session": RAW_SESSION})); + assert!(!ended.is_error(), "end_session failed: {}", ended.text()); + assert_ready(&history_cli("flush", &[])); + + let hydrated = query(&mut driver, Some(since_sequence)); + assert_no_private_fields(&hydrated); + let completion = hydrated["events"] + .as_array() + .into_iter() + .flatten() + .find(|event| { + event["data"]["capability"] == "window.frame.set" + && event["data"]["payload"]["kind"] == "action_completed" + && event["data"]["payload"]["effect"] == "confirmed" + }) + .expect("history did not contain the confirmed window-frame action"); + let action_id = completion["data"]["action_id"] + .as_str() + .expect("history action has no opaque id") + .to_owned(); + assert_ne!(completion["data"]["session_id"], RAW_SESSION); + let application = &completion["data"]["application"]; + assert!( + application["bundle_id"] + .as_str() + .is_some_and(|value| !value.is_empty()) + || application["display_name"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "history action has no fixed-field application identity: {completion}" + ); + + let ciphertext = ciphertext_paths(&history_root()); + assert!( + !ciphertext.is_empty(), + "history produced no encrypted chunks" + ); + for path in ciphertext { + let bytes = fs::read(&path).expect("read encrypted history chunk"); + for forbidden in [RAW_SESSION, "window.frame.set", "action_completed"] { + assert!( + !bytes + .windows(forbidden.len()) + .any(|window| window == forbidden.as_bytes()), + "plaintext marker {forbidden:?} appeared in {}", + path.display() + ); + } + } + drop(driver); + + stop_daemon(); + let restarted = start_daemon(); + let mut driver = McpDriver::spawn_daemon_proxy_unrecorded(&daemon_socket()) + .expect("start restarted daemon proxy"); + let status = driver.call("history_status", json!({})); + assert_ready(status.structured()); + let reopened = query(&mut driver, None); + assert!( + reopened["events"] + .as_array() + .into_iter() + .flatten() + .any(|event| { + event["data"]["action_id"].as_str() == Some(&action_id) + && event["data"]["capability"] == "window.frame.set" + && event["data"]["payload"]["kind"] == "action_completed" + }), + "restarted daemon could not hydrate the recorded action" + ); + drop(driver); + + let disabled = history_cli("disable", &[]); + assert_eq!(disabled["enabled"], false); + assert!(disabled["bytes_used"].as_u64().unwrap_or(0) > 0); + let preserved = history_cli("list", &["200"]); + assert!( + preserved["events"] + .as_array() + .into_iter() + .flatten() + .any(|event| { event["data"]["action_id"].as_str() == Some(&action_id) }), + "disable unexpectedly removed stored history" + ); + let deleted = history_cli("delete", &["--yes"]); + assert_eq!(deleted["bytes_used"], 0); + assert!( + ciphertext_paths(&history_root()).is_empty(), + "delete retained ciphertext" + ); + assert!( + key_references().is_empty(), + "delete retained the native encryption key" + ); + drop(restarted); + + let observation = Observation::delivered(vec![OracleKind::Protocol], evidence); + let result = CaseResult::evaluate(case, observation, started_at.elapsed()); + write_result_from_env(&result).expect("write history E2E result"); + assert_eq!(result.test_status, TestStatus::Pass, "{}", result.message); +} diff --git a/libs/cua-driver/rust/crates/platform-linux/Cargo.toml b/libs/cua-driver/rust/crates/platform-linux/Cargo.toml index 4b9c581348..83eb4f6911 100644 --- a/libs/cua-driver/rust/crates/platform-linux/Cargo.toml +++ b/libs/cua-driver/rust/crates/platform-linux/Cargo.toml @@ -15,11 +15,14 @@ cua-driver-core = { path = "../cua-driver-core" } cua-driver-contract = { path = "../cua-driver-contract" } cursor-overlay = { path = "../cursor-overlay" } pip-preview = { path = "../pip-preview" } +zeroize = { workspace = true } +getrandom = { workspace = true } # tiny-skia for cursor rendering (shared cross-platform) tiny-skia = { version = "0.11", default-features = false, features = ["std"] } [target.'cfg(target_os = "linux")'.dependencies] clipboard-rs = { version = "0.3.5", features = ["wayland"] } +keyring = { version = "4.1.6", default-features = false, features = ["v1"] } # X11 background input + window enumeration + MIT-SHM capture x11rb = { version = "0.13", features = ["xinput", "randr", "xfixes", "composite", "shape", "xtest", "shm"] } x11 = { version = "2.21", features = ["xlib", "xinput", "xtest"] } @@ -135,6 +138,7 @@ portal-libei = ["portal-input", "portal-capture"] [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +uuid = { workspace = true } # Used by the `screenshot_cascade` example to surface the # `tracing::debug!` lines emitted by each tier of the dispatch as it # falls through. diff --git a/libs/cua-driver/rust/crates/platform-linux/src/history.rs b/libs/cua-driver/rust/crates/platform-linux/src/history.rs new file mode 100644 index 0000000000..d9c208dbd5 --- /dev/null +++ b/libs/cua-driver/rust/crates/platform-linux/src/history.rs @@ -0,0 +1,242 @@ +//! Linux adapters for encrypted Computer History. + +use cua_driver_core::history::{ + ApplicationIdentity, ApplicationIdentityProvider, HistoryError, HistoryHealthCategory, + HistoryKey, KeyProvider, +}; +use keyring::{Entry, Error as KeyringError}; +use std::sync::Arc; +use zeroize::Zeroizing; + +const KEY_ACCOUNT: &str = "namespace-root-key-v1"; + +#[derive(Default)] +pub struct LinuxSecretServiceKeyProvider; + +impl LinuxSecretServiceKeyProvider { + pub fn shared() -> Arc { + Arc::new(Self) + } + + fn service(namespace: &str) -> Result { + validate_namespace(namespace)?; + Ok(format!("com.trycua.{namespace}.computer-history.v1")) + } + + fn entry(service: &str) -> Result { + Entry::new(service, KEY_ACCOUNT).map_err(map_keyring_error) + } +} + +impl KeyProvider for LinuxSecretServiceKeyProvider { + fn load_or_create(&self, namespace: &str) -> Result { + let service = Self::service(namespace)?; + let entry = Self::entry(&service)?; + match entry.get_secret() { + Ok(bytes) => key(service, bytes), + Err(KeyringError::NoEntry) => { + let mut bytes = Zeroizing::new(vec![0_u8; 32]); + getrandom::fill(&mut bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + entry.set_secret(&bytes).map_err(map_keyring_error)?; + let verified = Zeroizing::new(entry.get_secret().map_err(map_keyring_error)?); + if verified.as_slice() != bytes.as_slice() { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + zeroizing_key(service, verified) + } + Err(error) => Err(map_keyring_error(error)), + } + } + + fn load(&self, namespace: &str, reference: &str) -> Result { + let service = Self::service(namespace)?; + if reference != service { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + key( + service.clone(), + Self::entry(&service)? + .get_secret() + .map_err(map_keyring_error)?, + ) + } + + fn references(&self, namespace: &str) -> Result, HistoryError> { + let service = Self::service(namespace)?; + match Self::entry(&service)?.get_secret() { + Ok(bytes) => { + let _bytes = Zeroizing::new(bytes); + Ok(vec![service]) + } + Err(KeyringError::NoEntry) => Ok(Vec::new()), + Err(error) => Err(map_keyring_error(error)), + } + } + + fn destroy(&self, namespace: &str, reference: &str) -> Result<(), HistoryError> { + let service = Self::service(namespace)?; + if reference != service { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + let entry = Self::entry(&service)?; + match entry.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => {} + Err(error) => return Err(map_keyring_error(error)), + } + match entry.get_secret() { + Err(KeyringError::NoEntry) => Ok(()), + Ok(bytes) => { + let _bytes = Zeroizing::new(bytes); + Err(HistoryError::new(HistoryHealthCategory::KeyDestroyFailed)) + } + Err(error) => Err(map_keyring_error(error)), + } + } +} + +fn validate_namespace(namespace: &str) -> Result<(), HistoryError> { + let valid = !namespace.is_empty() + && namespace.len() <= 64 + && namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); + valid + .then_some(()) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::KeyUnavailable)) +} + +fn key(reference: String, bytes: Vec) -> Result { + zeroizing_key(reference, Zeroizing::new(bytes)) +} + +fn zeroizing_key(reference: String, bytes: Zeroizing>) -> Result { + if bytes.len() != 32 { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + Ok(HistoryKey { + reference, + epoch: 1, + bytes, + }) +} + +fn map_keyring_error(error: KeyringError) -> HistoryError { + let category = match error { + KeyringError::NoStorageAccess(_) => HistoryHealthCategory::KeyLocked, + KeyringError::BadDataFormat(_, _) + | KeyringError::BadStoreFormat(_) + | KeyringError::BadEncoding(_) + | KeyringError::Ambiguous(_) => HistoryHealthCategory::KeyCorrupt, + _ => HistoryHealthCategory::KeyUnavailable, + }; + HistoryError::new(category) +} + +#[derive(Default)] +pub struct LinuxApplicationIdentityProvider; + +impl ApplicationIdentityProvider for LinuxApplicationIdentityProvider { + fn resolve(&self, pid: i64) -> Option { + let pid = u32::try_from(pid).ok()?; + let process = crate::proc_fs::list_processes() + .into_iter() + .find(|process| process.pid == pid)?; + let platform_id = if crate::wayland::is_wayland() { + crate::wayland::list_windows_dispatch(Some(pid)) + .into_iter() + .find_map(|window| (!window.app_name.trim().is_empty()).then_some(window.app_name)) + } else { + crate::x11::list_windows(Some(pid)) + .into_iter() + .find_map(|window| (!window.app_name.trim().is_empty()).then_some(window.app_name)) + } + .or_else(|| (!process.name.trim().is_empty()).then_some(process.name.clone())); + let display_name = (!process.name.trim().is_empty()).then_some(process.name); + (platform_id.is_some() || display_name.is_some()).then_some(ApplicationIdentity { + bundle_id: platform_id, + display_name, + }) + } +} + +pub fn application_identity_provider() -> Arc { + Arc::new(LinuxApplicationIdentityProvider) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct CredentialCleanup(Entry); + + impl Drop for CredentialCleanup { + fn drop(&mut self) { + let _ = self.0.delete_credential(); + } + } + + #[test] + fn secret_service_namespace_is_strict_and_separated() { + assert_ne!( + LinuxSecretServiceKeyProvider::service("cua-driver").unwrap(), + LinuxSecretServiceKeyProvider::service("cua-driver-local").unwrap() + ); + assert!(LinuxSecretServiceKeyProvider::service("../escape").is_err()); + } + + #[test] + fn current_process_identity_is_resolvable_without_titles_or_paths() { + let identity = LinuxApplicationIdentityProvider + .resolve(i64::from(std::process::id())) + .expect("current process identity"); + assert!(identity.bundle_id.is_some() || identity.display_name.is_some()); + } + + #[test] + fn inaccessible_store_maps_to_locked_without_fallback() { + let error = KeyringError::NoStorageAccess(Box::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "synthetic locked vault", + ))); + assert_eq!( + map_keyring_error(error).category, + HistoryHealthCategory::KeyLocked + ); + } + + #[test] + #[ignore = "mutates uniquely named Secret Service items; run in native release qualification"] + fn secret_service_key_lifecycle_is_isolated_corruptible_and_destroyable() { + let namespace = format!("cua-driver-local-test-{}", uuid::Uuid::new_v4().simple()); + let other_namespace = format!("{namespace}-other"); + let provider = LinuxSecretServiceKeyProvider; + assert!(provider.references(&namespace).unwrap().is_empty()); + assert!(provider.references(&other_namespace).unwrap().is_empty()); + + let created = provider.load_or_create(&namespace).unwrap(); + let entry = LinuxSecretServiceKeyProvider::entry(&created.reference).unwrap(); + let _cleanup = CredentialCleanup(entry); + assert_eq!(created.bytes.len(), 32); + assert_eq!( + provider + .load(&namespace, &created.reference) + .unwrap() + .bytes + .as_slice(), + created.bytes.as_slice() + ); + assert!(provider.references(&other_namespace).unwrap().is_empty()); + + LinuxSecretServiceKeyProvider::entry(&created.reference) + .unwrap() + .set_secret(b"corrupt") + .unwrap(); + match provider.load(&namespace, &created.reference) { + Err(error) => assert_eq!(error.category, HistoryHealthCategory::KeyCorrupt), + Ok(_) => panic!("corrupt credential unexpectedly loaded"), + } + provider.destroy(&namespace, &created.reference).unwrap(); + assert!(provider.references(&namespace).unwrap().is_empty()); + } +} diff --git a/libs/cua-driver/rust/crates/platform-linux/src/lib.rs b/libs/cua-driver/rust/crates/platform-linux/src/lib.rs index 75334a31f8..6298435c3e 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/lib.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/lib.rs @@ -29,6 +29,9 @@ pub mod tty; #[cfg(target_os = "linux")] pub mod proc_fs; +#[cfg(target_os = "linux")] +pub mod history; + #[cfg(target_os = "linux")] pub mod browser_platform; diff --git a/libs/cua-driver/rust/crates/platform-windows/Cargo.toml b/libs/cua-driver/rust/crates/platform-windows/Cargo.toml index 96fb8fd274..1479c4f338 100644 --- a/libs/cua-driver/rust/crates/platform-windows/Cargo.toml +++ b/libs/cua-driver/rust/crates/platform-windows/Cargo.toml @@ -15,6 +15,8 @@ cua-driver-core = { path = "../cua-driver-core" } cua-driver-contract = { path = "../cua-driver-contract" } cursor-overlay = { path = "../cursor-overlay" } pip-preview = { path = "../pip-preview" } +zeroize = { workspace = true } +getrandom = { workspace = true } # IndexMap gives the keyed cursor render collection deterministic # insertion-ordered iteration = stable per-session cursor z-order frame to # frame (mirrors platform-macos's per-session overlay). @@ -24,6 +26,7 @@ tiny-skia = { version = "0.11", default-features = false, features = ["std"] } [target.'cfg(target_os = "windows")'.dependencies] clipboard-rs = "0.3.5" +keyring = { version = "4.1.6", default-features = false, features = ["v1"] } image = { workspace = true } windows = { version = "0.58", features = [ "Win32_Foundation", @@ -81,3 +84,6 @@ windows = { version = "0.58", features = [ "Win32_System_WinRT_Graphics_Capture", ] } base64 = { workspace = true } + +[dev-dependencies] +uuid = { workspace = true } diff --git a/libs/cua-driver/rust/crates/platform-windows/src/history.rs b/libs/cua-driver/rust/crates/platform-windows/src/history.rs new file mode 100644 index 0000000000..72916ae9fa --- /dev/null +++ b/libs/cua-driver/rust/crates/platform-windows/src/history.rs @@ -0,0 +1,255 @@ +//! Windows adapters for encrypted Computer History. + +use cua_driver_core::history::{ + ApplicationIdentity, ApplicationIdentityProvider, HistoryError, HistoryHealthCategory, + HistoryKey, KeyProvider, +}; +use keyring::{Entry, Error as KeyringError}; +use std::{path::PathBuf, sync::Arc}; +use zeroize::Zeroizing; + +const KEY_ACCOUNT: &str = "namespace-root-key-v1"; + +#[derive(Default)] +pub struct WindowsCredentialKeyProvider; + +impl WindowsCredentialKeyProvider { + pub fn shared() -> Arc { + Arc::new(Self) + } + + fn service(namespace: &str) -> Result { + validate_namespace(namespace)?; + Ok(format!("com.trycua.{namespace}.computer-history.v1")) + } + + fn entry(service: &str) -> Result { + Entry::new(service, KEY_ACCOUNT).map_err(map_keyring_error) + } +} + +impl KeyProvider for WindowsCredentialKeyProvider { + fn load_or_create(&self, namespace: &str) -> Result { + let service = Self::service(namespace)?; + let entry = Self::entry(&service)?; + match entry.get_secret() { + Ok(bytes) => key(service, bytes), + Err(KeyringError::NoEntry) => { + let mut bytes = Zeroizing::new(vec![0_u8; 32]); + getrandom::fill(&mut bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + entry.set_secret(&bytes).map_err(map_keyring_error)?; + let verified = Zeroizing::new(entry.get_secret().map_err(map_keyring_error)?); + if verified.as_slice() != bytes.as_slice() { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + zeroizing_key(service, verified) + } + Err(error) => Err(map_keyring_error(error)), + } + } + + fn load(&self, namespace: &str, reference: &str) -> Result { + let service = Self::service(namespace)?; + if reference != service { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + key( + service.clone(), + Self::entry(&service)? + .get_secret() + .map_err(map_keyring_error)?, + ) + } + + fn references(&self, namespace: &str) -> Result, HistoryError> { + let service = Self::service(namespace)?; + match Self::entry(&service)?.get_secret() { + Ok(bytes) => { + let _bytes = Zeroizing::new(bytes); + Ok(vec![service]) + } + Err(KeyringError::NoEntry) => Ok(Vec::new()), + Err(error) => Err(map_keyring_error(error)), + } + } + + fn destroy(&self, namespace: &str, reference: &str) -> Result<(), HistoryError> { + let service = Self::service(namespace)?; + if reference != service { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + let entry = Self::entry(&service)?; + match entry.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => {} + Err(error) => return Err(map_keyring_error(error)), + } + match entry.get_secret() { + Err(KeyringError::NoEntry) => Ok(()), + Ok(bytes) => { + let _bytes = Zeroizing::new(bytes); + Err(HistoryError::new(HistoryHealthCategory::KeyDestroyFailed)) + } + Err(error) => Err(map_keyring_error(error)), + } + } +} + +fn validate_namespace(namespace: &str) -> Result<(), HistoryError> { + let valid = !namespace.is_empty() + && namespace.len() <= 64 + && namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); + valid + .then_some(()) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::KeyUnavailable)) +} + +fn key(reference: String, bytes: Vec) -> Result { + zeroizing_key(reference, Zeroizing::new(bytes)) +} + +fn zeroizing_key(reference: String, bytes: Zeroizing>) -> Result { + if bytes.len() != 32 { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + Ok(HistoryKey { + reference, + epoch: 1, + bytes, + }) +} + +fn map_keyring_error(error: KeyringError) -> HistoryError { + let category = match error { + KeyringError::NoStorageAccess(_) => HistoryHealthCategory::KeyLocked, + KeyringError::BadDataFormat(_, _) + | KeyringError::BadStoreFormat(_) + | KeyringError::BadEncoding(_) + | KeyringError::Ambiguous(_) => HistoryHealthCategory::KeyCorrupt, + _ => HistoryHealthCategory::KeyUnavailable, + }; + HistoryError::new(category) +} + +#[derive(Default)] +pub struct WindowsApplicationIdentityProvider; + +impl ApplicationIdentityProvider for WindowsApplicationIdentityProvider { + fn resolve(&self, pid: i64) -> Option { + let pid = u32::try_from(pid).ok()?; + let name = crate::win32::list_processes() + .into_iter() + .find(|process| process.pid == pid)? + .name; + (!name.trim().is_empty()).then(|| ApplicationIdentity { + bundle_id: Some(name.clone()), + display_name: Some(name), + }) + } +} + +pub fn application_identity_provider() -> Arc { + Arc::new(WindowsApplicationIdentityProvider) +} + +pub fn process_executable_path(pid: u32) -> Option { + use windows::core::PWSTR; + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut buffer = vec![0_u16; 32_768]; + let mut length = u32::try_from(buffer.len()).ok()?; + let result = QueryFullProcessImageNameW( + process, + PROCESS_NAME_FORMAT(0), + PWSTR(buffer.as_mut_ptr()), + &mut length, + ); + let _ = CloseHandle(process); + result.ok()?; + buffer.truncate(length as usize); + Some(PathBuf::from(String::from_utf16_lossy(&buffer))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct CredentialCleanup(Entry); + + impl Drop for CredentialCleanup { + fn drop(&mut self) { + let _ = self.0.delete_credential(); + } + } + + #[test] + fn credential_namespace_is_strict_and_separated() { + assert_ne!( + WindowsCredentialKeyProvider::service("cua-driver").unwrap(), + WindowsCredentialKeyProvider::service("cua-driver-local").unwrap() + ); + assert!(WindowsCredentialKeyProvider::service("../escape").is_err()); + } + + #[test] + fn current_process_identity_is_resolvable() { + let path = process_executable_path(std::process::id()).expect("current process path"); + assert!(path.is_absolute()); + } + + #[test] + fn inaccessible_store_maps_to_locked_without_fallback() { + let error = KeyringError::NoStorageAccess(Box::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "synthetic locked vault", + ))); + assert_eq!( + map_keyring_error(error).category, + HistoryHealthCategory::KeyLocked + ); + } + + #[test] + #[ignore = "mutates uniquely named Windows Credential Manager items; run in native release qualification"] + fn credential_manager_key_lifecycle_is_isolated_corruptible_and_destroyable() { + let namespace = format!("cua-driver-local-test-{}", uuid::Uuid::new_v4().simple()); + let other_namespace = format!("{namespace}-other"); + let provider = WindowsCredentialKeyProvider; + assert!(provider.references(&namespace).unwrap().is_empty()); + assert!(provider.references(&other_namespace).unwrap().is_empty()); + + let created = provider.load_or_create(&namespace).unwrap(); + let entry = WindowsCredentialKeyProvider::entry(&created.reference).unwrap(); + let _cleanup = CredentialCleanup(entry); + assert_eq!(created.bytes.len(), 32); + assert_eq!( + provider + .load(&namespace, &created.reference) + .unwrap() + .bytes + .as_slice(), + created.bytes.as_slice() + ); + assert!(provider.references(&other_namespace).unwrap().is_empty()); + + WindowsCredentialKeyProvider::entry(&created.reference) + .unwrap() + .set_secret(b"corrupt") + .unwrap(); + match provider.load(&namespace, &created.reference) { + Err(error) => assert_eq!(error.category, HistoryHealthCategory::KeyCorrupt), + Ok(_) => panic!("corrupt credential unexpectedly loaded"), + } + provider.destroy(&namespace, &created.reference).unwrap(); + assert!(provider.references(&namespace).unwrap().is_empty()); + } +} diff --git a/libs/cua-driver/rust/crates/platform-windows/src/lib.rs b/libs/cua-driver/rust/crates/platform-windows/src/lib.rs index 060ffc1d41..66d9f13b3f 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/lib.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/lib.rs @@ -46,6 +46,9 @@ pub mod lparam; #[cfg(target_os = "windows")] pub mod win32; +#[cfg(target_os = "windows")] +pub mod history; + #[cfg(target_os = "windows")] pub mod browser_platform; diff --git a/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh b/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh index eb960dc4b2..3b85f90a33 100644 --- a/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh +++ b/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh @@ -37,6 +37,11 @@ purge_macos_history "$APP" "$HELPER" 1 "$FIXTURE/codesign" [[ "$(sed -n '1p' "$LOG")" == "stop" ]] [[ "$(sed -n '2p' "$LOG")" == "history purge-offline --yes" ]] +: > "$LOG" +purge_linux_history "$HELPER" 1 +[[ "$(sed -n '1p' "$LOG")" == "stop" ]] +[[ "$(sed -n '2p' "$LOG")" == "history purge-offline --yes" ]] + export UNINSTALL_FIXTURE_FAIL=1 if purge_macos_history "$APP" "$HELPER" 1 "$FIXTURE/codesign" 2> "$FIXTURE/error.log"; then echo "expected synthetic purge failure" >&2 @@ -46,9 +51,18 @@ grep -Fq history_purge_incomplete "$FIXTURE/error.log" [[ -d "$APP" ]] [[ -f "$STATE/state.json" ]] +if purge_linux_history "$FIXTURE/missing-helper" 1 2> "$FIXTURE/linux-error.log"; then + echo "expected missing Linux helper refusal" >&2 + exit 1 +fi +grep -Fq history_purge_incomplete "$FIXTURE/linux-error.log" + purge_line="$(grep -n 'purge_macos_history \\' "$UNINSTALL" | tail -1 | cut -d: -f1)" remove_line="$(grep -n 'rm -rf "\$APP_BUNDLE"' "$UNINSTALL" | head -1 | cut -d: -f1)" [[ "$purge_line" -lt "$remove_line" ]] +linux_purge_line="$(grep -n 'purge_linux_history "\$HISTORY_PURGE_HELPER"' "$UNINSTALL" | tail -1 | cut -d: -f1)" +package_remove_line="$(grep -n 'rm -rf "\$HOME_DIR"' "$UNINSTALL" | head -1 | cut -d: -f1)" +[[ "$linux_purge_line" -lt "$package_remove_line" ]] grep -Fq 'preserved encrypted Computer History' "$UNINSTALL" echo "uninstall history purge fixture: ok" diff --git a/libs/cua-driver/scripts/tests/uninstall-windows-history-regression.ps1 b/libs/cua-driver/scripts/tests/uninstall-windows-history-regression.ps1 new file mode 100644 index 0000000000..063a86f41c --- /dev/null +++ b/libs/cua-driver/scripts/tests/uninstall-windows-history-regression.ps1 @@ -0,0 +1,41 @@ +[CmdletBinding()] +param( + [string]$UninstallerPath = (Join-Path (Split-Path -Parent $PSScriptRoot) "uninstall.ps1") +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$tokens = $null +$parseErrors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile( + $UninstallerPath, + [ref]$tokens, + [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { + throw "uninstall.ps1 parse errors: $($parseErrors -join '; ')" +} + +$source = Get-Content -LiteralPath $UninstallerPath -Raw +$purgeInvocation = '& $HistoryPurgeHelper history purge-offline --yes' +$runtimeRemoval = '# 3. Visible bin directory junction.' +$purgeIndex = $source.IndexOf($purgeInvocation, [StringComparison]::Ordinal) +$runtimeRemovalIndex = $source.IndexOf($runtimeRemoval, [StringComparison]::Ordinal) + +if ($purgeIndex -lt 0) { + throw "Windows uninstaller does not invoke exact installed-helper history purge" +} +if ($runtimeRemovalIndex -lt 0 -or $purgeIndex -ge $runtimeRemovalIndex) { + throw "Windows history purge must run before installed runtime removal" +} +if ($source -notmatch 'if \(-not \(Test-Path -LiteralPath \$HistoryPurgeHelper\)\)[\s\S]*?history_purge_incomplete[\s\S]*?exit 1') { + throw "Windows uninstaller does not fail closed when its exact helper is absent" +} +if ($source -notmatch 'if \(\$historyPurgeExit -ne 0\)[\s\S]*?history_purge_incomplete[\s\S]*?exit 1') { + throw "Windows uninstaller does not fail closed when native key destruction fails" +} +if ($source -notmatch 'preserved encrypted Computer History') { + throw "Windows normal-uninstall preservation disclosure is absent" +} + +Write-Host "Windows Computer History uninstall ordering checks passed." diff --git a/libs/cua-driver/scripts/uninstall.ps1 b/libs/cua-driver/scripts/uninstall.ps1 index 46af92ef69..11cc5fd7e4 100644 --- a/libs/cua-driver/scripts/uninstall.ps1 +++ b/libs/cua-driver/scripts/uninstall.ps1 @@ -59,7 +59,7 @@ # working install. Inherited automatically by the elevated # re-exec child (see Elevation below). # -# Env (purge identity + preference): +# Env (purge identity, preference, and encrypted Computer History): # $env:CUA_DRIVER_RS_UNINSTALL_PURGE = '1' # also delete the package home after removing the runtime. # @@ -313,6 +313,33 @@ if ($running.Count -gt 0) { Write-Step "no running cua-driver.exe processes" } +# Computer History purge must run while the exact installed helper still +# exists. It destroys the namespace-scoped Windows Credential Manager key, +# verifies its absence, and only then removes encrypted history state. Any +# failure leaves the runtime and retryable ciphertext in place. +$HistoryPurgeHelper = Join-Path $CurrentDir "cua-driver.exe" +if ($Purge) { + if (-not (Test-Path -LiteralPath $HistoryPurgeHelper)) { + Write-ErrorStep "history_purge_incomplete: installed Cua Driver helper unavailable; preserved history state and runtime for retry" + exit 1 + } + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $HistoryPurgeHelper history purge-offline --yes + $historyPurgeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + if ($historyPurgeExit -ne 0) { + Write-ErrorStep "history_purge_incomplete: exact-namespace Windows Credential Manager key destruction was not verified; preserved history state and runtime for retry" + exit 1 + } + Write-Step "cryptographically purged release Computer History key and local history state" +} else { + Write-Step "preserved encrypted Computer History if present; reinstall to reopen it or purge explicitly" +} + # 3. Visible bin directory junction. Only remove when it's actually a # reparse point — refuse to clobber a real directory the user might # have at that path. @@ -461,7 +488,7 @@ Write-Host "" Write-Host "cua-driver-rs uninstalled." -ForegroundColor Green Write-Host "" if (-not $Purge) { - Write-Host "Telemetry identity and preference were preserved for a future reinstall." -ForegroundColor Cyan + Write-Host "Telemetry identity, preference, and encrypted Computer History were preserved for a future reinstall." -ForegroundColor Cyan Write-Host "To delete them too, re-run with:" Write-Host "" Write-Host " `$env:CUA_DRIVER_RS_UNINSTALL_PURGE = '1'" diff --git a/libs/cua-driver/scripts/uninstall.sh b/libs/cua-driver/scripts/uninstall.sh index 63013854aa..9bcbbfe556 100755 --- a/libs/cua-driver/scripts/uninstall.sh +++ b/libs/cua-driver/scripts/uninstall.sh @@ -126,10 +126,24 @@ purge_macos_history() { fi } +purge_linux_history() { + local helper="$1" + local rust_install_present="$2" + if [[ "$rust_install_present" != "1" || ! -x "$helper" ]]; then + printf 'history_purge_incomplete: installed Cua Driver helper unavailable; preserved history state for retry\n' >&2 + return 1 + fi + "$helper" stop >/dev/null 2>&1 || true + if ! "$helper" history purge-offline --yes; then + printf 'history_purge_incomplete: exact-namespace Secret Service key destruction was not verified; preserved history state and runtime for retry\n' >&2 + return 1 + fi +} + reject_root_invocation() { local effective_uid="$1" if [[ "$effective_uid" == "0" ]]; then - printf 'error: do not run the Cua Driver uninstaller with sudo; run it as the login user so Computer History is purged from the correct home directory and Keychain. The script elevates only protected app removal when needed.\n' >&2 + printf 'error: do not run the Cua Driver uninstaller with sudo; run it as the login user so Computer History is purged from the correct home directory and native credential store. The script elevates only protected app removal when needed.\n' >&2 return 77 fi } @@ -344,10 +358,10 @@ if [[ "$USE_RUST_BACKEND" == "1" ]]; then fi fi - # Cryptographic history purge must run while the exact packaged, signed + # Cryptographic history purge must run while the exact installed helper # executable still exists. The helper uses the production KeyProvider and # its own bundle-derived namespace, then takes the exclusive writer lease; - # failure leaves the app and all retryable history state in place. + # failure leaves the runtime and all retryable history state in place. if [[ "$OS" == "Darwin" && "$PURGE_DATA" == "1" ]]; then HISTORY_PURGE_HELPER="$APP_BUNDLE/Contents/MacOS/cua-driver" if ! purge_macos_history \ @@ -357,6 +371,14 @@ if [[ "$USE_RUST_BACKEND" == "1" ]]; then log "cryptographically purged release Computer History key and local history state" elif [[ "$OS" == "Darwin" ]]; then log "preserved encrypted Computer History if present; reinstall to reopen it or run uninstall.sh --purge to destroy it" + elif [[ "$OS" == "Linux" && "$PURGE_DATA" == "1" ]]; then + HISTORY_PURGE_HELPER="$PACKAGES_DIR/current/cua-driver" + if ! purge_linux_history "$HISTORY_PURGE_HELPER" "$RUST_INSTALL_PRESENT"; then + exit 1 + fi + log "cryptographically purged release Computer History Secret Service key and local history state" + elif [[ "$OS" == "Linux" ]]; then + log "preserved encrypted Computer History if present; reinstall to reopen it or run uninstall.sh --purge to destroy it" fi # --- Revoke TCC grants BEFORE removing the app --- diff --git a/scripts/ci/linux/run-rust-e2e.sh b/scripts/ci/linux/run-rust-e2e.sh index 09c74689b2..bdd73dee80 100755 --- a/scripts/ci/linux/run-rust-e2e.sh +++ b/scripts/ci/linux/run-rust-e2e.sh @@ -193,6 +193,68 @@ run_test() { fi } +run_computer_history_gate() { + local history_home="${ARTIFACT_DIR}/history-product-home" + local history_bin_dir="${ARTIFACT_DIR}/history-product-bin" + local history_socket="${ARTIFACT_DIR}/history-daemon.sock" + export CUA_DRIVER_LOCAL_HOME="${history_home}" + export CUA_DRIVER_LOCAL_INSTALL_DIR="${history_bin_dir}" + export XDG_STATE_HOME="${ARTIFACT_DIR}/history-state" + + echo "[HISTORY] Installing the exact candidate into an isolated local namespace" + bash "${DRIVER_ROOT}/scripts/install-local.sh" --release \ + 2>&1 | tee "${ARTIFACT_DIR}/history-install-local.log" + export CUA_E2E_INSTALLED_DRIVER_BIN="${history_home}/packages/current/cua-driver-local" + export CUA_E2E_HISTORY_DAEMON_SOCKET="${history_socket}" + if [[ ! -x "${CUA_E2E_INSTALLED_DRIVER_BIN}" ]]; then + echo "installed history driver is missing: ${CUA_E2E_INSTALLED_DRIVER_BIN}" >&2 + FAILURE_COUNT=$((FAILURE_COUNT + 1)) + return + fi + + if ! "${CUA_E2E_INSTALLED_DRIVER_BIN}" history purge-offline --yes \ + >"${ARTIFACT_DIR}/history-purge-preflight.log" 2>&1; then + echo "installed history driver could not establish an empty encrypted store" >&2 + FAILURE_COUNT=$((FAILURE_COUNT + 1)) + return + fi + + "${CUA_E2E_INSTALLED_DRIVER_BIN}" serve \ + --socket "${history_socket}" \ + --permission-mode unrestricted \ + --dangerously-bypass-approvals \ + >"${ARTIFACT_DIR}/history-daemon.log" 2>&1 & + local daemon_pid=$! + local ready=0 + for _ in $(seq 1 150); do + if [[ -S "${history_socket}" ]]; then + ready=1 + break + fi + if ! kill -0 "${daemon_pid}" 2>/dev/null; then + break + fi + sleep 0.1 + done + if [[ "${ready}" != 1 ]]; then + echo "installed history daemon did not become ready" >&2 + FAILURE_COUNT=$((FAILURE_COUNT + 1)) + return + fi + + run_test computer-history-encrypted-lifecycle \ + cargo test -p cua-driver "${CARGO_DRIVER_FEATURE_ARGS[@]}" \ + --test computer_history_cross_platform_test -- \ + --ignored --exact encrypted_history_survives_restart_and_cryptographically_purges \ + --nocapture --test-threads=1 + + set +e + "${CUA_E2E_INSTALLED_DRIVER_BIN}" stop --socket "${history_socket}" \ + >>"${ARTIFACT_DIR}/history-daemon.log" 2>&1 + wait "${daemon_pid}" 2>/dev/null + set -e +} + if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then run_test protected-permission-prompt-socket \ cargo test -p cua-driver "${CARGO_DRIVER_FEATURE_ARGS[@]}" \ @@ -254,6 +316,10 @@ if [[ "${SUITE}" == capture || "${SUITE}" == all ]]; then --ignored --nocapture --test-threads=1 fi +if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then + run_computer_history_gate +fi + video_count=0 while IFS= read -r -d '' video; do video_count=$((video_count + 1)) diff --git a/scripts/ci/windows/run-rust-e2e.ps1 b/scripts/ci/windows/run-rust-e2e.ps1 index 582565e6ea..41d5d7dd11 100644 --- a/scripts/ci/windows/run-rust-e2e.ps1 +++ b/scripts/ci/windows/run-rust-e2e.ps1 @@ -206,6 +206,114 @@ function Invoke-CargoTest { } } +function Invoke-ComputerHistoryGate { + $historyHome = Join-Path $artifactDir "history-product-home" + $historyBinDir = Join-Path $artifactDir "history-product-bin" + $historyLocalAppData = Join-Path $artifactDir "history-local-app-data" + $historyPipe = "\\.\pipe\cua-driver-local-history-$PID" + $env:CUA_DRIVER_LOCAL_HOME = $historyHome + $env:CUA_DRIVER_LOCAL_INSTALL_DIR = $historyBinDir + $env:CUA_E2E_HISTORY_DAEMON_SOCKET = $historyPipe + $env:LOCALAPPDATA = $historyLocalAppData + + Write-Host "[HISTORY] Installing the exact candidate into an isolated local namespace" -ForegroundColor Yellow + $installLog = Join-Path $artifactDir "history-install-local.log" + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $installOutput = @(& (Join-Path $driverRoot "scripts\install-local.ps1") ` + -NoAutoStart -NoPathUpdate 2>&1) | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.Exception.Message + } else { + $_.ToString() + } + } + $installExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + $installOutput | Tee-Object -FilePath $installLog + if ($installExit -ne 0) { + $script:FailureCount++ + return + } + + $installed = Join-Path $historyHome "packages\current\cua-driver-local.exe" + $env:CUA_E2E_INSTALLED_DRIVER_BIN = $installed + if (-not (Test-Path -LiteralPath $installed)) { + Write-Host "[HISTORY FAIL] Installed driver is missing: $installed" -ForegroundColor Red + $script:FailureCount++ + return + } + + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $installed history purge-offline --yes 2>&1 | + Set-Content (Join-Path $artifactDir "history-purge-preflight.log") + $purgeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + if ($purgeExit -ne 0) { + Write-Host "[HISTORY FAIL] Installed driver could not establish an empty encrypted store" -ForegroundColor Red + $script:FailureCount++ + return + } + + $daemonLog = Join-Path $artifactDir "history-daemon.log" + $daemonErr = Join-Path $artifactDir "history-daemon.err.log" + $daemon = Start-Process -FilePath $installed -ArgumentList @( + "serve", + "--socket", $historyPipe, + "--permission-mode", "unrestricted", + "--dangerously-bypass-approvals" + ) -RedirectStandardOutput $daemonLog -RedirectStandardError $daemonErr -PassThru + + $ready = $false + for ($attempt = 0; $attempt -lt 150; $attempt++) { + if ($daemon.HasExited) { break } + try { + $probe = [System.IO.File]::Open( + $historyPipe, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::ReadWrite + ) + $probe.Dispose() + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 100 + } + } + if (-not $ready) { + Write-Host "[HISTORY FAIL] Installed daemon did not become ready" -ForegroundColor Red + $script:FailureCount++ + if (-not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force } + return + } + + Invoke-CargoTest "computer-history-encrypted-lifecycle" @( + "test", "-p", "cua-driver", "--test", "computer_history_cross_platform_test", "--", + "--ignored", "--exact", "encrypted_history_survives_restart_and_cryptographically_purges", + "--nocapture", "--test-threads=1" + ) + + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $installed stop --socket $historyPipe 2>&1 | Add-Content $daemonLog + } finally { + $ErrorActionPreference = $previousPreference + } + if (-not $daemon.HasExited) { + $daemon.WaitForExit(5000) | Out-Null + } + if (-not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force } +} + function Test-E2eRecordings { $failureCount = 0 $errors = @(Get-ChildItem -Path $recordingRoot -Filter "recording-error.txt" -Recurse -ErrorAction SilentlyContinue) @@ -329,6 +437,10 @@ if ($suite -in @("capture", "all")) { ) } +if ($suite -in @("shared", "all")) { + Invoke-ComputerHistoryGate +} + $script:FailureCount += (Test-E2eRecordings) $reportExit = Invoke-E2eReport From ff7e0ff08c0c5f22b13323e8df487890f1c1c8cd Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 22:00:58 -0500 Subject: [PATCH 02/18] test(cua-driver): wait for Project Centennial daemon --- .../tests/computer_history_cross_platform_test.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs index 1181df9ae7..f9faa24db9 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -253,9 +253,17 @@ fn ciphertext_paths(root: &Path) -> Vec { fn forged_control_response() -> Value { use std::os::unix::net::UnixStream; - let mut stream = UnixStream::connect(daemon_socket()) - .expect("connect test process directly to packaged daemon"); - write_forged_control(&mut stream) + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match UnixStream::connect(daemon_socket()) { + Ok(mut stream) => return write_forged_control(&mut stream), + Err(error) if Instant::now() < deadline => { + let _ = error; + sleep(Duration::from_millis(50)); + } + Err(error) => panic!("connect test process directly to packaged daemon: {error}"), + } + } } #[cfg(windows)] From 7b3d5e6426e1644a4f2c2823c60bef73aefb0548 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 22:54:47 -0500 Subject: [PATCH 03/18] fix(cua-driver): tolerate Cargo progress in local Windows installer --- libs/cua-driver/scripts/install-local.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/scripts/install-local.ps1 b/libs/cua-driver/scripts/install-local.ps1 index 8c2738e3c4..13cdb4ef58 100644 --- a/libs/cua-driver/scripts/install-local.ps1 +++ b/libs/cua-driver/scripts/install-local.ps1 @@ -176,10 +176,21 @@ if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { Write-Step "cargo build --release -p cua-driver -p cua-driver-uia -p cursor-theme-cli" Push-Location $RepoRoot try { - & cargo build --release -p cua-driver -p cua-driver-uia -p cursor-theme-cli - if ($LASTEXITCODE -ne 0) { + # Windows PowerShell 5.1 promotes native stderr into ErrorRecord objects. + # Cargo writes ordinary progress there, so the script-wide Stop preference + # would terminate a healthy build before its exit code can be inspected. + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & cargo build --release -p cua-driver -p cua-driver-uia -p cursor-theme-cli + $buildExit = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousPreference + } + if ($buildExit -ne 0) { Write-Host "Error: cargo build failed." -ForegroundColor Red - exit $LASTEXITCODE + exit $buildExit } } finally { From b51f1ae6a8cbedd3bc3a32540dd0a00472fc7b47 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 23:09:45 -0500 Subject: [PATCH 04/18] fix(cua-driver): return successful local Windows install status --- libs/cua-driver/scripts/install-local.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libs/cua-driver/scripts/install-local.ps1 b/libs/cua-driver/scripts/install-local.ps1 index 13cdb4ef58..0a106f8636 100644 --- a/libs/cua-driver/scripts/install-local.ps1 +++ b/libs/cua-driver/scripts/install-local.ps1 @@ -419,3 +419,11 @@ if ($AutoStart) { Write-Host " cua-driver-local autostart disable (remove)" -ForegroundColor Cyan Write-Host "" } + +# Native tools such as `schtasks.exe /Query` leave `$LASTEXITCODE` unchanged +# even after later PowerShell commands succeed. When this script is launched +# through `powershell.exe -File`, that stale value can become the process exit +# code and make a completed install look failed to CI callers. Every real +# failure above exits or throws explicitly, so finish with an unambiguous +# success status. +exit 0 From 84877a85bf6fef6815bcc082d562874a1d89e2d0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 23:17:37 -0500 Subject: [PATCH 05/18] fix(cua-driver): isolate native history controls --- .../rust/crates/cua-driver/src/serve.rs | 35 ++++++++++++++----- scripts/ci/windows/run-rust-e2e.ps1 | 13 +++---- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs index d9afefea8f..029ef80149 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs @@ -316,6 +316,25 @@ fn history_control_response( } } +async fn history_control_response_async( + registry: std::sync::Arc, + request: DaemonRequest, + trusted_cli_connection: bool, +) -> DaemonResponse { + // Native credential stores are synchronous at this boundary. Linux's + // Secret Service adapter may drive its own async runtime internally, which + // must not be entered from a Tokio request worker. Keep key creation, + // deletion, and encrypted-store I/O off the daemon's async executor on all + // platforms so one control request cannot stall unrelated clients. + tokio::task::spawn_blocking(move || { + history_control_response(®istry, &request, trusted_cli_connection) + }) + .await + .unwrap_or_else(|error| { + DaemonResponse::err(format!("history_control_worker_failed: {error}"), 1) + }) +} + fn history_relaunch_state_response( request: &DaemonRequest, trusted_cli_connection: bool, @@ -949,11 +968,11 @@ pub async fn run_serve( ).await; } "history_control" => { - let resp = history_control_response( - ®, - &req, + let resp = history_control_response_async( + reg.clone(), + req, trusted_history_cli_connection, - ); + ).await; let _ = writer.write_all( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; @@ -1670,11 +1689,11 @@ pub async fn run_serve( ).await; } "history_control" => { - let resp = history_control_response( - ®, - &req, + let resp = history_control_response_async( + reg.clone(), + req, trusted_history_cli_connection, - ); + ).await; let _ = writer.write_all( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; diff --git a/scripts/ci/windows/run-rust-e2e.ps1 b/scripts/ci/windows/run-rust-e2e.ps1 index 41d5d7dd11..f1b4ad03d3 100644 --- a/scripts/ci/windows/run-rust-e2e.ps1 +++ b/scripts/ci/windows/run-rust-e2e.ps1 @@ -210,7 +210,8 @@ function Invoke-ComputerHistoryGate { $historyHome = Join-Path $artifactDir "history-product-home" $historyBinDir = Join-Path $artifactDir "history-product-bin" $historyLocalAppData = Join-Path $artifactDir "history-local-app-data" - $historyPipe = "\\.\pipe\cua-driver-local-history-$PID" + $historyPipeName = "cua-driver-local-history-$PID" + $historyPipe = "\\.\pipe\$historyPipeName" $env:CUA_DRIVER_LOCAL_HOME = $historyHome $env:CUA_DRIVER_LOCAL_INSTALL_DIR = $historyBinDir $env:CUA_E2E_HISTORY_DAEMON_SOCKET = $historyPipe @@ -275,12 +276,12 @@ function Invoke-ComputerHistoryGate { for ($attempt = 0; $attempt -lt 150; $attempt++) { if ($daemon.HasExited) { break } try { - $probe = [System.IO.File]::Open( - $historyPipe, - [System.IO.FileMode]::Open, - [System.IO.FileAccess]::ReadWrite, - [System.IO.FileShare]::ReadWrite + $probe = [System.IO.Pipes.NamedPipeClientStream]::new( + ".", + $historyPipeName, + [System.IO.Pipes.PipeDirection]::InOut ) + $probe.Connect(100) $probe.Dispose() $ready = $true break From 07f3baa847812539e43bab81a2f1f51c7510e123 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 23:23:17 -0500 Subject: [PATCH 06/18] fix(cua-driver): validate Project Centennial desktop events --- .../crates/cua-driver-core/src/history.rs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs index 2054fe80a8..2b0483e794 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs @@ -1936,7 +1936,7 @@ fn validate_event(event: &HistoryEvent) -> Result<(), HistoryError> { || decode_hex_128(&event.id).is_none() || event.subject.len() > 160 || event.data.sequence == 0 - || event.data.platform != "macos" + || !matches!(event.data.platform.as_str(), "macos" | "windows" | "linux") || event.data.process_model != "in_daemon" || event.data.caller_category != "cua_runtime" || event @@ -2607,6 +2607,38 @@ mod tests { assert!(!rendered.contains("test_query")); } + #[test] + fn every_supported_desktop_platform_roundtrips_encrypted_events() { + let temp = tempfile::tempdir().unwrap(); + for platform in ["macos", "windows", "linux"] { + let root = temp.path().join(platform); + let manager = HistoryManager::new( + HistoryConfig { + root, + platform: platform.to_owned(), + ..config(temp.path()) + }, + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + manager.flush().unwrap(); + let events = manager + .query( + HistoryQuery { + limit: Some(20), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(!events.is_empty(), "{platform} emitted no encrypted events"); + assert!(events.iter().all(|event| event.data.platform == platform)); + manager.disable().unwrap(); + manager.delete_all().unwrap(); + } + } + #[test] fn implicit_sessions_use_keyed_ids_that_can_be_queried_as_opaque_ids() { let temp = tempfile::tempdir().unwrap(); From d78e2b6eb22f8df845a11438d3ff28ccc1eff742 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 23:37:20 -0500 Subject: [PATCH 07/18] test(cua-driver): report Project Centennial lifecycle phases --- .../tests/computer_history_cross_platform_test.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs index f9faa24db9..20d0e520e7 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -414,29 +414,36 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { write_declaration_from_env(&case).expect("write history E2E declaration"); assert_forged_cli_control_is_rejected(); + eprintln!("[history-e2e] forged control denied"); assert!( key_references().is_empty(), "runner preflight retained the history key" ); + eprintln!("[history-e2e] native key store starts empty"); assert!( ciphertext_paths(&history_root()).is_empty(), "runner preflight retained history ciphertext" ); + eprintln!("[history-e2e] enabling preview"); let enabled = history_cli("enable", &[]); assert_ready(&enabled); + eprintln!("[history-e2e] preview enabled"); assert_daemon_remains_unrestricted(); + eprintln!("[history-e2e] authorization mode preserved"); assert_eq!( key_references().len(), 1, "enable did not create one native key" ); + eprintln!("[history-e2e] native key created"); let mut driver = McpDriver::spawn_daemon_proxy_named( &daemon_socket(), &format!("{}-computer-history-continuity", std::env::consts::OS), ) .expect("start installed daemon proxy"); + eprintln!("[history-e2e] MCP proxy attached"); let evidence = recording_evidence(driver.recording_dir()); let status = driver.call("history_status", json!({})); assert!( From 48460d65eaef8b4d3dcd22757d6ddab3440d1e9d Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 14 Aug 2026 23:54:42 -0500 Subject: [PATCH 08/18] fix(cua-driver): isolate Windows daemon stdio handles --- .../rust/crates/cua-driver/Cargo.toml | 4 ++ .../rust/crates/cua-driver/src/cli.rs | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml index 3adaa86f10..f2801681b9 100644 --- a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml @@ -52,6 +52,10 @@ platform-macos = { path = "../platform-macos" } [target.'cfg(target_os = "windows")'.dependencies] platform-windows = { path = "../platform-windows" } +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Console", +] } [target.'cfg(target_os = "linux")'.dependencies] platform-linux = { path = "../platform-linux" } diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index 282a1bc92d..fc2877fcb9 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -1345,6 +1345,14 @@ fn launch_daemon_with_state_and_wait( use std::os::windows::process::CommandExt as _; command.creation_flags(0x0000_0200 | 0x0000_0008); } + // A Windows CLI invoked through `Command::output` owns inheritable + // stdout/stderr pipe handles. CreateProcess otherwise gives those + // unrelated handles to this long-lived daemon even though its standard + // streams are redirected to NUL, keeping the caller's capture open + // until the daemon exits. Mask inheritance only for the spawn and then + // restore the CLI's original handle flags. + #[cfg(target_os = "windows")] + let _stdio_inheritance = WindowsStdioInheritanceGuard::new()?; command.spawn().map_err(|error| LaunchDaemonError { kind: LaunchDaemonErrorKind::Failed, message: format!("failed to launch {} serve: {error}", executable.display()), @@ -1364,6 +1372,65 @@ fn launch_daemon_with_state_and_wait( }) } +#[cfg(target_os = "windows")] +struct WindowsStdioInheritanceGuard { + handles: Vec<(windows::Win32::Foundation::HANDLE, u32)>, +} + +#[cfg(target_os = "windows")] +impl WindowsStdioInheritanceGuard { + fn new() -> Result { + use windows::Win32::Foundation::{ + GetHandleInformation, SetHandleInformation, HANDLE_FLAGS, HANDLE_FLAG_INHERIT, + }; + use windows::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + }; + + let mut guard = Self { + handles: Vec::new(), + }; + for stream in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { + let Ok(handle) = (unsafe { GetStdHandle(stream) }) else { + continue; + }; + let mut flags = 0_u32; + if unsafe { GetHandleInformation(handle, &mut flags) }.is_err() { + continue; + } + if flags & HANDLE_FLAG_INHERIT.0 == 0 { + continue; + } + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) } + .map_err(|error| LaunchDaemonError { + kind: LaunchDaemonErrorKind::Failed, + message: format!( + "could not isolate daemon standard handles from the calling CLI: {error}" + ), + })?; + guard.handles.push((handle, flags)); + } + Ok(guard) + } +} + +#[cfg(target_os = "windows")] +impl Drop for WindowsStdioInheritanceGuard { + fn drop(&mut self) { + use windows::Win32::Foundation::{SetHandleInformation, HANDLE_FLAGS, HANDLE_FLAG_INHERIT}; + + for (handle, flags) in self.handles.drain(..) { + let _ = unsafe { + SetHandleInformation( + handle, + HANDLE_FLAG_INHERIT.0, + HANDLE_FLAGS(flags & HANDLE_FLAG_INHERIT.0), + ) + }; + } + } +} + #[cfg(not(target_os = "macos"))] fn daemon_process_arguments( socket_path: &str, From c760dd8a7e126ba822a599c75373e10504ce4af6 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 00:03:24 -0500 Subject: [PATCH 09/18] fix(cua-driver): launch Windows daemon without inherited handles --- .../rust/crates/cua-driver/Cargo.toml | 3 +- .../rust/crates/cua-driver/src/cli.rs | 170 ++++++++++-------- 2 files changed, 100 insertions(+), 73 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml index f2801681b9..3b76b02717 100644 --- a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml @@ -54,7 +54,8 @@ platform-macos = { path = "../platform-macos" } platform-windows = { path = "../platform-windows" } windows = { version = "0.61", features = [ "Win32_Foundation", - "Win32_System_Console", + "Win32_Security", + "Win32_System_Threading", ] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index fc2877fcb9..669325a21e 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -1314,7 +1314,6 @@ fn launch_daemon_with_state_and_wait( experimental_history: bool, allow_managed_restart: bool, ) -> Result<(), LaunchDaemonError> { - use std::process::{Command as Cmd, Stdio}; use std::time::{Duration, Instant}; let executable = std::env::current_exe().map_err(|error| LaunchDaemonError { @@ -1325,38 +1324,26 @@ fn launch_daemon_with_state_and_wait( && socket_path == crate::serve::default_socket_path() && restart_managed_daemon_if_present(&executable); if !managed { - let mut command = Cmd::new(&executable); - command - .args(daemon_process_arguments( - socket_path, - state, - experimental_history, - )) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); + let arguments = daemon_process_arguments(socket_path, state, experimental_history); #[cfg(unix)] { use std::os::unix::process::CommandExt as _; + use std::process::{Command as Cmd, Stdio}; + + let mut command = Cmd::new(&executable); + command + .args(&arguments) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); command.process_group(0); + command.spawn().map_err(|error| LaunchDaemonError { + kind: LaunchDaemonErrorKind::Failed, + message: format!("failed to launch {} serve: {error}", executable.display()), + })?; } #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt as _; - command.creation_flags(0x0000_0200 | 0x0000_0008); - } - // A Windows CLI invoked through `Command::output` owns inheritable - // stdout/stderr pipe handles. CreateProcess otherwise gives those - // unrelated handles to this long-lived daemon even though its standard - // streams are redirected to NUL, keeping the caller's capture open - // until the daemon exits. Mask inheritance only for the spawn and then - // restore the CLI's original handle flags. - #[cfg(target_os = "windows")] - let _stdio_inheritance = WindowsStdioInheritanceGuard::new()?; - command.spawn().map_err(|error| LaunchDaemonError { - kind: LaunchDaemonErrorKind::Failed, - message: format!("failed to launch {} serve: {error}", executable.display()), - })?; + spawn_detached_windows_daemon(&executable, &arguments)?; } let deadline = Instant::now() + Duration::from_secs(timeout_secs); @@ -1373,62 +1360,101 @@ fn launch_daemon_with_state_and_wait( } #[cfg(target_os = "windows")] -struct WindowsStdioInheritanceGuard { - handles: Vec<(windows::Win32::Foundation::HANDLE, u32)>, +fn spawn_detached_windows_daemon( + executable: &std::path::Path, + arguments: &[String], +) -> Result<(), LaunchDaemonError> { + use std::os::windows::ffi::OsStrExt as _; + use windows::core::{PCWSTR, PWSTR}; + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + CreateProcessW, CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS, PROCESS_INFORMATION, + STARTUPINFOW, + }; + + let executable_wide = executable + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let mut command_line = windows_command_line(executable, arguments); + let mut startup = STARTUPINFOW { + cb: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut process = PROCESS_INFORMATION::default(); + // Rust's Command::output gives this short-lived CLI inheritable capture + // handles. A normal Command::spawn can leak those unrelated handles into + // the long-lived daemon, so the caller never observes EOF. CreateProcess + // with handle inheritance disabled is the Windows process boundary here. + unsafe { + CreateProcessW( + PCWSTR(executable_wide.as_ptr()), + Some(PWSTR(command_line.as_mut_ptr())), + None, + None, + false, + CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, + None, + PCWSTR::null(), + &mut startup, + &mut process, + ) + } + .map_err(|error| LaunchDaemonError { + kind: LaunchDaemonErrorKind::Failed, + message: format!("failed to launch {} serve: {error}", executable.display()), + })?; + let _ = unsafe { CloseHandle(process.hThread) }; + let _ = unsafe { CloseHandle(process.hProcess) }; + Ok(()) } #[cfg(target_os = "windows")] -impl WindowsStdioInheritanceGuard { - fn new() -> Result { - use windows::Win32::Foundation::{ - GetHandleInformation, SetHandleInformation, HANDLE_FLAGS, HANDLE_FLAG_INHERIT, - }; - use windows::Win32::System::Console::{ - GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, - }; +fn windows_command_line(executable: &std::path::Path, arguments: &[String]) -> Vec { + use std::os::windows::ffi::OsStrExt as _; - let mut guard = Self { - handles: Vec::new(), - }; - for stream in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { - let Ok(handle) = (unsafe { GetStdHandle(stream) }) else { - continue; - }; - let mut flags = 0_u32; - if unsafe { GetHandleInformation(handle, &mut flags) }.is_err() { - continue; - } - if flags & HANDLE_FLAG_INHERIT.0 == 0 { - continue; - } - unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) } - .map_err(|error| LaunchDaemonError { - kind: LaunchDaemonErrorKind::Failed, - message: format!( - "could not isolate daemon standard handles from the calling CLI: {error}" - ), - })?; - guard.handles.push((handle, flags)); + let mut command_line = Vec::new(); + for argument in + std::iter::once(executable.as_os_str()).chain(arguments.iter().map(std::ffi::OsStr::new)) + { + if !command_line.is_empty() { + command_line.push(' ' as u16); } - Ok(guard) + append_windows_argument(&mut command_line, argument.encode_wide()); } + command_line.push(0); + command_line } #[cfg(target_os = "windows")] -impl Drop for WindowsStdioInheritanceGuard { - fn drop(&mut self) { - use windows::Win32::Foundation::{SetHandleInformation, HANDLE_FLAGS, HANDLE_FLAG_INHERIT}; - - for (handle, flags) in self.handles.drain(..) { - let _ = unsafe { - SetHandleInformation( - handle, - HANDLE_FLAG_INHERIT.0, - HANDLE_FLAGS(flags & HANDLE_FLAG_INHERIT.0), - ) - }; +fn append_windows_argument(command_line: &mut Vec, argument: impl IntoIterator) { + let argument = argument.into_iter().collect::>(); + let quote = argument.is_empty() + || argument + .iter() + .any(|value| matches!(*value, 0x20 | 0x09 | 0x22)); + if !quote { + command_line.extend(argument); + return; + } + command_line.push('"' as u16); + let mut backslashes = 0; + for value in argument { + if value == '\\' as u16 { + backslashes += 1; + } else if value == '"' as u16 { + command_line.extend(std::iter::repeat_n('\\' as u16, backslashes * 2 + 1)); + command_line.push(value); + backslashes = 0; + } else { + command_line.extend(std::iter::repeat_n('\\' as u16, backslashes)); + command_line.push(value); + backslashes = 0; } } + command_line.extend(std::iter::repeat_n('\\' as u16, backslashes * 2)); + command_line.push('"' as u16); } #[cfg(not(target_os = "macos"))] From e8afb187620c4178e742c8b6250f53f20afebdaa Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 04:55:30 -0500 Subject: [PATCH 10/18] test(cua-driver): wait for native frame settlement --- .../computer_history_cross_platform_test.rs | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs index 20d0e520e7..3ae899d0e0 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -207,6 +207,46 @@ fn launch_fixture(driver: &mut McpDriver) -> (u32, u64, Value) { } } +fn wait_for_window_position( + driver: &mut McpDriver, + pid: u32, + window_id: u64, + requested_x: f64, + requested_y: f64, +) -> Value { + let deadline = Instant::now() + Duration::from_secs(2); + let mut last_bounds = None; + loop { + let readback = driver.call("list_windows", json!({"pid": pid})); + assert!( + !readback.is_error(), + "independent list_windows readback failed: {}", + readback.text() + ); + if let Some(bounds) = readback.structured()["windows"] + .as_array() + .and_then(|windows| { + windows + .iter() + .find(|window| window["window_id"].as_u64() == Some(window_id)) + }) + .map(|window| window["bounds"].clone()) + { + let observed_x = bounds["x"].as_f64().expect("readback x"); + let observed_y = bounds["y"].as_f64().expect("readback y"); + if (observed_x - requested_x).abs() <= 2.0 && (observed_y - requested_y).abs() <= 2.0 { + return bounds; + } + last_bounds = Some(bounds); + } + assert!( + Instant::now() < deadline, + "window frame did not settle within 2s: requested=({requested_x}, {requested_y}) observed={last_bounds:?}" + ); + sleep(Duration::from_millis(50)); + } +} + fn assert_no_private_fields(value: &Value) { const FORBIDDEN_KEYS: &[&str] = &[ "screenshot", @@ -483,17 +523,8 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { moved.text() ); assert_eq!(moved.action_effect(), Some("confirmed")); - let readback = driver.call("list_windows", json!({"pid": pid})); - let observed = readback.structured()["windows"] - .as_array() - .and_then(|windows| { - windows - .iter() - .find(|window| window["window_id"].as_u64() == Some(window_id)) - }) - .expect("moved fixture window disappeared"); - assert!((observed["bounds"]["x"].as_f64().unwrap() - requested_x).abs() <= 2.0); - assert!((observed["bounds"]["y"].as_f64().unwrap() - requested_y).abs() <= 2.0); + let _settled_bounds = + wait_for_window_position(&mut driver, pid, window_id, requested_x, requested_y); let ended = driver.call("end_session", json!({"session": RAW_SESSION})); assert!(!ended.is_error(), "end_session failed: {}", ended.text()); assert_ready(&history_cli("flush", &[])); From 29cbaa26aa14b641ea82e1d560d7d029324b1136 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 05:20:18 -0500 Subject: [PATCH 11/18] fix(cua-driver): align Windows frame coordinate space --- .../platform-windows/src/tools/impl_.rs | 150 ++++++++++++++---- 1 file changed, 123 insertions(+), 27 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs index 3e0326ac05..ef1477e1d6 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs @@ -8669,6 +8669,41 @@ impl Tool for InvokeMenuTool { // ── set_window_frame ────────────────────────────────────────────────────── +fn outer_frame_for_visible_request( + requested: (i32, i32, i32, i32), + outer_before: (i32, i32, i32, i32), + visible_before: (i32, i32, i32, i32), +) -> Result<(i32, i32, i32, i32), String> { + let (requested_x, requested_y, requested_width, requested_height) = requested; + let (outer_x, outer_y, outer_width, outer_height) = outer_before; + let (visible_x, visible_y, visible_width, visible_height) = visible_before; + let left = i64::from(visible_x) - i64::from(outer_x); + let top = i64::from(visible_y) - i64::from(outer_y); + let right = i64::from(outer_x) + i64::from(outer_width) + - (i64::from(visible_x) + i64::from(visible_width)); + let bottom = i64::from(outer_y) + i64::from(outer_height) + - (i64::from(visible_y) + i64::from(visible_height)); + let adjusted = ( + i64::from(requested_x) - left, + i64::from(requested_y) - top, + i64::from(requested_width) + left + right, + i64::from(requested_height) + top + bottom, + ); + if adjusted.2 <= 0 || adjusted.3 <= 0 { + return Err("visible-frame adjustment produced a non-positive outer size".to_owned()); + } + let to_i32 = |value: i64| { + i32::try_from(value) + .map_err(|_| "visible-frame adjustment exceeded Windows coordinate range".to_owned()) + }; + Ok(( + to_i32(adjusted.0)?, + to_i32(adjusted.1)?, + to_i32(adjusted.2)?, + to_i32(adjusted.3)?, + )) +} + pub struct SetWindowFrameTool; static SET_WINDOW_FRAME_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -8706,6 +8741,7 @@ impl Tool for SetWindowFrameTool { let outcome = tokio::task::spawn_blocking(move || { use windows::Win32::{ Foundation::{HWND, RECT}, + Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}, UI::WindowsAndMessaging::{ GetWindowRect, GetWindowThreadProcessId, IsIconic, IsWindow, IsZoomed, SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER, @@ -8761,52 +8797,81 @@ impl Tool for SetWindowFrameTool { input.window_id )); } - let mut before = RECT::default(); - unsafe { GetWindowRect(hwnd, &mut before) } - .map_err(|error| format!("could not read the current window frame: {error}"))?; + let read_frames = |hwnd: HWND| -> Result< + ((i32, i32, i32, i32), (i32, i32, i32, i32)), + String, + > { + let mut outer = RECT::default(); + unsafe { GetWindowRect(hwnd, &mut outer) } + .map_err(|error| format!("could not read the current window frame: {error}"))?; + let outer = ( + outer.left, + outer.top, + outer.right - outer.left, + outer.bottom - outer.top, + ); + let mut visible = RECT::default(); + let visible = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + &mut visible as *mut RECT as *mut _, + std::mem::size_of::() as u32, + ) + } + .ok() + .map(|()| { + ( + visible.left, + visible.top, + visible.right - visible.left, + visible.bottom - visible.top, + ) + }) + .filter(|(_, _, width, height)| *width > 0 && *height > 0) + .unwrap_or(outer); + Ok((outer, visible)) + }; + let (outer_before, before) = read_frames(hwnd)?; + let requested = (x, y, width, height); + let outer_requested = + outer_frame_for_visible_request(requested, outer_before, before)?; let mutation_error = unsafe { SetWindowPos( hwnd, HWND::default(), - x, - y, - width, - height, + outer_requested.0, + outer_requested.1, + outer_requested.2, + outer_requested.3, SWP_NOACTIVATE | SWP_NOZORDER, ) } .err() .map(|error| format!("SetWindowPos failed: {error}")); - let requested = (x, y, width, height); let mut observed = None; for _ in 0..6 { - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) }.is_ok() { - observed = Some(( - rect.left, - rect.top, - rect.right - rect.left, - rect.bottom - rect.top, - )); + if let Ok((_, visible)) = read_frames(hwnd) { + observed = Some(visible); if observed == Some(requested) { break; } } std::thread::sleep(std::time::Duration::from_millis(40)); } - let before = ( - before.left, - before.top, - before.right - before.left, - before.bottom - before.top, - ); let changed = observed.is_some_and(|observed| before != observed); - Ok((requested, observed, changed, mutation_error)) + Ok(( + requested, + outer_requested, + observed, + changed, + mutation_error, + )) }) .await; - let (requested, observed, changed, mutation_error) = match outcome { + let (requested, outer_requested, observed, changed, mutation_error) = match outcome { Ok(Ok(outcome)) => outcome, Ok(Err(error)) => return ToolResult::error(format!("set_window_frame: {error}")), Err(error) => { @@ -8824,15 +8889,15 @@ impl Tool for SetWindowFrameTool { ) .actual_delivery(ActualDelivery::NotApplicable) .detail(format!( - "requested={requested:?} observed={observed:?} mutation_error={mutation_error:?}" + "requested_visible={requested:?} requested_outer={outer_requested:?} observed_visible={observed:?} mutation_error={mutation_error:?}" )); if observed.is_some() { record = record.evidence(ActionEvidence { kind: EvidenceKind::ValueReadback, detail: if confirmed { - "GetWindowRect matched the requested frame".into() + "DWM visible-frame bounds matched the requested frame".into() } else { - "GetWindowRect returned a frame that did not match the request".into() + "DWM visible-frame bounds did not match the requested frame".into() }, }); } @@ -10038,6 +10103,37 @@ mod click_button_schema_tests { } } +#[cfg(test)] +mod set_window_frame_geometry_tests { + use super::outer_frame_for_visible_request; + + #[test] + fn compensates_for_invisible_resize_borders() { + assert_eq!( + outer_frame_for_visible_request( + (65, 52, 872, 626), + (40, 40, 886, 633), + (47, 40, 872, 626), + ) + .unwrap(), + (58, 52, 886, 633) + ); + } + + #[test] + fn leaves_outer_request_unchanged_when_dwm_bounds_are_unavailable() { + assert_eq!( + outer_frame_for_visible_request( + (-25, 10, 640, 480), + (20, 30, 800, 600), + (20, 30, 800, 600), + ) + .unwrap(), + (-25, 10, 640, 480) + ); + } +} + #[cfg(test)] mod desktop_scope_tests { use super::{is_windowless_desktop_action, GetDesktopStateTool}; From 0393270a7deca297ab0f3dd753fd7fb3f0bbd54b Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 05:29:54 -0500 Subject: [PATCH 12/18] test(cua-driver): probe Windows daemon without consuming pipe --- .../rust/crates/cua-driver-testkit/src/mcp.rs | 22 ++++++++++++------- .../computer_history_cross_platform_test.rs | 15 +------------ 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs index dce599e2da..a5ed7260c2 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs @@ -443,14 +443,20 @@ fn daemon_socket_is_reachable(socket: &str) -> bool { #[cfg(windows)] fn daemon_socket_is_reachable(socket: &str) -> bool { - use std::os::windows::fs::OpenOptionsExt; - - std::fs::OpenOptions::new() - .read(true) - .write(true) - .share_mode(0x0000_0001 | 0x0000_0002) - .open(socket) - .is_ok() + use std::os::windows::ffi::OsStrExt; + + #[link(name = "kernel32")] + extern "system" { + fn WaitNamedPipeW(lp_named_pipe_name: *const u16, timeout_ms: u32) -> i32; + } + + let wide: Vec = std::ffi::OsStr::new(socket) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + // NMPWAIT_NOWAIT == 1. Unlike opening the path, this does not consume the + // daemon's available named-pipe instance before the MCP proxy connects. + unsafe { WaitNamedPipeW(wide.as_ptr(), 1) != 0 } } impl Drop for McpDriver { diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs index 3ae899d0e0..e5b4187ab5 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -404,21 +404,8 @@ fn start_daemon() -> DaemonChild { } } -#[cfg(unix)] fn daemon_is_reachable() -> bool { - std::os::unix::net::UnixStream::connect(daemon_socket()).is_ok() -} - -#[cfg(windows)] -fn daemon_is_reachable() -> bool { - use std::os::windows::fs::OpenOptionsExt; - - fs::OpenOptions::new() - .read(true) - .write(true) - .share_mode(0x0000_0001 | 0x0000_0002) - .open(daemon_socket()) - .is_ok() + cua_driver_core::daemon::is_daemon_listening(&daemon_socket()) } fn key_references() -> Vec { From f262cdb4e3bc602f85cb53b9eaf36fb262aea6e0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 05:39:17 -0500 Subject: [PATCH 13/18] fix(cua-driver): exclude ambiguous Wayland titles from history --- .../rust/crates/platform-linux/src/history.rs | 69 +++++++++++++++---- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/history.rs b/libs/cua-driver/rust/crates/platform-linux/src/history.rs index d9c208dbd5..993ca73bfe 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/history.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/history.rs @@ -136,6 +136,34 @@ fn map_keyring_error(error: KeyringError) -> HistoryError { #[derive(Default)] pub struct LinuxApplicationIdentityProvider; +#[derive(Clone, Copy)] +enum WindowAppNameProvenance { + StableAppId, + Ambiguous, +} + +fn stable_window_app_name( + app_name: Option, + provenance: WindowAppNameProvenance, +) -> Option { + match provenance { + WindowAppNameProvenance::StableAppId => app_name.filter(|name| !name.trim().is_empty()), + WindowAppNameProvenance::Ambiguous => None, + } +} + +fn application_identity_from_stable_sources( + process_name: String, + platform_id: Option, +) -> Option { + let process_name = (!process_name.trim().is_empty()).then_some(process_name); + let bundle_id = platform_id.or_else(|| process_name.clone()); + (bundle_id.is_some() || process_name.is_some()).then_some(ApplicationIdentity { + bundle_id, + display_name: process_name, + }) +} + impl ApplicationIdentityProvider for LinuxApplicationIdentityProvider { fn resolve(&self, pid: i64) -> Option { let pid = u32::try_from(pid).ok()?; @@ -143,20 +171,21 @@ impl ApplicationIdentityProvider for LinuxApplicationIdentityProvider { .into_iter() .find(|process| process.pid == pid)?; let platform_id = if crate::wayland::is_wayland() { - crate::wayland::list_windows_dispatch(Some(pid)) - .into_iter() - .find_map(|window| (!window.app_name.trim().is_empty()).then_some(window.app_name)) + // Wayland's shared WindowInfo cannot distinguish a native app ID + // from the shell-helper fallback that copies the window title. + // Do not enumerate or persist that ambiguous value in history. + stable_window_app_name(None, WindowAppNameProvenance::Ambiguous) } else { - crate::x11::list_windows(Some(pid)) - .into_iter() - .find_map(|window| (!window.app_name.trim().is_empty()).then_some(window.app_name)) - } - .or_else(|| (!process.name.trim().is_empty()).then_some(process.name.clone())); - let display_name = (!process.name.trim().is_empty()).then_some(process.name); - (platform_id.is_some() || display_name.is_some()).then_some(ApplicationIdentity { - bundle_id: platform_id, - display_name, - }) + stable_window_app_name( + crate::x11::list_windows(Some(pid)) + .into_iter() + .find_map(|window| { + (!window.app_name.trim().is_empty()).then_some(window.app_name) + }), + WindowAppNameProvenance::StableAppId, + ) + }; + application_identity_from_stable_sources(process.name, platform_id) } } @@ -193,6 +222,20 @@ mod tests { assert!(identity.bundle_id.is_some() || identity.display_name.is_some()); } + #[test] + fn wayland_title_fallback_is_not_stored_as_application_identity() { + let title = "Confidential roadmap - Web Browser".to_owned(); + let platform_id = + stable_window_app_name(Some(title.clone()), WindowAppNameProvenance::Ambiguous); + let identity = application_identity_from_stable_sources("browser".to_owned(), platform_id) + .expect("process-derived identity"); + + assert_eq!(identity.bundle_id.as_deref(), Some("browser")); + assert_eq!(identity.display_name.as_deref(), Some("browser")); + assert_ne!(identity.bundle_id.as_deref(), Some(title.as_str())); + assert_ne!(identity.display_name.as_deref(), Some(title.as_str())); + } + #[test] fn inaccessible_store_maps_to_locked_without_fallback() { let error = KeyringError::NoStorageAccess(Box::new(std::io::Error::new( From c9ee6ed48706f5261e2b9f2acd3d6a54a920186c Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 13:59:30 -0500 Subject: [PATCH 14/18] fix(cua-driver): align cross-platform history relaunch authorization --- libs/cua-driver/rust/crates/cua-driver/src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index 82e8b1cbbe..9022e52c8a 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -935,7 +935,6 @@ fn main() -> anyhow::Result<()> { history_runtime::configure_daemon_launch_state( permission_mode.as_deref(), dangerously_bypass_approvals, - allow_legacy_existing_profile_approval, capability_manifest.as_deref(), approve_capability_manifest, no_permissions_gate, From 8dc939b49b721f1cc50248fcb7a2b4e694b47a83 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 17:35:34 -0500 Subject: [PATCH 15/18] fix(cua-driver): make X11 frame requests decoration-stable --- .../rust/crates/platform-linux/src/x11/mod.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/x11/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/x11/mod.rs index e276f8a443..11f3b83588 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/x11/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/x11/mod.rs @@ -183,7 +183,7 @@ pub fn set_window_frame( } let atom = get_atom(&conn, "_NET_MOVERESIZE_WINDOW")?; - let fields = (1_u32 << 8) | (1 << 9) | (1 << 10) | (1 << 11); + let fields = moveresize_window_flags(); let source_indication = 1_u32 << 12; // normal application (EWMH §4.1.5) let event = ClientMessageEvent::new( 32, @@ -228,6 +228,19 @@ pub fn set_window_frame( Ok((observed, confirmed, mutation_error)) } +/// EWMH §4.2 requires an explicit StaticGravity when a pager-style client +/// wants the requested geometry to include server-side window decorations. +/// A zero gravity delegates to WM_NORMAL_HINTS and makes the same request land +/// at different client offsets under window managers such as Xfwm. +fn moveresize_window_flags() -> u32 { + const STATIC_GRAVITY: u32 = 10; + const X_PRESENT: u32 = 1 << 8; + const Y_PRESENT: u32 = 1 << 9; + const WIDTH_PRESENT: u32 = 1 << 10; + const HEIGHT_PRESENT: u32 = 1 << 11; + STATIC_GRAVITY | X_PRESENT | Y_PRESENT | WIDTH_PRESENT | HEIGHT_PRESENT +} + fn get_window_pid(conn: &RustConnection, window: Window) -> Result> { let atom = get_atom(conn, "_NET_WM_PID")?; let reply = conn @@ -335,4 +348,12 @@ mod tests { assert_eq!(indices, vec![0, 1, 2]); assert!(indices[2] > indices[0]); } + + #[test] + fn moveresize_requests_static_gravity_and_all_frame_fields() { + let flags = moveresize_window_flags(); + assert_eq!(flags & 0xff, 10); + assert_eq!(flags & 0x0f00, 0x0f00); + assert_eq!(flags & !0x0fff, 0); + } } From 2b2d6dcf6406d1cc6fc87a1150be38f42de111b5 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 19:12:41 -0500 Subject: [PATCH 16/18] ci(cua-driver): provision Wayland history key store --- .github/workflows/e2e-rust-linux-wayland.yml | 2 +- scripts/ci/linux/run-rust-e2e-wayland.sh | 32 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-rust-linux-wayland.yml b/.github/workflows/e2e-rust-linux-wayland.yml index a80f78accc..0fd94fa537 100644 --- a/.github/workflows/e2e-rust-linux-wayland.yml +++ b/.github/workflows/e2e-rust-linux-wayland.yml @@ -92,7 +92,7 @@ jobs: libwayland-dev libxkbcommon-dev libx11-dev libxi-dev libxtst-dev \ libxext-dev libdrm-dev libgbm-dev libwebkit2gtk-4.1-dev \ libssl-dev libxdo-dev libayatana-appindicator3-dev librsvg2-dev \ - ffmpeg jq + ffmpeg jq gnome-keyring - name: Install Nix for the custom compositor environment if: inputs.environment == 'cua-compositor' uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30 diff --git a/scripts/ci/linux/run-rust-e2e-wayland.sh b/scripts/ci/linux/run-rust-e2e-wayland.sh index ee44b6e9ed..e0e15a5499 100755 --- a/scripts/ci/linux/run-rust-e2e-wayland.sh +++ b/scripts/ci/linux/run-rust-e2e-wayland.sh @@ -93,6 +93,38 @@ if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then DBUS_PID="$(sed -n '2p' <<< "${dbus_info}")" fi +# The shared lane exercises the encrypted Computer History lifecycle. Give its +# Linux key provider the same unlocked Secret Service that the canonical X11 +# lane provisions, but on this lane's private disposable session bus. +if [[ "${CUA_E2E_INTERNAL_LANE:-all}" == "shared" \ + || "${CUA_E2E_INTERNAL_LANE:-all}" == "all" ]]; then + if ! command -v gnome-keyring-daemon >/dev/null 2>&1; then + echo "gnome-keyring-daemon is required for the shared Wayland history gate" >&2 + exit 1 + fi + keyring_environment="$( + printf 'test-password' | gnome-keyring-daemon --unlock --components=secrets + )" + eval "${keyring_environment}" + deadline=$((SECONDS + 15)) + while ((SECONDS < deadline)); do + if gdbus call --session \ + --dest org.freedesktop.secrets \ + --object-path /org/freedesktop/secrets \ + --method org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1; then + break + fi + sleep 0.2 + done + if ! gdbus call --session \ + --dest org.freedesktop.secrets \ + --object-path /org/freedesktop/secrets \ + --method org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1; then + echo "Secret Service did not become ready on the private Wayland session bus" >&2 + exit 1 + fi +fi + # A private session bus does not activate the desktop accessibility stack by # itself. Start the repo-pinned AT-SPI launcher, then require org.a11y.Bus to # answer before any fixture or driver process inherits this session. From 83b250decb8770a009df7f77ee2362dcd1209d7c Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 19:48:17 -0500 Subject: [PATCH 17/18] test(cua-driver): use supported Wayland history action --- .../computer_history_cross_platform_test.rs | 172 ++++++++++++++---- 1 file changed, 141 insertions(+), 31 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs index e5b4187ab5..bc1cc07943 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -17,6 +17,7 @@ use std::{ }; use cua_driver_core::history::KeyProvider; +use cua_driver_testkit::ax::{element_index_by_id, element_index_containing}; use cua_driver_testkit::e2e::{ recording_evidence, write_declaration_from_env, write_result_from_env, CaseResult, CaseSpec, Delivery, DriverRoute, Observation, OracleKind, Scope, Targeting, TestStatus, @@ -247,6 +248,39 @@ fn wait_for_window_position( } } +fn wait_for_window_text( + driver: &mut McpDriver, + pid: u32, + window_id: u64, + expected: &str, +) -> cua_driver_testkit::ToolResponse { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let state = driver.call( + "get_window_state", + json!({ + "pid": pid, + "window_id": window_id, + "capture_mode": "ax" + }), + ); + assert!( + !state.is_error(), + "independent accessibility readback failed: {}", + state.text() + ); + if state.tree_text().contains(expected) { + return state; + } + assert!( + Instant::now() < deadline, + "window state did not contain {expected:?}: {}", + state.tree_text() + ); + sleep(Duration::from_millis(50)); + } +} + fn assert_no_private_fields(value: &Value) { const FORBIDDEN_KEYS: &[&str] = &[ "screenshot", @@ -427,16 +461,29 @@ fn key_references() -> Vec { #[ignore = "requires an installed local product, native credential store, GUI fixture, and daemon"] fn encrypted_history_survives_restart_and_cryptographically_purges() { let started_at = Instant::now(); + let pure_wayland = cfg!(target_os = "linux") + && std::env::var_os("WAYLAND_DISPLAY").is_some() + && std::env::var_os("DISPLAY").is_none(); + let (case_action, case_route) = if pure_wayland { + ("left_click", DriverRoute::LinuxAtSpiAction) + } else { + ("computer_history_continuity", DriverRoute::WindowState) + }; + let case_oracles = if pure_wayland { + vec![OracleKind::AxState, OracleKind::Protocol] + } else { + vec![OracleKind::Protocol] + }; let case = CaseSpec::delivered( format!("{}-computer-history-continuity", std::env::consts::OS), "electron", "electron", - "computer_history_continuity", + case_action, Targeting::Ax, Delivery::Foreground, Scope::Window, - DriverRoute::WindowState, - vec![OracleKind::Protocol], + case_route, + case_oracles.clone(), ); write_declaration_from_env(&case).expect("write history E2E declaration"); @@ -490,28 +537,88 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { ); let (pid, window_id, bounds) = launch_fixture(&mut driver); driver.start_behavior_recording(); - let requested_x = bounds["x"].as_f64().expect("fixture x") + 18.0; - let requested_y = bounds["y"].as_f64().expect("fixture y") + 12.0; - let moved = driver.call( - "set_window_frame", - json!({ - "pid": pid, - "window_id": window_id, - "x": requested_x, - "y": requested_y, - "width": bounds["width"], - "height": bounds["height"], - "session": RAW_SESSION - }), - ); - assert!( - !moved.is_error(), - "set_window_frame failed: {}", - moved.text() - ); - assert_eq!(moved.action_effect(), Some("confirmed")); - let _settled_bounds = - wait_for_window_position(&mut driver, pid, window_id, requested_x, requested_y); + let (capability, expected_effect, expected_route) = if pure_wayland { + let snapshot = driver.call( + "get_window_state", + json!({ + "pid": pid, + "window_id": window_id, + "capture_mode": "ax" + }), + ); + assert!( + !snapshot.is_error(), + "Wayland history snapshot failed: {}", + snapshot.text() + ); + assert!( + snapshot.tree_text().contains("counter=0"), + "Wayland fixture did not expose its initial counter state: {}", + snapshot.tree_text() + ); + let element_index = element_index_by_id(snapshot.tree_text(), "btn-increment") + .or_else(|| element_index_containing(snapshot.tree_text(), "btn-increment")) + .or_else(|| element_index_containing(snapshot.tree_text(), "Increment")) + .expect("Wayland fixture exposed no accessible increment button"); + let clicked = driver.call( + "click", + json!({ + "pid": pid, + "window_id": window_id, + "element_index": element_index, + "snapshot_id": snapshot.snapshot_id(), + "delivery_mode": "foreground", + "session": RAW_SESSION + }), + ); + assert!( + !clicked.is_error(), + "Wayland accessibility click failed: {}", + clicked.text() + ); + let effect = clicked + .action_effect() + .expect("Wayland click emitted no action effect"); + assert!( + matches!(effect, "confirmed" | "unverifiable"), + "Wayland click did not produce a delivered effect: {}", + clicked.raw + ); + let _settled_state = wait_for_window_text(&mut driver, pid, window_id, "counter=1"); + ( + "input.pointer.click.left".to_owned(), + effect.to_owned(), + "accessibility", + ) + } else { + let requested_x = bounds["x"].as_f64().expect("fixture x") + 18.0; + let requested_y = bounds["y"].as_f64().expect("fixture y") + 12.0; + let moved = driver.call( + "set_window_frame", + json!({ + "pid": pid, + "window_id": window_id, + "x": requested_x, + "y": requested_y, + "width": bounds["width"], + "height": bounds["height"], + "session": RAW_SESSION + }), + ); + assert!( + !moved.is_error(), + "set_window_frame failed: {}", + moved.text() + ); + assert_eq!(moved.action_effect(), Some("confirmed")); + let _settled_bounds = + wait_for_window_position(&mut driver, pid, window_id, requested_x, requested_y); + ( + "window.frame.set".to_owned(), + "confirmed".to_owned(), + "system_api", + ) + }; let ended = driver.call("end_session", json!({"session": RAW_SESSION})); assert!(!ended.is_error(), "end_session failed: {}", ended.text()); assert_ready(&history_cli("flush", &[])); @@ -523,11 +630,12 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { .into_iter() .flatten() .find(|event| { - event["data"]["capability"] == "window.frame.set" + event["data"]["capability"].as_str() == Some(capability.as_str()) && event["data"]["payload"]["kind"] == "action_completed" - && event["data"]["payload"]["effect"] == "confirmed" + && event["data"]["payload"]["effect"].as_str() == Some(expected_effect.as_str()) + && event["data"]["payload"]["route"] == expected_route }) - .expect("history did not contain the confirmed window-frame action"); + .expect("history did not contain the delivered platform action"); let action_id = completion["data"]["action_id"] .as_str() .expect("history action has no opaque id") @@ -551,7 +659,7 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { ); for path in ciphertext { let bytes = fs::read(&path).expect("read encrypted history chunk"); - for forbidden in [RAW_SESSION, "window.frame.set", "action_completed"] { + for forbidden in [RAW_SESSION, capability.as_str(), "action_completed"] { assert!( !bytes .windows(forbidden.len()) @@ -577,8 +685,10 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { .flatten() .any(|event| { event["data"]["action_id"].as_str() == Some(&action_id) - && event["data"]["capability"] == "window.frame.set" + && event["data"]["capability"].as_str() == Some(capability.as_str()) && event["data"]["payload"]["kind"] == "action_completed" + && event["data"]["payload"]["effect"].as_str() == Some(expected_effect.as_str()) + && event["data"]["payload"]["route"] == expected_route }), "restarted daemon could not hydrate the recorded action" ); @@ -608,7 +718,7 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { ); drop(restarted); - let observation = Observation::delivered(vec![OracleKind::Protocol], evidence); + let observation = Observation::delivered(case_oracles, evidence); let result = CaseResult::evaluate(case, observation, started_at.elapsed()); write_result_from_env(&result).expect("write history E2E result"); assert_eq!(result.test_status, TestStatus::Pass, "{}", result.message); From 48df01bbb617181bb695db998cbe3bb7b0a5095c Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 15 Aug 2026 20:16:43 -0500 Subject: [PATCH 18/18] test(cua-driver): match primary click history capability --- .../tests/computer_history_cross_platform_test.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs index bc1cc07943..7b73b822cc 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_cross_platform_test.rs @@ -585,11 +585,15 @@ fn encrypted_history_survives_restart_and_cryptographically_purges() { clicked.raw ); let _settled_state = wait_for_window_text(&mut driver, pid, window_id, "counter=1"); - ( - "input.pointer.click.left".to_owned(), - effect.to_owned(), - "accessibility", - ) + let capability = cua_driver_core::tool::default_capabilities_for("click") + .into_iter() + .next() + .expect("click has no primary capability"); + assert_eq!( + capability, "input.pointer.click", + "history must use click's primary closed-contract capability" + ); + (capability, effect.to_owned(), "accessibility") } else { let requested_x = bounds["x"].as_f64().expect("fixture x") + 18.0; let requested_y = bounds["y"].as_f64().expect("fixture y") + 12.0;