diff --git a/.gitattributes b/.gitattributes index 68b533816..9b436a6b5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Keep text checkouts stable across developer and CI operating systems. * text=auto eol=lf + +# Unified patches use a single space for an empty context line. +native/browser/component/patches/macos-owned-process/chromium.patch whitespace=-blank-at-eol diff --git a/.github/workflows/premerge.yml b/.github/workflows/premerge.yml index 75b3ccf6b..62ad14561 100644 --- a/.github/workflows/premerge.yml +++ b/.github/workflows/premerge.yml @@ -638,6 +638,25 @@ jobs: continue-on-error: true working-directory: apps/desktop run: npm run test:approval-runtime + - name: Build pinned CEF and accept the supervised owned Windows browser + id: owned_chromium + continue-on-error: true + timeout-minutes: 45 + shell: pwsh + run: ./scripts/ci/browser-windows-native.ps1 + - name: Retain bounded owned Windows browser evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-owned-chromium-source-acceptance + path: | + .local/windows-owned-browser-evidence/native-factory.json + .local/windows-owned-browser-evidence/native-factory.log + .local/windows-owned-browser-evidence/browser-component.json + .local/windows-owned-browser-evidence/*startup*.log + .local/windows-owned-browser-evidence/core-death-*.log + if-no-files-found: warn + retention-days: 7 - name: Retain Windows plugin acceptance evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -660,6 +679,7 @@ jobs: PLUGIN_RUNTIME_OUTCOME: ${{ steps.plugin_runtime.outcome }} APPROVAL_RUNTIME_OUTCOME: ${{ steps.approval_runtime.outcome }} CLOUD_NATIVE_OUTCOME: ${{ steps.cloud_native.outcome }} + OWNED_CHROMIUM_OUTCOME: ${{ steps.owned_chromium.outcome }} run: | set -eu failed= @@ -669,7 +689,8 @@ jobs: "desktop-tests=$NATIVE_TEST_OUTCOME" \ "desktop-plugin-runtime=$PLUGIN_RUNTIME_OUTCOME" \ "desktop-approval-runtime=$APPROVAL_RUNTIME_OUTCOME" \ - "desktop-cloud-native=$CLOUD_NATIVE_OUTCOME" + "desktop-cloud-native=$CLOUD_NATIVE_OUTCOME" \ + "owned-chromium=$OWNED_CHROMIUM_OUTCOME" do if test "${result#*=}" != success; then failed="$failed ${result%%=*}" diff --git a/Cargo.lock b/Cargo.lock index 4a4743cfc..7dabe9bb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1105,6 +1105,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "colossus-contracts", + "colossus-media", "colossus-ports", "colossus-testkit", "futures", @@ -1116,6 +1117,7 @@ dependencies = [ "time", "tokio", "url", + "zeroize", ] [[package]] @@ -1201,6 +1203,54 @@ dependencies = [ "uuid", ] +[[package]] +name = "colossus-browser" +version = "0.11.8" +dependencies = [ + "async-trait", + "base64 0.22.1", + "colossus-contracts", + "colossus-ports", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "uuid", + "zeroize", +] + +[[package]] +name = "colossus-browser-bridge" +version = "0.11.8" +dependencies = [ + "async-trait", + "base64 0.22.1", + "colossus-contracts", + "colossus-ports", + "getrandom 0.4.3", + "hmac 0.12.1", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "zeroize", +] + +[[package]] +name = "colossus-browser-presentation" +version = "0.11.8" +dependencies = [ + "async-trait", + "colossus-contracts", + "getrandom 0.4.3", + "hmac 0.12.1", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "zeroize", +] + [[package]] name = "colossus-bundled-plugins" version = "0.11.8" @@ -1503,6 +1553,7 @@ dependencies = [ "colossus-domain", "serde", "serde_json", + "url", "zeroize", ] @@ -1536,7 +1587,11 @@ dependencies = [ name = "colossus-darwin-process" version = "0.11.8" dependencies = [ + "core-foundation", "libc", + "security-framework", + "sha2 0.10.9", + "tempfile", ] [[package]] @@ -1692,6 +1747,17 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "colossus-mac-browser-process-broker" +version = "0.1.0" +dependencies = [ + "colossus-darwin-process", + "rustix 1.1.4", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "colossus-mcp" version = "0.11.8" @@ -1787,6 +1853,38 @@ dependencies = [ "url", ] +[[package]] +name = "colossus-native-browser-pki" +version = "0.1.0" +dependencies = [ + "rustls-pki-types", + "sha2 0.10.9", + "x509-parser", +] + +[[package]] +name = "colossus-native-browser-tls-broker" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "colossus-native-browser-pki", + "getrandom 0.4.3", + "hex", + "hmac 0.12.1", + "rcgen", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "socket2", + "subtle", + "time", + "tokio", + "tokio-rustls", + "url", + "zeroize", +] + [[package]] name = "colossus-native-dictation" version = "0.1.0" @@ -1909,6 +2007,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "zeroize", ] [[package]] @@ -2000,6 +2099,9 @@ dependencies = [ "colossus-access", "colossus-agent", "colossus-audit", + "colossus-browser", + "colossus-browser-bridge", + "colossus-browser-presentation", "colossus-bundled-plugins", "colossus-bundles", "colossus-communication", @@ -2033,21 +2135,26 @@ dependencies = [ "colossus-work", "colossus-worker-protocol", "colossus-workflow", + "ed25519-dalek", "fs4", "futures", "getrandom 0.4.3", "hex", "ignore", + "image", "opentelemetry", "opentelemetry_sdk", "rustix 1.1.4", + "rustls", "serde", "serde-saphyr", "serde_json", "sha2 0.10.9", "tempfile", "thiserror 2.0.18", + "time", "tokio", + "tokio-rustls", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2063,19 +2170,28 @@ dependencies = [ "async-trait", "base64 0.22.1", "capctl", + "colossus-browser-bridge", + "colossus-browser-presentation", "colossus-contracts", + "colossus-darwin-process", "colossus-home", + "colossus-mac-browser-process-broker", + "colossus-native-browser-pki", + "colossus-native-browser-tls-broker", "colossus-network", "colossus-policy", "colossus-ports", "colossus-testkit", + "colossus-windows-native", "colossus-windows-process", "command-group", "futures", + "getrandom 0.4.3", "globset", "hex", "hmac 0.12.1", "ignore", + "libc", "nix 0.31.3", "nono", "rappct", @@ -2095,6 +2211,7 @@ dependencies = [ "tokio", "tokio-rustls", "uuid", + "zeroize", ] [[package]] @@ -2106,6 +2223,7 @@ dependencies = [ "colossus-api", "colossus-api-proto", "colossus-api-runtime", + "colossus-browser-presentation", "colossus-contracts", "colossus-darwin-process", "colossus-grpc", @@ -2174,6 +2292,9 @@ version = "0.11.8" dependencies = [ "colossus-access", "colossus-api", + "colossus-browser-presentation", + "colossus-bundles", + "colossus-contracts", "colossus-grpc", "colossus-home", "colossus-mcp", @@ -2189,8 +2310,11 @@ dependencies = [ "colossus-worker", "colossus-worker-protocol", "getrandom 0.4.3", + "hex", "keyring", + "libc", "rustix 1.1.4", + "serde", "serde-saphyr", "serde_json", "sha2 0.10.9", @@ -2307,8 +2431,12 @@ dependencies = [ name = "colossus-windows-process" version = "0.11.8" dependencies = [ + "colossus-windows-native", + "tempfile", "thiserror 2.0.18", + "tokio", "windows-sys 0.61.2", + "zeroize", ] [[package]] @@ -2334,6 +2462,7 @@ dependencies = [ "colossus-api", "colossus-api-proto", "colossus-api-runtime", + "colossus-browser-presentation", "colossus-contracts", "colossus-grpc", "colossus-plugins", @@ -5822,9 +5951,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", diff --git a/Cargo.toml b/Cargo.toml index d45229053..8ed402123 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] resolver = "3" +exclude = ["native/browser/driver", "native/browser/pki", "native/browser/process-broker", "native/browser/tls-broker"] members = [ "crates/colossus-cloud", "crates/colossus-cloud-postgres", @@ -10,6 +11,9 @@ members = [ "xtask", "crates/colossus-domain", "crates/colossus-contracts", + "crates/colossus-browser", + "crates/colossus-browser-bridge", + "crates/colossus-browser-presentation", "crates/colossus-credentials", "crates/colossus-api-proto", "crates/colossus-api", @@ -77,6 +81,9 @@ repository = "https://github.com/obscuritylabs/Colossus" publish-sdks = false [workspace.dependencies] +colossus-browser = { version = "=0.11.8", path = "crates/colossus-browser" } +colossus-browser-bridge = { version = "=0.11.8", path = "crates/colossus-browser-bridge" } +colossus-browser-presentation = { version = "=0.11.8", path = "crates/colossus-browser-presentation" } colossus-communication = { version = "=0.11.8", path = "crates/colossus-communication" } chrono = "0.4.44" openidconnect = { version = "4.0.1", default-features = false, features = ["reqwest", "rustls-tls"] } @@ -275,6 +282,12 @@ strip = "symbols" [profile.test] strip = "debuginfo" +# Retina frames cross independently authenticated native boundaries. Unoptimized +# SHA-256 consumed about 245ms per 6.9MiB frame on the Mac acceptance machine; +# keep full authentication within the unchanged visibility/control deadlines. +[profile.dev.package.sha2] +opt-level = 3 + # Runnable VSIX binaries keep assertions without producing large MSVC symbol files. [profile.vscode] inherits = "dev" diff --git a/apps/desktop/native-browser/Cargo.toml b/apps/desktop/native-browser/Cargo.toml index 7d8ab4b28..c941b2b92 100644 --- a/apps/desktop/native-browser/Cargo.toml +++ b/apps/desktop/native-browser/Cargo.toml @@ -8,10 +8,19 @@ license = "Apache-2.0" workspace = "../src-tauri" [features] +cef-preview = ["dep:serde_json", "dep:sha2", "dep:rustls-pki-types", "dep:x509-parser", "dep:zeroize", "dep:colossus-native-browser-pki", "dep:colossus-browser-presentation", "dep:async-trait"] native-test-driver = ["objc2-foundation/NSData", "objc2-foundation/NSJSONSerialization"] [dependencies] +async-trait = { version = "0.1.89", optional = true } +colossus-native-browser-pki = { version = "=0.1.0", path = "../../../native/browser/pki", optional = true } +colossus-browser-presentation = { version = "=0.11.8", path = "../../../crates/colossus-browser-presentation", optional = true } serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.149", optional = true } +sha2 = { version = "0.10.9", optional = true } +rustls-pki-types = { version = "1.14.0", optional = true } +x509-parser = { version = "0.18.1", optional = true } +zeroize = { version = "1.8.2", optional = true } tauri = { version = "=2.11.5", features = ["unstable"] } tokio = { version = "1.49.0", features = ["sync", "time"] } url = "2.5.8" @@ -19,6 +28,7 @@ url = "2.5.8" [target.'cfg(windows)'.dependencies] webview2-com = "=0.38.2" windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Security_Cryptography", "Win32_Foundation"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" @@ -30,3 +40,6 @@ objc2-web-kit = { version = "0.3.2", features = ["WKNavigation", "WKNavigationAc [lints.clippy] all = "warn" pedantic = "warn" + +[dev-dependencies] +rcgen = { version = "0.14.8", default-features = false, features = ["crypto", "pem", "ring", "zeroize"] } diff --git a/apps/desktop/native-browser/build.rs b/apps/desktop/native-browser/build.rs new file mode 100644 index 000000000..05503fa78 --- /dev/null +++ b/apps/desktop/native-browser/build.rs @@ -0,0 +1,112 @@ +//! Optional native shim linkage. Ordinary Desktop builds never download CEF. + +use std::{env, path::PathBuf}; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(colossus_cef_linked)"); + println!("cargo:rerun-if-env-changed=COLOSSUS_CEF_NATIVE_LIB_DIR"); + println!("cargo:rerun-if-env-changed=COLOSSUS_CEF_ROOT"); + if env::var_os("CARGO_FEATURE_CEF_PREVIEW").is_none() { + return; + } + let Some(directory) = env::var_os("COLOSSUS_CEF_NATIVE_LIB_DIR") else { + return; + }; + // This opt-in is a developer feasibility lane. Release composition cannot + // enable an unverified component by merely choosing a library directory. + assert_eq!( + env::var("PROFILE").as_deref(), + Ok("debug"), + "CEF preview linkage is limited to native development builds" + ); + let directory = PathBuf::from(directory) + .canonicalize() + .expect("native CEF shim directory must exist"); + assert!( + directory.is_dir(), + "native CEF shim directory must be a directory" + ); + let (shim_name, wrapper_name) = if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + ("colossus_cef.lib", "libcef_dll_wrapper.lib") + } else { + ("libcolossus_cef.a", "libcef_dll_wrapper.a") + }; + // CMake runs outside Cargo. Watch the archives that the linker consumes so + // a rebuilt native shim cannot leave a stale browser host in the executable. + // Installed stages place both archives together; build trees keep the CEF + // wrapper in its own target directory. + let shim = directory.join(shim_name); + let installed_wrapper = directory.join(wrapper_name); + let wrapper = if installed_wrapper.is_file() { + installed_wrapper + } else if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + directory + .parent() + .expect("native Release directory needs a build root") + .join("libcef_dll_wrapper/Release") + .join(wrapper_name) + } else { + directory.join("libcef_dll_wrapper").join(wrapper_name) + }; + let presenter = directory.join( + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + "colossus_browser_presenter.lib" + } else { + "libcolossus_browser_presenter.a" + }, + ); + for archive in [&shim, &wrapper, &presenter] { + println!("cargo:rerun-if-changed={}", archive.display()); + } + println!("cargo:rustc-link-search=native={}", directory.display()); + let cef = PathBuf::from( + env::var_os("COLOSSUS_CEF_ROOT") + .expect("native CEF linkage requires the pinned distribution root"), + ); + assert!( + cef.join("Release").is_dir(), + "native CEF release libraries are absent" + ); + println!( + "cargo:rustc-link-search=native={}", + wrapper.parent().unwrap().display() + ); + println!( + "cargo:rustc-link-search=native={}", + cef.join("Release").display() + ); + println!("cargo:rustc-link-lib=static=colossus_cef"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rustc-link-lib=static=libcef_dll_wrapper"); + } else { + println!("cargo:rustc-link-lib=static=cef_dll_wrapper"); + } + match env::var("CARGO_CFG_TARGET_OS").as_deref() { + Ok("macos") => { + println!("cargo:rustc-link-lib=static=colossus_browser_presenter"); + // CEF's supported scoped loader loads the framework at entry. Do + // not directly link the CEF framework before sandbox helper setup. + for framework in ["Cocoa", "IOSurface"] { + println!("cargo:rustc-link-lib=framework={framework}"); + } + println!("cargo:rustc-link-lib=c++"); + } + Ok("windows") => { + println!("cargo:rustc-link-lib=static=colossus_browser_presenter"); + println!("cargo:rustc-link-lib=libcef"); + for library in [ + "delayimp", "comctl32", "crypt32", "dwmapi", "gdi32", "imm32", "rpcrt4", "shlwapi", + "wintrust", "ws2_32", + ] { + println!("cargo:rustc-link-lib={library}"); + } + } + Ok("linux") => { + for library in ["cef", "stdc++", "dl", "pthread", "rt"] { + println!("cargo:rustc-link-lib={library}"); + } + } + _ => panic!("native CEF linkage target is unsupported"), + } + println!("cargo:rustc-cfg=colossus_cef_linked"); +} diff --git a/apps/desktop/native-browser/src/chromium.rs b/apps/desktop/native-browser/src/chromium.rs new file mode 100644 index 000000000..f4fb0543e --- /dev/null +++ b/apps/desktop/native-browser/src/chromium.rs @@ -0,0 +1,245 @@ +//! Direct CEF guest presentation behind an explicit native feasibility gate. +//! +//! CEF application installation occurs before Tauri/AppKit. A missing component +//! never falls back to another engine under a Chromium label. + +#[cfg(colossus_cef_linked)] +mod callbacks; +#[cfg(colossus_cef_linked)] +mod ffi; +#[cfg(colossus_cef_linked)] +mod linked; + +#[cfg(all(windows, colossus_cef_linked))] +pub use linked::run_windows_client; +#[cfg(colossus_cef_linked)] +pub use linked::{Surface, bind_quit_handler, bootstrap, pump, shutdown}; + +/// A Windows preview without native linkage cannot accept a bootstrap entry. +/// +/// # Safety +/// Matches the linked entry's borrowed bootstrap ABI; no pointer is inspected. +#[cfg(all(windows, not(colossus_cef_linked)))] +pub unsafe fn run_windows_client( + _: usize, + _: *mut std::ffi::c_void, + _: *mut std::ffi::c_void, + _: unsafe extern "C" fn() -> i32, +) -> i32 { + 1 +} + +use crate::BrowserError; + +/// Result of the real application-entry bootstrap, before any Tauri UI exists. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Bootstrap { + Ready, + SubprocessExit(i32), + Unavailable(BrowserError), +} + +/// Closed native acceptance evidence for one actual Chromium child view. +/// +/// macOS pixel counts sample a fixed PNG from the CEF browser; Windows counts +/// sample the actual screen compositor at the guest HWND. Native child bounds +/// and visibility are read independently of CDP. Platform-specific fields carry +/// no evidence on other operating systems. +#[cfg(feature = "native-test-driver")] +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +// Independent observed native facts are serialized evidence, not state flags. +#[allow(clippy::struct_excessive_bools)] +pub struct AcceptanceProbe { + pub visible: bool, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub magenta_pixels: u32, + pub green_pixels: u32, + pub pixel_width: u32, + pub pixel_height: u32, + pub bits_per_sample: u32, + pub samples_per_pixel: u32, + pub alpha_first: bool, + pub magenta_sample: Vec, + pub green_sample: Vec, + pub child_width: f64, + pub child_height: f64, + pub child_class: String, + pub parent_layer: bool, + pub view_autoresizes_subviews: bool, + pub child_autoresizing_mask: u32, + pub cef_application: bool, + pub tauri_event_loop: bool, + pub parent_attached: bool, + pub app_active: bool, + pub activation_policy: i32, + pub window_key: bool, + pub window_visible: bool, + pub window_can_become_key: bool, + pub delegate_class: String, + #[serde(default)] + pub windows_owning_thread: bool, + #[serde(default)] + pub os_compositor_capture: bool, +} + +/// Actual availability established by early bootstrap; never a compile-time guess. +/// +/// # Errors +/// Reports missing bootstrap, component, or native platform acceptance. +pub fn readiness() -> Result<(), BrowserError> { + #[cfg(colossus_cef_linked)] + return linked::readiness(); + #[cfg(not(colossus_cef_linked))] + Err(BrowserError::ComponentMissing) +} + +#[cfg(not(colossus_cef_linked))] +/// Missing-component implementation keeps optional preview builds inspectable. +pub struct Surface; + +#[cfg(not(colossus_cef_linked))] +impl Clone for Surface { + fn clone(&self) -> Self { + Self + } +} + +#[cfg(not(colossus_cef_linked))] +#[allow(clippy::unused_async)] // Preserve the same asynchronous linked lifecycle API. +impl Surface { + /// # Errors + /// Chromium is absent in this build. + pub fn pending_client_identity( + &self, + ) -> Result, BrowserError> { + Err(BrowserError::ComponentMissing) + } + + /// An unlinked component owns no pending native identity request. + pub fn revoke_client_identity(&self) {} + + /// An unlinked component owns no native review to dismiss. + pub fn cancel_client_identity(&self, _: &crate::pki::IdentityRequest) {} + + /// # Errors + /// Chromium is absent in this build; no identity can be selected. + pub async fn select_client_identity( + &self, + _: &crate::pki::IdentityRequest, + _: Option<&str>, + ) -> Result<(), BrowserError> { + Err(BrowserError::ComponentMissing) + } + + /// Create the same native guest later controlled by the runtime bridge. + /// + /// # Errors + /// The unlinked build reports the missing verified component explicitly. + pub async fn create( + _: &tauri::Window, + _: &str, + _: u64, + _: std::path::PathBuf, + _: crate::NavigationPolicy, + _: crate::EventSink, + ) -> Result { + Err(BrowserError::ComponentMissing) + } + + /// # Errors + /// Chromium is absent in this build. + pub fn navigate(&self, _: &url::Url) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub fn hide(&self) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub fn show(&self) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub fn set_bounds(&self, _: tauri::Rect) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub async fn close(&self) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub async fn inspect(&self) -> Result { + Err(BrowserError::ComponentMissing) + } + /// # Errors + /// Chromium is absent in this build. + pub async fn control(&self, _: crate::NavigationAction) -> Result<(), BrowserError> { + readiness() + } + + /// # Errors + /// A linked, accepted native Chromium component is required. + #[cfg(feature = "native-test-driver")] + pub async fn acceptance_probe(&self) -> Result { + Err(BrowserError::ComponentMissing) + } + + /// # Errors + /// A linked native Chromium component and owning window are required. + #[cfg(feature = "native-test-driver")] + pub async fn acceptance_activate_parent(&self) -> Result<(), BrowserError> { + Err(BrowserError::ComponentMissing) + } + + /// # Errors + /// A linked native Chromium component and owning window are required. + #[cfg(feature = "native-test-driver")] + pub async fn acceptance_terminate_application(&self) -> Result<(), BrowserError> { + Err(BrowserError::ComponentMissing) + } + + /// # Errors + /// No linked Windows native input driver is available. + #[cfg(all(windows, feature = "native-test-driver"))] + pub async fn acceptance_input(&self) -> Result<(), BrowserError> { + Err(BrowserError::ComponentMissing) + } +} + +#[cfg(not(colossus_cef_linked))] +/// Initialize before the native application is created. +#[must_use] +pub fn bootstrap(_: &std::path::Path, _: &std::path::Path) -> Bootstrap { + Bootstrap::Unavailable(BrowserError::ComponentMissing) +} + +#[cfg(not(colossus_cef_linked))] +/// Pump callback is inert when no component is linked. +pub fn pump() {} + +#[cfg(not(colossus_cef_linked))] +/// Bind the trusted native application's Quit route. +/// +/// # Errors +/// Returns the missing-component error for an unlinked preview build. +pub fn bind_quit_handler(_: impl Fn() + Send + Sync + 'static) -> Result<(), BrowserError> { + Err(BrowserError::ComponentMissing) +} + +#[cfg(not(colossus_cef_linked))] +/// Report absent native teardown evidence for an unlinked preview build. +/// +/// # Errors +/// Returns the missing-component error because no Chromium host initialized. +pub fn shutdown() -> Result<(), BrowserError> { + Err(BrowserError::ComponentMissing) +} diff --git a/apps/desktop/native-browser/src/chromium/callbacks.rs b/apps/desktop/native-browser/src/chromium/callbacks.rs new file mode 100644 index 000000000..f2d9f71ec --- /dev/null +++ b/apps/desktop/native-browser/src/chromium/callbacks.rs @@ -0,0 +1,521 @@ +//! Bounded callback copies and generation fencing. No page receives host objects. + +use std::{ + collections::HashMap, + ffi::{c_char, c_void}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, Ordering}, + }, +}; + +use tokio::sync::oneshot; + +use crate::{BrowserError, BrowserEvent, EventSink, NavigationPolicy, PageState}; + +use super::ffi; + +const MAX_STATE_BYTES: usize = 32 * 1024; +type QuitHandler = Arc; +static QUIT_HANDLER: OnceLock>> = OnceLock::new(); +static QUIT_PENDING: AtomicBool = AtomicBool::new(false); + +pub(super) fn bind_quit_handler( + handler: impl Fn() + Send + Sync + 'static, +) -> Result<(), BrowserError> { + let handler: QuitHandler = Arc::new(handler); + { + let mut registered = QUIT_HANDLER + .get_or_init(Mutex::default) + .lock() + .map_err(|_| BrowserError::Unavailable)?; + if registered.is_some() { + return Err(BrowserError::Unavailable); + } + *registered = Some(handler.clone()); + } + if QUIT_PENDING.swap(false, Ordering::AcqRel) { + handler(); + } + Ok(()) +} + +pub(super) fn release_quit_handler() { + if let Some(handler) = QUIT_HANDLER.get() + && let Ok(mut handler) = handler.lock() + { + *handler = None; + } +} + +fn request_quit() { + let handler = QUIT_HANDLER + .get() + .and_then(|handler| handler.lock().ok()) + .and_then(|handler| handler.clone()); + if let Some(handler) = handler { + handler(); + } else { + QUIT_PENDING.store(true, Ordering::Release); + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageUpdate { + url: Option, + title: Option, + can_go_back: Option, + can_go_forward: Option, + loading: Option, +} +pub(super) struct Entry { + pub generation: u64, + pub policy: NavigationPolicy, + pub sink: EventSink, + pub page: PageState, + pub created: Option>, + pub closed: Option>, + pub inspection: Option>, + pub abandoned: bool, + pub identity_epoch: u64, + pub identity_request: Option, + #[cfg(feature = "native-test-driver")] + pub acceptance: Option>>, +} + +pub(super) fn entries() -> &'static Mutex> { + static ENTRIES: OnceLock>> = OnceLock::new(); + ENTRIES.get_or_init(Mutex::default) +} + +/// Consuming this exact review is the dispatch point shared with revocation. +pub(super) fn consume_identity( + entry: &mut Entry, + generation: u64, + review: &crate::pki::IdentityRequest, + fingerprint: Option<&str>, +) -> Result { + if entry.generation != generation || entry.abandoned || entry.closed.is_some() { + return Err(BrowserError::Closed); + } + let pending = entry + .identity_request + .as_ref() + .filter(|pending| { + pending.id == review.id + && pending.epoch == review.epoch + && pending.epoch == entry.identity_epoch + && pending.origin == review.origin + && !pending.is_expired() + }) + .ok_or(BrowserError::Closed)?; + let index = fingerprint.map_or(Ok(-1), |fingerprint| { + pending + .candidate_index(fingerprint) + .ok_or(BrowserError::Unavailable) + })?; + entry.identity_request = None; + Ok(index) +} + +pub(super) fn callbacks() -> ffi::Callbacks { + ffi::Callbacks { + owner: std::ptr::null_mut(), + event, + allow_url, + select_identity, + schedule_pump: None, + } +} + +fn cancel_identity(entry: &mut Entry, payload: *const u8, length: usize) { + if payload.is_null() || length != std::mem::size_of::() { + return; + } + let mut request = [0; 8]; + // SAFETY: Native ABI passes the exact bounded borrowed request ID. + request.copy_from_slice(unsafe { std::slice::from_raw_parts(payload, length) }); + if entry + .identity_request + .as_ref() + .is_some_and(|pending| pending.id == u64::from_ne_bytes(request)) + { + entry.identity_request = None; + } +} + +#[cfg(feature = "native-test-driver")] +fn complete_acceptance(entry: &mut Entry, success: bool, evidence: Option<&[u8]>) { + if let Some(sender) = entry.acceptance.take() { + let result = evidence + .filter(|_| success) + .ok_or(BrowserError::Unavailable) + .and_then(|evidence| { + serde_json::from_slice(evidence).map_err(|_| BrowserError::Unavailable) + }); + let _ = sender.send(result); + } +} + +unsafe extern "C" fn event( + _: *mut c_void, + tab: u64, + generation: u64, + event: u32, + _: i32, + success: i32, + payload: *const u8, + length: usize, +) { + // Native callers own the payload during this callback. All parsing/copies + // are bounded, and panics never unwind into Chromium. + let _ = std::panic::catch_unwind(|| { + // Native application Quit is independent of any tab. Pages cannot + // generate this event, and the trusted host binds its exit callback. + if event == 17 && tab == 0 && generation == 0 { + eprintln!("native AppKit Quit callback received"); + request_quit(); + return; + } + let Ok(mut entries) = entries().lock() else { + return; + }; + let Some(entry) = entries + .get_mut(&tab) + .filter(|entry| entry.generation == generation) + else { + return; + }; + let mut emit = None; + match event { + 1 => { + if let Some(sender) = entry.created.take() { + let _ = sender.send(()); + } + } + 2 => { + entry.page.loading = success != 0; + emit = Some(BrowserEvent::Loading(success != 0)); + } + 3 if !payload.is_null() && length <= MAX_STATE_BYTES => { + // SAFETY: Shim guarantees a valid borrowed buffer of `length` bytes. + let bytes = unsafe { std::slice::from_raw_parts(payload, length) }; + if let Ok(page) = serde_json::from_slice::(bytes) { + if page.url.as_ref().is_some_and(|url| url.len() > 8_192) + || page.title.as_ref().is_some_and(|title| title.len() > 4_096) + { + return; + } + if let Some(url) = page.url { + entry.page.url = if entry.policy.allows(&url) { + url + } else { + String::new() + }; + } + if let Some(title) = page.title { + entry.page.title = title; + } + if let Some(value) = page.can_go_back { + entry.page.can_go_back = value; + } + if let Some(value) = page.can_go_forward { + entry.page.can_go_forward = value; + } + if let Some(value) = page.loading { + entry.page.loading = value; + } + if let Some(sender) = entry.inspection.take() { + let _ = sender.send(entry.page.clone()); + } + } + } + 4 => emit = Some(BrowserEvent::Failed), + 5 | 13 | 15 => emit = Some(BrowserEvent::Crashed), + 6 => emit = Some(BrowserEvent::Blocked), + 7 => emit = Some(BrowserEvent::Download), + 8 if !payload.is_null() && length <= 8_192 => { + // SAFETY: Shim guarantees callback buffer lifetime and length. + if let Ok(url) = + std::str::from_utf8(unsafe { std::slice::from_raw_parts(payload, length) }) + { + emit = Some(BrowserEvent::Popup(url.to_owned())); + } + } + 9 => emit = Some(BrowserEvent::TlsFailed), + 10 => { + if let Some(sender) = entry.closed.take() { + let _ = sender.send(()); + } + // OnBeforeClose is terminal even if the original waiter timed + // out. Removal lets a later native-close reconciliation succeed. + entries.remove(&tab); + return; + } + 14 => emit = Some(BrowserEvent::AuthenticationRequired), + 18 => cancel_identity(entry, payload, length), + #[cfg(feature = "native-test-driver")] + 16 => { + let evidence = if !payload.is_null() && length <= MAX_STATE_BYTES { + // SAFETY: The shim supplies the bounded borrowed evidence buffer. + Some(unsafe { std::slice::from_raw_parts(payload, length) }) + } else { + None + }; + complete_acceptance(entry, success != 0, evidence); + } + _ => {} + } + let sink = entry.sink.clone(); + drop(entries); + if let Some(event) = emit { + sink(event); + } + }); +} + +unsafe extern "C" fn allow_url( + _: *mut c_void, + tab: u64, + generation: u64, + url: *const c_char, + length: usize, + _: i32, +) -> i32 { + std::panic::catch_unwind(|| { + if url.is_null() || length > 8_192 { + return 0; + } + // SAFETY: Shim supplies a valid, bounded borrowed URL buffer. + let Ok(url) = + std::str::from_utf8(unsafe { std::slice::from_raw_parts(url.cast(), length) }) + else { + return 0; + }; + // Clone the authority reference under one tiny critical section. URL + // parsing/policy work never holds the lifetime-event registry lock. + let policy = { + let Ok(entries) = entries().lock() else { + return 0; + }; + entries + .get(&tab) + .filter(|entry| entry.generation == generation && !entry.abandoned) + .map(|entry| entry.policy.clone()) + }; + i32::from(policy.is_some_and(|policy| policy.allows(url))) + }) + .unwrap_or(0) +} + +unsafe extern "C" fn select_identity( + _: *mut c_void, + tab: u64, + generation: u64, + request_id: u64, + origin: *const c_char, + origin_length: usize, + candidates: *const ffi::Certificate, + candidate_count: usize, +) -> i32 { + std::panic::catch_unwind(|| { + use crate::pki::{ + IdentityRequest, + selection::{MAX_CANDIDATES, MAX_CERTIFICATE_BYTES}, + }; + if origin.is_null() + || origin_length > 8192 + || candidates.is_null() + || candidate_count == 0 + || candidate_count > MAX_CANDIDATES + { + return -1; + } + // SAFETY: CEF owns these bounded borrowed origin/certificate buffers for + // the callback lifetime; only validated public metadata is retained. + let Ok(origin) = std::str::from_utf8(unsafe { + std::slice::from_raw_parts(origin.cast(), origin_length) + }) else { + return -1; + }; + let (policy, epoch) = { + let Ok(entries) = entries().lock() else { + return -1; + }; + let Some(entry) = entries.get(&tab).filter(|entry| { + entry.generation == generation && !entry.abandoned && entry.closed.is_none() + }) else { + return -1; + }; + (entry.policy.clone(), entry.identity_epoch) + }; + if !policy.allows(origin) { + return -1; + } + let mut certificates = Vec::with_capacity(candidate_count); + // SAFETY: Native shim supplies candidate_count valid certificate entries. + for candidate in unsafe { std::slice::from_raw_parts(candidates, candidate_count) } { + if candidate.der.is_null() + || candidate.der_len == 0 + || candidate.der_len > MAX_CERTIFICATE_BYTES + { + return -1; + } + // SAFETY: The validated bounded public DER remains native-owned. + certificates + .push(unsafe { std::slice::from_raw_parts(candidate.der, candidate.der_len) }); + } + let Ok(review) = IdentityRequest::new(request_id, epoch, origin, &certificates) else { + return -1; + }; + let Ok(mut entries) = entries().lock() else { + return -1; + }; + let Some(entry) = entries.get_mut(&tab).filter(|entry| { + entry.generation == generation + && !entry.abandoned + && entry.closed.is_none() + && entry.identity_epoch == epoch + }) else { + return -1; + }; + entry.identity_request = Some(review); + // Defer the native callback. No first-candidate or OS chooser fallback. + -2 + }) + .unwrap_or(-1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn review_entry() -> (Entry, crate::pki::IdentityRequest, String) { + let mut parameters = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + parameters.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + parameters.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + let certificate = parameters + .self_signed(&rcgen::KeyPair::generate().unwrap()) + .unwrap(); + let review = crate::pki::IdentityRequest::new( + 41, + 9, + "https://example.com", + &[certificate.der().as_ref()], + ) + .unwrap(); + let fingerprint = review.candidates[0].fingerprint_sha256.clone(); + ( + Entry { + generation: 17, + policy: NavigationPolicy::default(), + sink: std::sync::Arc::new(|_| {}), + page: PageState::default(), + created: None, + closed: None, + inspection: None, + abandoned: false, + identity_epoch: 9, + identity_request: Some(review.clone()), + #[cfg(feature = "native-test-driver")] + acceptance: None, + }, + review, + fingerprint, + ) + } + + #[test] + fn identity_review_is_single_use_and_bound_to_generation_epoch_origin_and_fingerprint() { + let (mut entry, review, fingerprint) = review_entry(); + assert_eq!( + consume_identity(&mut entry, 18, &review, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + assert_eq!( + consume_identity(&mut entry, 17, &review, Some(&"a".repeat(64))), + Err(BrowserError::Unavailable) + ); + let mut different_origin = review.clone(); + different_origin.origin = "https://other.example.com".into(); + assert_eq!( + consume_identity(&mut entry, 17, &different_origin, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + let mut replaced = review.clone(); + replaced.id += 1; + assert_eq!( + consume_identity(&mut entry, 17, &replaced, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + entry.identity_epoch += 1; + assert_eq!( + consume_identity(&mut entry, 17, &review, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + entry.identity_epoch -= 1; + assert_eq!( + consume_identity(&mut entry, 17, &review, Some(&fingerprint)), + Ok(0) + ); + assert!(entry.identity_request.is_none()); + assert_eq!( + consume_identity(&mut entry, 17, &review, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + } + + #[test] + fn closing_or_abandoned_tabs_cannot_finish_a_dialog_and_dismissal_selects_none() { + let (mut entry, review, fingerprint) = review_entry(); + entry.abandoned = true; + assert_eq!( + consume_identity(&mut entry, 17, &review, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + entry.abandoned = false; + let (send, _) = oneshot::channel(); + entry.closed = Some(send); + assert_eq!( + consume_identity(&mut entry, 17, &review, Some(&fingerprint)), + Err(BrowserError::Closed) + ); + entry.closed = None; + assert_eq!(consume_identity(&mut entry, 17, &review, None), Ok(-1)); + assert!(entry.identity_request.is_none()); + } + + #[test] + fn delayed_close_reconciles_after_waiter_cancellation_and_fences_generation() { + let tab = u64::MAX; + let (sender, receiver) = oneshot::channel(); + drop(receiver); + entries().lock().unwrap().insert( + tab, + Entry { + generation: 17, + policy: NavigationPolicy::default(), + sink: std::sync::Arc::new(|_| {}), + page: PageState::default(), + created: None, + closed: Some(sender), + inspection: None, + identity_epoch: 0, + identity_request: None, + abandoned: false, + #[cfg(feature = "native-test-driver")] + acceptance: None, + }, + ); + // SAFETY: Callback takes no payload for CLOSED and pointer is null. + unsafe { + event(std::ptr::null_mut(), tab, 16, 10, 0, 1, std::ptr::null(), 0); + } + assert!(entries().lock().unwrap().contains_key(&tab)); + unsafe { + event(std::ptr::null_mut(), tab, 17, 10, 0, 1, std::ptr::null(), 0); + } + assert!(!entries().lock().unwrap().contains_key(&tab)); + } +} diff --git a/apps/desktop/native-browser/src/chromium/ffi.rs b/apps/desktop/native-browser/src/chromium/ffi.rs new file mode 100644 index 000000000..5be120008 --- /dev/null +++ b/apps/desktop/native-browser/src/chromium/ffi.rs @@ -0,0 +1,98 @@ +//! Exact private ABI from `native/browser/include/colossus_cef.h`. + +use std::ffi::{c_char, c_void}; + +pub(super) const ABI_VERSION: u32 = 2; + +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct Bounds { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +#[repr(C)] +pub(super) struct Certificate { + pub der: *const u8, + pub der_len: usize, +} + +#[repr(C)] +pub(super) struct Callbacks { + pub owner: *mut c_void, + pub event: unsafe extern "C" fn(*mut c_void, u64, u64, u32, i32, i32, *const u8, usize), + pub allow_url: unsafe extern "C" fn(*mut c_void, u64, u64, *const c_char, usize, i32) -> i32, + pub select_identity: unsafe extern "C" fn( + *mut c_void, + u64, + u64, + u64, + *const c_char, + usize, + *const Certificate, + usize, + ) -> i32, + pub schedule_pump: Option, +} + +#[repr(C)] +pub(super) struct Options { + pub abi_version: u32, + pub argc: i32, + pub argv: *mut *mut c_char, + pub platform_instance: usize, + pub sandbox_info: *mut c_void, + pub root_cache_path: *const c_char, + pub browser_subprocess_path: *const c_char, + pub headless: i32, + pub callbacks: Callbacks, +} + +unsafe extern "C" { + #[cfg(windows)] + pub(super) fn colossus_cef_windows_run( + instance: usize, + sandbox: *mut c_void, + version: *mut c_void, + run: unsafe extern "C" fn() -> i32, + ) -> i32; + #[cfg(windows)] + pub(super) fn colossus_cef_windows_context( + instance: *mut usize, + sandbox: *mut *mut c_void, + ) -> i32; + pub(super) fn colossus_cef_bootstrap(options: *const Options, subprocess_exit: *mut i32) + -> i32; + pub(super) fn colossus_cef_pump() -> i32; + pub(super) fn colossus_cef_shutdown() -> i32; + pub(super) fn colossus_cef_create( + tab: u64, + generation: u64, + context_id: u64, + parent: usize, + bounds: Bounds, + initial_url: *const c_char, + ) -> i32; + pub(super) fn colossus_cef_navigate(tab: u64, generation: u64, url: *const c_char) -> i32; + pub(super) fn colossus_cef_control(tab: u64, generation: u64, action: u32) -> i32; + pub(super) fn colossus_cef_inspect(tab: u64, generation: u64) -> i32; + pub(super) fn colossus_cef_bounds_set(tab: u64, generation: u64, bounds: Bounds) -> i32; + pub(super) fn colossus_cef_visible(tab: u64, generation: u64, visible: i32) -> i32; + pub(super) fn colossus_cef_close(tab: u64, generation: u64) -> i32; + #[cfg(all(windows, feature = "native-test-driver"))] + pub(super) fn colossus_cef_acceptance_input(tab: u64, generation: u64) -> i32; + pub(super) fn colossus_cef_select_identity( + tab: u64, + generation: u64, + request_id: u64, + candidate_index: i32, + ) -> i32; + #[cfg(feature = "native-test-driver")] + pub(super) fn colossus_cef_acceptance_probe(tab: u64, generation: u64) -> i32; + #[cfg(feature = "native-test-driver")] + pub(super) fn colossus_cef_acceptance_activate(tab: u64, generation: u64) -> i32; + #[cfg(feature = "native-test-driver")] + pub(super) fn colossus_cef_acceptance_terminate(tab: u64, generation: u64) -> i32; +} diff --git a/apps/desktop/native-browser/src/chromium/linked.rs b/apps/desktop/native-browser/src/chromium/linked.rs new file mode 100644 index 000000000..6618a6124 --- /dev/null +++ b/apps/desktop/native-browser/src/chromium/linked.rs @@ -0,0 +1,734 @@ +//! Main-thread CEF lifecycle and real child-surface operations. + +use std::{ + collections::HashMap, + ffi::CString, + path::{Path, PathBuf}, + sync::{ + Mutex, OnceLock, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use tauri::Window; +use tokio::sync::oneshot; + +use crate::{BrowserError, EventSink, NavigationAction, NavigationPolicy, PageState}; + +use super::{ + Bootstrap, + callbacks::{self, Entry}, + ffi, +}; + +static READY: OnceLock> = OnceLock::new(); +static NEXT_TAB: AtomicU64 = AtomicU64::new(1); +const DEADLINE: Duration = Duration::from_secs(5); + +pub(super) fn readiness() -> Result<(), BrowserError> { + READY + .get() + .copied() + .unwrap_or(Err(BrowserError::BootstrapRequired)) +} + +/// Initialize at the real entry point before Tauri creates its native application. +#[must_use] +pub fn bootstrap(root: &Path, helper: &Path) -> Bootstrap { + if READY.get().is_some() { + return Bootstrap::Unavailable(BrowserError::BootstrapRequired); + } + let result = bootstrap_inner(root, helper); + let _ = READY.set(match result { + Bootstrap::Ready => Ok(()), + Bootstrap::Unavailable(error) => Err(error), + Bootstrap::SubprocessExit(_) => Err(BrowserError::Closed), + }); + result +} + +fn bootstrap_inner(root: &Path, helper: &Path) -> Bootstrap { + if !helper.is_file() { + return Bootstrap::Unavailable(BrowserError::ComponentMissing); + } + let build = || -> Result<(CString, CString, Vec), BrowserError> { + let root = CString::new(root.to_str().ok_or(BrowserError::Unavailable)?) + .map_err(|_| BrowserError::Unavailable)?; + let helper = CString::new(helper.to_str().ok_or(BrowserError::Unavailable)?) + .map_err(|_| BrowserError::Unavailable)?; + let args = std::env::args_os() + .map(|arg| { + arg.into_string() + .map_err(|_| BrowserError::Unavailable) + .and_then(|arg| CString::new(arg).map_err(|_| BrowserError::Unavailable)) + }) + .collect::, _>>()?; + Ok((root, helper, args)) + }; + let Ok((root, _helper, args)) = build() else { + return Bootstrap::Unavailable(BrowserError::Unavailable); + }; + let Ok(argument_count) = i32::try_from(args.len()) else { + return Bootstrap::Unavailable(BrowserError::Unavailable); + }; + let mut pointers = args + .iter() + .map(|arg| arg.as_ptr().cast_mut()) + .chain(std::iter::once(std::ptr::null_mut())) + .collect::>(); + let (platform_instance, sandbox_info) = match platform_bootstrap() { + Ok(context) => context, + Err(error) => return Bootstrap::Unavailable(error), + }; + let options = ffi::Options { + abi_version: ffi::ABI_VERSION, + argc: argument_count, + argv: pointers.as_mut_ptr(), + platform_instance, + sandbox_info, + root_cache_path: root.as_ptr(), + // Windows sandbox dispatch must re-enter the same pinned bootstrap. + #[cfg(windows)] + browser_subprocess_path: std::ptr::null(), + #[cfg(not(windows))] + browser_subprocess_path: _helper.as_ptr(), + headless: 0, + callbacks: callbacks::callbacks(), + }; + let mut exit = -1; + // SAFETY: Borrowed argument buffers outlive this call. Callbacks reference + // process-static storage. Native shim performs pre-AppKit installation. + let code = unsafe { ffi::colossus_cef_bootstrap(&raw const options, &raw mut exit) }; + if code != 0 { + return Bootstrap::Unavailable(BrowserError::NativeAcceptanceRequired); + } + if exit >= 0 { + Bootstrap::SubprocessExit(exit) + } else { + Bootstrap::Ready + } +} + +#[cfg_attr( + not(windows), + allow( + clippy::unnecessary_wraps, + reason = "Shared bootstrap API can fail when Windows has no CEF-owned entry." + ) +)] +fn platform_bootstrap() -> Result<(usize, *mut std::ffi::c_void), BrowserError> { + #[cfg(windows)] + { + let mut instance = 0; + let mut sandbox = std::ptr::null_mut(); + // SAFETY: Pointers refer to live local output slots. The native entry + // returns a context only while its bootstrap-owned main-thread call lives. + let code = + unsafe { ffi::colossus_cef_windows_context(&raw mut instance, &raw mut sandbox) }; + if code != 0 { + return Err(BrowserError::BootstrapRequired); + } + Ok((instance, sandbox)) + } + #[cfg(not(windows))] + Ok((0, std::ptr::null_mut())) +} + +/// Run the Windows client DLL under CEF's bootstrap-owned loader and sandbox. +/// +/// # Safety +/// Call only from the pinned bootstrap's `RunWinMain` export. The instance, +/// sandbox, version and callback must remain valid for the complete invocation. +#[cfg(windows)] +pub unsafe fn run_windows_client( + instance: usize, + sandbox: *mut std::ffi::c_void, + version: *mut std::ffi::c_void, + run: unsafe extern "C" fn() -> i32, +) -> i32 { + // SAFETY: Forward the caller's bootstrap lifetime contract without retaining + // any borrowed pointer beyond the native, synchronous callback invocation. + unsafe { ffi::colossus_cef_windows_run(instance, sandbox, version, run) } +} + +/// Called only from Tauri's main-thread scheduler. +pub fn pump() { + if readiness().is_ok() { + // SAFETY: Native shim rejects wrong-thread/lifecycle calls. + let _ = unsafe { ffi::colossus_cef_pump() }; + } +} + +/// Bind native `AppKit` Quit to the trusted application's controlled exit path. +/// +/// # Errors +/// Requires initialized Chromium and rejects replacing an existing host owner. +pub fn bind_quit_handler(handler: impl Fn() + Send + Sync + 'static) -> Result<(), BrowserError> { + readiness()?; + callbacks::bind_quit_handler(handler) +} + +/// Shutdown occurs on main thread after native close acknowledgements. +/// +/// # Errors +/// Reports missing initialization, outstanding native tabs, wrong-thread +/// shutdown, or unavailable callback ownership instead of claiming teardown. +pub fn shutdown() -> Result<(), BrowserError> { + readiness()?; + // SAFETY: The application-entry owner remains alive until this returns. + // Native shutdown refuses live tabs and calls CefShutdown on its UI thread. + let code = unsafe { ffi::colossus_cef_shutdown() }; + if code != 0 { + let reason = match code { + 2 => "Chromium is not initialized", + 4 => "shutdown is not on the owning UI thread", + 6 => "native browser close acknowledgements remain outstanding", + _ => "unexpected native lifecycle status", + }; + eprintln!("native CefShutdown refused: {reason} (status {code})"); + return status(code); + } + callbacks::release_quit_handler(); + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .clear(); + Ok(()) +} + +/// Real CEF tab, not a `WebView` navigated to a matching URL. +#[derive(Clone)] +pub struct Surface { + window: Window, + tab: u64, + generation: u64, +} + +struct PendingCreation(Option); +impl Drop for PendingCreation { + fn drop(&mut self) { + if let Some(surface) = self.0.take() { + surface.abandon(); + } + } +} + +impl Surface { + /// Return bounded public metadata for the live, exact native handshake. + /// + /// # Errors + /// Fails if the tab is closing, revoked, or its native generation changed. + pub fn pending_client_identity( + &self, + ) -> Result, BrowserError> { + let entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let entry = entries + .get(&self.tab) + .filter(|entry| { + entry.generation == self.generation && !entry.abandoned && entry.closed.is_none() + }) + .ok_or(BrowserError::Closed)?; + Ok(entry + .identity_request + .as_ref() + .filter(|request| !request.is_expired()) + .cloned()) + } + + /// Revoke native review before controller/workspace ownership changes. + /// Queued native cancellation does not block the controller registry lock. + pub fn revoke_client_identity(&self) { + self.cancel_identity_request(None); + } + + /// Cancel only this review when its native dialog/future is dismissed. + pub fn cancel_client_identity(&self, review: &crate::pki::IdentityRequest) { + self.cancel_identity_request(Some(review.id)); + } + + fn cancel_identity_request(&self, expected: Option) { + let request = if let Ok(mut entries) = callbacks::entries().lock() { + entries + .get_mut(&self.tab) + .filter(|entry| entry.generation == self.generation) + .and_then(|entry| { + if expected.is_some_and(|expected| { + entry + .identity_request + .as_ref() + .is_none_or(|request| request.id != expected) + }) { + return None; + } + entry.identity_epoch = entry.identity_epoch.wrapping_add(1); + entry.identity_request.take().map(|request| request.id) + }) + } else { + None + }; + if let Some(request) = request { + let (tab, generation) = (self.tab, self.generation); + let _ = self.window.run_on_main_thread(move || { + // SAFETY: Cancel only the exact pending native request on UI. + let _ = unsafe { ffi::colossus_cef_select_identity(tab, generation, request, -1) }; + }); + } + } + + /// Complete one native-reviewed origin/fingerprint selection on this guest. + /// The native callback uses the OS private key; no key is read by Rust. + /// + /// # Errors + /// Rejects stale/replaced/expired requests, unknown fingerprints, or closing + /// tabs. A consumed selection is never retried after a lost acknowledgement. + pub async fn select_client_identity( + &self, + review: &crate::pki::IdentityRequest, + fingerprint: Option<&str>, + ) -> Result<(), BrowserError> { + let review = review.clone(); + let fingerprint = fingerprint.map(str::to_owned); + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + let index = { + let mut entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let entry = entries.get_mut(&tab).ok_or(BrowserError::Closed)?; + // This consumption is the dispatch linearization point. Scope + // revocation clears requests under this same registry lock. + callbacks::consume_identity(entry, generation, &review, fingerprint.as_deref())? + }; + // SAFETY: Native checks request ID, tab generation, deadline and + // candidate index again immediately before platform key use. + status(unsafe { ffi::colossus_cef_select_identity(tab, generation, review.id, index) }) + }) + .await + } + + /// Create one hidden child CEF view after pre-application initialization. + /// + /// # Errors + /// Fails closed on missing bootstrap, parent, context or native readiness. + pub async fn create( + window: &Window, + _: &str, + generation: u64, + directory: PathBuf, + policy: NavigationPolicy, + sink: EventSink, + ) -> Result { + readiness()?; + let tab = NEXT_TAB.fetch_add(1, Ordering::Relaxed); + let context = context_id(directory)?; + let (send, created) = oneshot::channel(); + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .insert( + tab, + Entry { + generation, + policy, + sink, + page: PageState::default(), + created: Some(send), + closed: None, + inspection: None, + abandoned: false, + identity_epoch: 0, + identity_request: None, + #[cfg(feature = "native-test-driver")] + acceptance: None, + }, + ); + let surface = Self { + window: window.clone(), + tab, + generation, + }; + let mut pending = PendingCreation(Some(surface.clone())); + let parent_window = window.clone(); + surface + .dispatch(move || { + let parent = parent(&parent_window)?; + let url = CString::new("about:blank").map_err(|_| BrowserError::InvalidAddress)?; + // SAFETY: Parent belongs to the live Tauri main window; creation runs + // on its main thread. Native callback generation is registered first. + status(unsafe { + ffi::colossus_cef_create( + tab, + generation, + context, + parent, + ffi::Bounds { + x: -10_000, + y: -10_000, + width: 16, + height: 16, + }, + url.as_ptr(), + ) + }) + }) + .await?; + tokio::time::timeout(DEADLINE, created) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + surface.hide()?; + pending.0 = None; + Ok(surface) + } + + fn abandon(&self) { + if let Ok(mut entries) = callbacks::entries().lock() + && let Some(entry) = entries.get_mut(&self.tab) + { + entry.abandoned = true; + } + let (tab, generation) = (self.tab, self.generation); + let _ = self.window.run_on_main_thread(move || { + // SAFETY: Exact generation; shim closes a pending or created guest. + if unsafe { ffi::colossus_cef_close(tab, generation) } == 3 + && let Ok(mut entries) = callbacks::entries().lock() + { + entries.remove(&tab); + } + }); + } + + /// # Errors + /// Rejects invalid address, closed guest, or stale native generation. + pub fn navigate(&self, url: &url::Url) -> Result<(), BrowserError> { + let url = CString::new(url.as_str()).map_err(|_| BrowserError::InvalidAddress)?; + self.schedule(move |tab, generation| { + // SAFETY: Scheduled on the owning UI thread; URL lives through call. + status(unsafe { ffi::colossus_cef_navigate(tab, generation, url.as_ptr()) }) + }) + } + + /// # Errors + /// Fails when the native presentation owner has closed. + pub fn hide(&self) -> Result<(), BrowserError> { + self.visible(false) + } + /// # Errors + /// Fails when the native presentation owner has closed. + pub fn show(&self) -> Result<(), BrowserError> { + self.visible(true) + } + fn visible(&self, visible: bool) -> Result<(), BrowserError> { + self.schedule(move |tab, generation| { + // SAFETY: The shim validates exact native identity on UI thread. + status(unsafe { ffi::colossus_cef_visible(tab, generation, i32::from(visible)) }) + }) + } + + /// # Errors + /// Rejects nonfinite, oversized, or invalid native bounds. + pub fn set_bounds(&self, bounds: tauri::Rect) -> Result<(), BrowserError> { + let scale = self + .window + .scale_factor() + .map_err(|_| BrowserError::Closed)?; + let position = bounds.position.to_logical::(scale); + let size = bounds.size.to_logical::(scale); + let factor = if cfg!(windows) { scale } else { 1.0 }; + let values = [ + position.x * factor, + position.y * factor, + size.width * factor, + size.height * factor, + ]; + if values + .iter() + .any(|value| !value.is_finite() || *value < 0.0 || *value > f64::from(i32::MAX)) + || size.width < 16.0 + || size.height < 16.0 + { + return Err(BrowserError::Unavailable); + } + #[allow(clippy::cast_possible_truncation)] + let bounds = ffi::Bounds { + x: values[0].round() as i32, + y: values[1].round() as i32, + width: values[2].round() as i32, + height: values[3].round() as i32, + }; + self.schedule(move |tab, generation| { + // SAFETY: Bounds were validated and call runs on UI thread. + status(unsafe { ffi::colossus_cef_bounds_set(tab, generation, bounds) }) + }) + } + + /// Wait for Chromium's actual `OnBeforeClose` before releasing the profile. + /// + /// # Errors + /// Failure preserves native context/profile ownership for reconciliation. + pub async fn close(&self) -> Result<(), BrowserError> { + let (sender, receive) = oneshot::channel(); + { + let mut entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let Some(entry) = entries.get_mut(&self.tab) else { + return Ok(()); + }; + if entry.generation != self.generation + || entry + .closed + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + { + return Err(BrowserError::Closed); + } + entry.closed = Some(sender); + } + let (tab, generation) = (self.tab, self.generation); + let dispatched = self + .dispatch(move || { + // SAFETY: Close occurs on the UI thread with exact generation. + status(unsafe { ffi::colossus_cef_close(tab, generation) }) + }) + .await; + if matches!(dispatched, Err(BrowserError::Closed)) { + // Native close returns CLOSED only for a missing/wrong-generation + // target, including a previously acknowledged delayed close. + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .remove(&tab); + return Ok(()); + } + dispatched?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .remove(&self.tab); + Ok(()) + } + + /// # Errors + /// Fails if the native guest is closed, busy, or its callback expires. + pub async fn inspect(&self) -> Result { + let (sender, receive) = oneshot::channel(); + { + let mut entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let entry = entries + .get_mut(&self.tab) + .filter(|entry| entry.generation == self.generation) + .ok_or(BrowserError::Closed)?; + if entry + .inspection + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + { + return Err(BrowserError::Unavailable); + } + entry.inspection = Some(sender); + } + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Native metadata read on owning UI thread. + status(unsafe { ffi::colossus_cef_inspect(tab, generation) }) + }) + .await?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed) + } + + /// # Errors + /// Fails when the tab has closed or its native generation changed. + pub async fn control(&self, action: NavigationAction) -> Result<(), BrowserError> { + let action = match action { + NavigationAction::Back => 1, + NavigationAction::Forward => 2, + NavigationAction::Reload => 3, + NavigationAction::Stop => 4, + }; + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Closed operation set, exact native identity, UI thread. + status(unsafe { ffi::colossus_cef_control(tab, generation, action) }) + }) + .await + } + + /// Inspect rendering and `AppKit` presentation with a fixed native test probe. + /// + /// This accepts neither page script nor a `DevTools` method, and is absent + /// from ordinary Desktop builds and renderer IPC. + /// + /// # Errors + /// Fails for unsupported platforms, stale guests, busy probes, or timeout. + #[cfg(feature = "native-test-driver")] + pub async fn acceptance_probe(&self) -> Result { + let (sender, receive) = oneshot::channel(); + { + let mut entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let entry = entries + .get_mut(&self.tab) + .filter(|entry| entry.generation == self.generation) + .ok_or(BrowserError::Closed)?; + if entry + .acceptance + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + { + return Err(BrowserError::Unavailable); + } + entry.acceptance = Some(sender); + } + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Fixed read-only native probe on the owning main thread. + status(unsafe { ffi::colossus_cef_acceptance_probe(tab, generation) }) + }) + .await?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + } + + /// Request OS activation of this exact guest's owning application/window. + /// + /// Test callers must separately observe native activation before presenting + /// a guest. The request does not override production inactive-window hiding. + /// + /// # Errors + /// Rejects unsupported platforms, closed guests, or a missing owning window. + #[cfg(feature = "native-test-driver")] + pub async fn acceptance_activate_parent(&self) -> Result<(), BrowserError> { + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Exact native identity, owning UI thread, closed test API. + status(unsafe { ffi::colossus_cef_acceptance_activate(tab, generation) }) + }) + .await + } + + /// Invoke the normal native `AppKit` Quit path with this live owned guest. + /// + /// # Errors + /// Rejects unsupported platforms, closed guests, or a missing owning window. + #[cfg(feature = "native-test-driver")] + pub async fn acceptance_terminate_application(&self) -> Result<(), BrowserError> { + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Exact native identity and owning UI thread, test-only API. + status(unsafe { ffi::colossus_cef_acceptance_terminate(tab, generation) }) + }) + .await + } + + /// Exercise fixed native OS mouse and keyboard input in the test fixture. + /// + /// # Errors + /// Requires a visible, foreground Windows guest and accepted OS input. + #[cfg(all(windows, feature = "native-test-driver"))] + pub async fn acceptance_input(&self) -> Result<(), BrowserError> { + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: The callback runs on the guest's owning UI thread. Native + // code validates the HWND/foreground and sends only fixed fixture input. + status(unsafe { ffi::colossus_cef_acceptance_input(tab, generation) }) + }) + .await + } + + fn schedule( + &self, + action: impl FnOnce(u64, u64) -> Result<(), BrowserError> + Send + 'static, + ) -> Result<(), BrowserError> { + let (tab, generation) = (self.tab, self.generation); + let entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + if !entries + .get(&tab) + .is_some_and(|entry| entry.generation == generation && entry.closed.is_none()) + { + return Err(BrowserError::Closed); + } + drop(entries); + let (sender, receive) = std::sync::mpsc::sync_channel(1); + self.window + .run_on_main_thread(move || { + let _ = sender.send(action(tab, generation)); + }) + .map_err(|_| BrowserError::Closed)?; + // Tauri executes the closure inline when this is its main thread. A + // worker waits only for the operation itself, never for CEF callbacks. + receive + .recv_timeout(DEADLINE) + .map_err(|_| BrowserError::TimedOut)? + } + + async fn dispatch( + &self, + action: impl FnOnce() -> Result + Send + 'static, + ) -> Result { + let (sender, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + let _ = sender.send(action()); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + } +} + +fn status(value: i32) -> Result<(), BrowserError> { + match value { + 0 => Ok(()), + 3 => Err(BrowserError::Closed), + _ => Err(BrowserError::Unavailable), + } +} + +fn context_id(directory: PathBuf) -> Result { + static CONTEXTS: OnceLock>> = OnceLock::new(); + let mut contexts = CONTEXTS + .get_or_init(Mutex::default) + .lock() + .map_err(|_| BrowserError::Closed)?; + let next = u64::try_from(contexts.len()).map_err(|_| BrowserError::Unavailable)? + 1; + Ok(*contexts.entry(directory).or_insert(next)) +} + +#[cfg(target_os = "macos")] +fn parent(window: &Window) -> Result { + window + .ns_view() + .map(|view| view as usize) + .map_err(|_| BrowserError::Closed) +} +#[cfg(windows)] +fn parent(window: &Window) -> Result { + window + .hwnd() + .map(|window| window.0 as usize) + .map_err(|_| BrowserError::Closed) +} +#[cfg(not(any(windows, target_os = "macos")))] +fn parent(_: &Window) -> Result { + Err(BrowserError::NativeAcceptanceRequired) +} diff --git a/apps/desktop/native-browser/src/contained.rs b/apps/desktop/native-browser/src/contained.rs new file mode 100644 index 000000000..bd4250007 --- /dev/null +++ b/apps/desktop/native-browser/src/contained.rs @@ -0,0 +1,180 @@ +//! Trusted composition seam for Desktop's independently supervised human guests. +use crate::{BrowserError, BrowserView, EventSink, NavigationPolicy}; +use async_trait::async_trait; +/// Closed native placement type shared with the managed sidecar adapter. +pub use colossus_browser_presentation::Configure as NativeConfigure; +use std::sync::Arc; +use tauri::{AppHandle, Manager as _, Window}; +use url::Url; + +/// Native attachment shape. Selecting a role does not grant runtime ownership; +/// composition must separately admit the exact session and private channel. +#[derive(Clone, Copy)] +pub enum PresentationRole { + /// Fresh dedicated human session, before agent ownership begins. + Human, + /// View the exact current agent page without human input or navigation. + ReadOnly, +} +impl PresentationRole { + /// Check the native controller generation expected by this admitted role. + #[must_use] + pub const fn accepts_generation(self, generation: u64) -> bool { + match self { + Self::Human => generation == 0, + Self::ReadOnly => generation > 0, + } + } +} + +/// A native Desktop request after controller/workspace generation checks. +/// These inputs do not nominate an executable, profile, endpoint or grant. +pub struct ContainedGuestRequest { + /// Existing Desktop display identity; the factory creates private runtime identities. + pub id: String, + /// Trusted current controller generation. + pub generation: u64, + /// Native-selected workspace scope, checked again by composition. + pub scope: String, + /// Nominated canonical conversation, whose ownership is checked by Runtime. + pub conversation_id: Option, + /// Requested human address; composition derives the immutable origin envelope. + pub initial_url: Option, + /// Closed Desktop navigation checks, additional to native containment policy. + pub policy: NavigationPolicy, + /// Trusted generation-bound Desktop event sink. + pub sink: EventSink, +} +/// Native bootstrap supplies the verified installed host and complete cleanup owner. +/// Factories obtain runtime-owned human admission before launching any browser process. +#[async_trait] +pub trait ContainedGuestFactory: Send + Sync { + /// Query accepted embedded placement before any owned allocation is attempted. + async fn available( + &self, + window: &Window, + request: &ContainedGuestRequest, + ) -> Result; + /// Create the actual independently contained native pixel guest. + /// + /// # Errors + /// Missing platform proof or uncertain startup fails closed and retains cleanup. + async fn create( + &self, + window: &Window, + request: ContainedGuestRequest, + ) -> Result; +} + +/// Retained native context owner. No renderer can create or serialize this authority. +#[async_trait] +pub trait RemoteHostOwner: Send + Sync { + /// Canonical conversation admitted by the owning runtime. + fn conversation_id(&self) -> &str; + /// Exact browser context admitted by the owning runtime. + fn session_id(&self) -> &str; + /// Detach the view; human contexts additionally require complete owned cleanup. + async fn close(&self) -> Result<(), BrowserError>; + /// Transfer to a registered run and return only server-derived native placement. + async fn handoff( + &self, + _run_id: &str, + ) -> Result { + Err(BrowserError::Unavailable) + } +} + +/// Whether this build includes the native pixel compositor, independent of host acceptance. +#[must_use] +pub const fn native_presentation_available() -> bool { + cfg!(all(colossus_cef_linked, any(windows, target_os = "macos"))) +} + +/// Attach independently admitted pixels through the platform compositor. +/// +/// # Errors +/// Missing compositor or failed private/native admission fails closed. +pub async fn attach( + window: &Window, + client: colossus_browser_presentation::PresentationClient, + configure: colossus_browser_presentation::Configure, + digest: [u8; 32], + owner: Arc, + sink: EventSink, +) -> Result { + #[cfg(all(colossus_cef_linked, any(windows, target_os = "macos")))] + { + crate::remote::RemoteSurface::create(window, client, configure, digest, owner, sink) + .await + .map(BrowserView::Remote) + } + #[cfg(not(all(colossus_cef_linked, any(windows, target_os = "macos"))))] + { + let _ = (window, client, configure, digest, sink); + owner.close().await?; + Err(BrowserError::Unavailable) + } +} +/// Attach an independently admitted agent viewer without enabling human input. +/// +/// # Errors +/// Rejects a human generation or missing native compositor and retains cleanup. +pub async fn attach_read_only( + window: &Window, + client: colossus_browser_presentation::PresentationClient, + configure: colossus_browser_presentation::Configure, + digest: [u8; 32], + owner: Arc, + sink: EventSink, +) -> Result { + #[cfg(all(colossus_cef_linked, any(windows, target_os = "macos")))] + { + crate::remote::RemoteSurface::create_read_only( + window, client, configure, digest, owner, sink, + ) + .await + .map(BrowserView::Remote) + } + #[cfg(not(all(colossus_cef_linked, any(windows, target_os = "macos"))))] + { + let _ = (window, client, configure, digest, sink); + owner.close().await?; + Err(BrowserError::Unavailable) + } +} + +/// Native-only application state. No web IPC command installs or replaces this factory. +pub struct ContainedBrowserComposition(pub Arc); +impl ContainedBrowserComposition { + /// Install once from trusted native bootstrap before browser requests are admitted. + /// + /// # Errors + /// Refuses replacement of an existing native owner. + pub fn install( + app: &AppHandle, + factory: Arc, + ) -> Result<(), BrowserError> { + app.manage(Self(factory)) + .then_some(()) + .ok_or(BrowserError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::PresentationRole; + + #[test] + fn human_attachment_cannot_claim_an_agent_generation() { + assert!(PresentationRole::Human.accepts_generation(0)); + assert!(!PresentationRole::Human.accepts_generation(1)); + assert!(!PresentationRole::Human.accepts_generation(u64::MAX)); + } + + #[test] + fn read_only_viewer_cannot_claim_initial_human_ownership() { + assert!(!PresentationRole::ReadOnly.accepts_generation(0)); + assert!(PresentationRole::ReadOnly.accepts_generation(1)); + assert!(PresentationRole::ReadOnly.accepts_generation(u64::MAX)); + } +} diff --git a/apps/desktop/native-browser/src/engine.rs b/apps/desktop/native-browser/src/engine.rs index ca5887c8f..e1dbc62c3 100644 --- a/apps/desktop/native-browser/src/engine.rs +++ b/apps/desktop/native-browser/src/engine.rs @@ -108,7 +108,7 @@ async fn dispatch( #[cfg(not(any(windows, target_os = "macos")))] mod platform { - use super::*; + use super::{BrowserError, EventSink, NavigationAction, NavigationPolicy, PageState}; pub fn harden( _: &tauri::webview::PlatformWebview, _: NavigationPolicy, diff --git a/apps/desktop/native-browser/src/lib.rs b/apps/desktop/native-browser/src/lib.rs index 58e5a4d20..18e05e65e 100644 --- a/apps/desktop/native-browser/src/lib.rs +++ b/apps/desktop/native-browser/src/lib.rs @@ -4,11 +4,30 @@ //! or host objects. Future agent control must enter through a separately authorized //! runtime adapter; possession of a guest handle is not an agent permit. +#[cfg(feature = "cef-preview")] +pub mod chromium; +#[cfg(feature = "cef-preview")] +pub mod contained; mod engine; mod navigation; +#[cfg(feature = "cef-preview")] +pub mod pki; +#[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") +))] +pub mod presentation; +#[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") +))] +pub mod remote; #[cfg(any(target_os = "macos", test))] mod response; mod types; +mod view; #[cfg(all(target_os = "macos", feature = "native-test-driver"))] pub mod acceptance; @@ -21,6 +40,7 @@ mod windows; pub use engine::{control, harden, inspect, is_active, open_external, release, share_session}; pub use navigation::{NavigationPolicy, parse_address}; pub use types::{BrowserError, BrowserEvent, EventSink, NavigationAction, PageState}; +pub use view::BrowserView; #[cfg(all(windows, feature = "native-test-driver"))] pub use windows::probe_file_picker; diff --git a/apps/desktop/native-browser/src/pki.rs b/apps/desktop/native-browser/src/pki.rs new file mode 100644 index 000000000..7e0c03017 --- /dev/null +++ b/apps/desktop/native-browser/src/pki.rs @@ -0,0 +1,176 @@ +//! Native software-certificate import. Store scope is wider than browser profiles. +//! +//! This API receives only native inputs. Password/key bytes have no serialization +//! or diagnostic surface. Imports do not establish Chromium identity-use proof. + +use serde::Serialize; + +pub(crate) mod selection; +pub use selection::{IdentityCandidate, IdentityRequest}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +/// Non-secret native provisioning metadata only. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] // Independent native capability projection. +pub struct PkiStatus { + pub scope: &'static str, + pub ca_import_available: bool, + pub pfx_import_available: bool, + pub client_identity_review_available: bool, + pub client_identity_selection_ready: bool, + pub acceptance_pending: bool, + pub message: &'static str, +} + +/// A completed native-store import, not proof of a successful mTLS handshake. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportReceipt { + pub scope: &'static str, + pub fingerprints_sha256: Vec, + pub identity_selection_ready: bool, +} + +/// Categorical failures never include selected paths or secret/native error text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PkiError { + Unsupported, + InvalidCertificate, + InvalidIdentity, + Unavailable, + StoreChangeUnknown, +} + +impl std::fmt::Display for PkiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Unsupported => "Native browser certificate import is unavailable on this platform.", + Self::InvalidCertificate => "Choose one valid CA certificate in PEM or DER format.", + Self::InvalidIdentity => "The PKCS#12 identity or its password could not be validated.", + Self::Unavailable => "The native certificate store could not complete the import.", + Self::StoreChangeUnknown => "The native store may have changed before import failed. Review the user certificate store before retrying.", + }) + } +} +impl std::error::Error for PkiError {} + +/// Actual compiled platform importer status. Native use still requires acceptance. +#[must_use] +pub fn status() -> PkiStatus { + let supported = cfg!(any(windows, target_os = "macos")); + PkiStatus { + scope: if supported { + "operating_system_user" + } else { + "unsupported" + }, + ca_import_available: supported, + pfx_import_available: supported, + client_identity_review_available: supported, + client_identity_selection_ready: false, + acceptance_pending: true, + message: if supported { + "Imports affect this operating-system user's certificate store and can affect other applications. Browser profiles do not isolate this trust. The Chromium preview supports native review for each pending identity request; installed native key-use acceptance is pending." + } else { + "This Desktop build has no native browser certificate importer. Colossus network credentials do not provision Chromium trust or client identities." + }, + } +} + +/// Import one reviewed CA into the user's native trust store. +/// +/// # Errors +/// Rejects non-CA/expired/bounded input and reports categorical native failures. +pub fn import_ca(bytes: &[u8]) -> Result { + let certificate = validate_ca(bytes)?; + #[cfg(windows)] + windows::import_ca(&certificate)?; + #[cfg(target_os = "macos")] + macos::import_ca(&certificate)?; + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = certificate; + Err(PkiError::Unsupported) + } + #[cfg(any(windows, target_os = "macos"))] + Ok(receipt(vec![fingerprint(&certificate)])) +} + +/// Validate a selected native CA and return its DER fingerprint for final review. +/// +/// # Errors +/// Rejects invalid, expired, non-CA, or oversized certificates without mutation. +pub fn ca_fingerprint(bytes: &[u8]) -> Result { + validate_ca(bytes).map(|der| fingerprint(&der)) +} + +/// Import a native-entered PKCS#12 identity into the user's native key store. +/// Windows imports do not request exportable keys or overwrite existing keys. +/// +/// # Errors +/// Rejects empty/oversized input, wrong password, and categorical store failures. +pub fn import_pfx(bytes: &[u8], password: &str) -> Result { + if bytes.is_empty() + || bytes.len() > 4 * 1024 * 1024 + || password.len() > 64 * 1024 + || password.contains('\0') + { + return Err(PkiError::InvalidIdentity); + } + #[cfg(windows)] + return windows::import_pfx(bytes, password).map(receipt); + #[cfg(target_os = "macos")] + return macos::import_pfx(bytes, password).map(receipt); + #[cfg(not(any(windows, target_os = "macos")))] + Err(PkiError::Unsupported) +} + +#[cfg(any(windows, target_os = "macos"))] +fn receipt(fingerprints: Vec) -> ImportReceipt { + ImportReceipt { + scope: "operating_system_user", + fingerprints_sha256: fingerprints, + identity_selection_ready: false, + } +} + +fn fingerprint(bytes: &[u8]) -> String { + colossus_native_browser_pki::fingerprint(bytes) +} + +fn validate_ca(bytes: &[u8]) -> Result, PkiError> { + colossus_native_browser_pki::ca_der(bytes).map_err(|_| PkiError::InvalidCertificate) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn imports_reject_oversized_or_malformed_inputs_before_native_effects() { + assert!(matches!( + import_ca(b"not a certificate"), + Err(PkiError::InvalidCertificate) + )); + assert!(matches!( + import_pfx(&[], ""), + Err(PkiError::InvalidIdentity) + )); + assert!(matches!( + import_pfx(b"x", "bad\0password"), + Err(PkiError::InvalidIdentity) + )); + assert_eq!(fingerprint(b"x").len(), 64); + } + #[test] + fn native_status_never_claims_profile_isolation_or_mtls_readiness() { + let status = status(); + assert!(!status.client_identity_selection_ready); + assert!(status.acceptance_pending); + assert_ne!(status.scope, "browser_profile"); + } +} diff --git a/apps/desktop/native-browser/src/pki/macos.rs b/apps/desktop/native-browser/src/pki/macos.rs new file mode 100644 index 000000000..5cb3f71a1 --- /dev/null +++ b/apps/desktop/native-browser/src/pki/macos.rs @@ -0,0 +1,252 @@ +//! Security.framework provisioning in the user's default keychain/trust domain. + +use std::ffi::c_void; + +use super::{PkiError, fingerprint}; + +type CF = *const c_void; +const UTF8: u32 = 0x0800_0100; +const DUPLICATE_ITEM: i32 = -25299; + +#[repr(C)] +struct DictionaryKeyCallbacks { + version: isize, + retain: Option CF>, + release: Option, + copy_description: Option CF>, + equal: Option u8>, + hash: Option usize>, +} + +#[cfg(test)] +mod tests; + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + static kCFTypeDictionaryKeyCallBacks: DictionaryKeyCallbacks; + fn CFRelease(value: CF); + fn CFDataCreate(allocator: CF, bytes: *const u8, length: isize) -> CF; + fn CFDataGetLength(data: CF) -> isize; + fn CFDataGetBytePtr(data: CF) -> *const u8; + fn CFStringCreateWithBytes( + allocator: CF, + bytes: *const u8, + length: isize, + encoding: u32, + external: u8, + ) -> CF; + fn CFDictionaryCreate( + allocator: CF, + keys: *const CF, + values: *const CF, + count: isize, + key_callbacks: *const c_void, + value_callbacks: *const c_void, + ) -> CF; + fn CFDictionaryGetValue(dictionary: CF, key: CF) -> CF; + fn CFArrayGetCount(array: CF) -> isize; + fn CFArrayGetValueAtIndex(array: CF, index: isize) -> CF; + fn CFArrayCreate( + allocator: CF, + values: *const CF, + count: isize, + callbacks: *const c_void, + ) -> CF; + fn CFNumberCreate(allocator: CF, kind: isize, value: *const c_void) -> CF; +} + +#[link(name = "Security", kind = "framework")] +unsafe extern "C" { + static kSecImportExportPassphrase: CF; + static kSecImportExportKeychain: CF; + static kSecImportItemIdentity: CF; + fn SecKeychainCopyDefault(keychain: *mut CF) -> i32; + fn SecCertificateCreateWithData(allocator: CF, data: CF) -> CF; + fn SecCertificateAddToKeychain(certificate: CF, keychain: CF) -> i32; + fn SecTrustSettingsSetTrustSettings(certificate: CF, domain: u32, settings: CF) -> i32; + fn SecPKCS12Import(data: CF, options: CF, items: *mut CF) -> i32; + fn SecIdentityCopyCertificate(identity: CF, certificate: *mut CF) -> i32; + fn SecCertificateCopyData(certificate: CF) -> CF; +} + +struct Owned(CF); +impl Owned { + fn new(value: CF) -> Result { + if value.is_null() { + Err(PkiError::Unavailable) + } else { + Ok(Self(value)) + } + } +} +impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: Every owner represents one non-null Create/Copy reference. + unsafe { + CFRelease(self.0); + } + } +} + +fn data(bytes: &[u8]) -> Result { + // SAFETY: CF copies the bounded borrowed bytes into its native buffer. + Owned::new(unsafe { + CFDataCreate( + std::ptr::null(), + bytes.as_ptr(), + isize::try_from(bytes.len()).map_err(|_| PkiError::InvalidIdentity)?, + ) + }) +} + +fn keychain() -> Result { + let mut keychain = std::ptr::null(); + // SAFETY: Copy API writes one retained keychain reference on success. + if unsafe { SecKeychainCopyDefault(&raw mut keychain) } != 0 { + return Err(PkiError::Unavailable); + } + Owned::new(keychain) +} + +fn trust_dictionary(result: &Owned) -> Result { + // SecTrustSettings.h defines this key with CFSTR, not an exported symbol. + // Create the same string value and retain/compare it by CF content, since + // Security's constant key has a different address from this owned string. + let bytes = b"kSecTrustSettingsResult"; + // SAFETY: CF copies the fixed valid UTF-8 key into a retained string. + let key = Owned::new(unsafe { + CFStringCreateWithBytes( + std::ptr::null(), + bytes.as_ptr(), + isize::try_from(bytes.len()).map_err(|_| PkiError::Unavailable)?, + UTF8, + 0, + ) + })?; + let keys = [key.0]; + let values = [result.0]; + // SAFETY: CoreFoundation's exported key callbacks retain CF references and + // compare/hash CFString values. The caller keeps the no-retain value alive + // through the synchronous trust operation. + Owned::new(unsafe { + CFDictionaryCreate( + std::ptr::null(), + keys.as_ptr(), + values.as_ptr(), + 1, + (&raw const kCFTypeDictionaryKeyCallBacks).cast(), + std::ptr::null(), + ) + }) +} + +pub(super) fn import_ca(der: &[u8]) -> Result<(), PkiError> { + let bytes = data(der)?; + let keychain = keychain()?; + // SAFETY: Security.framework reads a live CFData and returns retained cert. + let certificate = + Owned::new(unsafe { SecCertificateCreateWithData(std::ptr::null(), bytes.0) })?; + // SAFETY: Explicit user default keychain, never System keychain. + let code = unsafe { SecCertificateAddToKeychain(certificate.0, keychain.0) }; + if code != 0 && code != DUPLICATE_ITEM { + return Err(PkiError::Unavailable); + } + // Explicit trust root/as-root result supports reviewed root and intermediate + // CA anchors. This is an OS-user change, not per-browser-profile trust. + let (_, parsed) = + x509_parser::parse_x509_certificate(der).map_err(|_| PkiError::InvalidCertificate)?; + let result: i32 = if parsed.subject() == parsed.issuer() { + 1 + } else { + 2 + }; + // kCFNumberSInt32Type = 3. No-retain values live only within this call. + let result = + Owned::new(unsafe { CFNumberCreate(std::ptr::null(), 3, (&raw const result).cast()) })?; + let dictionary = trust_dictionary(&result)?; + let settings = Owned::new(unsafe { + CFArrayCreate( + std::ptr::null(), + &raw const dictionary.0, + 1, + std::ptr::null(), + ) + })?; + // The OS may require native authorization before this operation succeeds. + // SAFETY: Certificate is native-owned and lives through synchronous call. + if unsafe { SecTrustSettingsSetTrustSettings(certificate.0, 0, settings.0) } != 0 { + return Err(PkiError::StoreChangeUnknown); + } + Ok(()) +} + +pub(super) fn import_pfx(bytes: &[u8], password: &str) -> Result, PkiError> { + let bytes = data(bytes)?; + let keychain = keychain()?; + // The passphrase is copied only into a native CFString. Its owned buffer is + // released after import and is never bridged to JavaScript, DTOs, or logs. + // OS/native control allocations cannot promise explicit byte zeroization. + // SAFETY: CF copies a valid bounded UTF-8 buffer. + let password = Owned::new(unsafe { + CFStringCreateWithBytes( + std::ptr::null(), + password.as_ptr(), + isize::try_from(password.len()).map_err(|_| PkiError::InvalidIdentity)?, + UTF8, + 0, + ) + })?; + // No-retain callbacks: local owners and Security's static keys outlive call. + let keys = unsafe { [kSecImportExportPassphrase, kSecImportExportKeychain] }; + let values = [password.0, keychain.0]; + // SAFETY: Both fixed arrays contain live CF references throughout import. + let options = Owned::new(unsafe { + CFDictionaryCreate( + std::ptr::null(), + keys.as_ptr(), + values.as_ptr(), + 2, + std::ptr::null(), + std::ptr::null(), + ) + })?; + let mut items = std::ptr::null(); + // Explicit keychain option avoids assuming PKCS12's platform default scope. + // SAFETY: Native objects live during synchronous import; API retains output. + if unsafe { SecPKCS12Import(bytes.0, options.0, &raw mut items) } != 0 { + return Err(PkiError::StoreChangeUnknown); + } + let items = Owned::new(items)?; + let count = unsafe { CFArrayGetCount(items.0) }; + if !(1..=64).contains(&count) { + return Err(PkiError::StoreChangeUnknown); + } + let mut fingerprints = Vec::new(); + for index in 0..count { + // SAFETY: PKCS12 import output is documented as array of dictionaries. + let dictionary = unsafe { CFArrayGetValueAtIndex(items.0, index) }; + let identity = unsafe { CFDictionaryGetValue(dictionary, kSecImportItemIdentity) }; + if identity.is_null() { + return Err(PkiError::StoreChangeUnknown); + } + let mut certificate = std::ptr::null(); + if unsafe { SecIdentityCopyCertificate(identity, &raw mut certificate) } != 0 { + return Err(PkiError::StoreChangeUnknown); + } + let certificate = Owned::new(certificate)?; + let der = Owned::new(unsafe { SecCertificateCopyData(certificate.0) })?; + let length = unsafe { CFDataGetLength(der.0) }; + if !(1..=65_536).contains(&length) { + return Err(PkiError::StoreChangeUnknown); + } + // SAFETY: CFData supplies a live bounded DER buffer until release. + let bytes = unsafe { + std::slice::from_raw_parts( + CFDataGetBytePtr(der.0), + usize::try_from(length).map_err(|_| PkiError::StoreChangeUnknown)?, + ) + }; + fingerprints.push(fingerprint(bytes)); + } + Ok(fingerprints) +} diff --git a/apps/desktop/native-browser/src/pki/macos/tests.rs b/apps/desktop/native-browser/src/pki/macos/tests.rs new file mode 100644 index 000000000..4d675d2a1 --- /dev/null +++ b/apps/desktop/native-browser/src/pki/macos/tests.rs @@ -0,0 +1,39 @@ +//! Pure `CoreFoundation` payload checks; no keychain or trust domain is modified. + +use super::{CF, CFDictionaryGetValue, CFNumberCreate, Owned, trust_dictionary}; + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFStringCreateWithCharacters(allocator: CF, characters: *const u16, length: isize) -> CF; +} + +fn key(value: &str) -> Owned { + let characters: Vec<_> = value.encode_utf16().collect(); + // SAFETY: CF copies the valid UTF-16 buffer into a retained test-only key. + Owned::new(unsafe { + CFStringCreateWithCharacters( + std::ptr::null(), + characters.as_ptr(), + isize::try_from(characters.len()).unwrap(), + ) + }) + .unwrap() +} + +#[test] +fn trust_result_key_is_retained_and_looked_up_by_cfstring_value() { + let value: i32 = 2; + // SAFETY: CF copies one SInt32 value into a retained native number. + let value = + Owned::new(unsafe { CFNumberCreate(std::ptr::null(), 3, (&raw const value).cast()) }) + .unwrap(); + // The dictionary retains its dynamic UTF-8 key after the builder returns. + let dictionary = trust_dictionary(&value).unwrap(); + let equivalent = key("kSecTrustSettingsResult"); + let other = key("kSecTrustSettingsPolicy"); + // SAFETY: Each dictionary/key/value is live for these pure native lookups. + unsafe { + assert_eq!(CFDictionaryGetValue(dictionary.0, equivalent.0), value.0); + assert!(CFDictionaryGetValue(dictionary.0, other.0).is_null()); + } +} diff --git a/apps/desktop/native-browser/src/pki/selection.rs b/apps/desktop/native-browser/src/pki/selection.rs new file mode 100644 index 000000000..526624dee --- /dev/null +++ b/apps/desktop/native-browser/src/pki/selection.rs @@ -0,0 +1,135 @@ +//! Public-certificate validation for one native, expiring handshake review. +//! +//! These native-only values are deliberately not deserializable or serializable. +//! A renderer can request review, but cannot supply an origin, certificate, or key. + +#![cfg_attr( + not(colossus_cef_linked), + allow( + dead_code, + reason = "An unlinked preview has the same native request API but cannot receive CEF handshakes." + ) +)] + +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use super::{PkiError, fingerprint}; + +pub(crate) const MAX_CANDIDATES: usize = 64; +pub(crate) const MAX_CERTIFICATE_BYTES: usize = 65_536; +const REVIEW_TIMEOUT: Duration = Duration::from_mins(2); + +/// An eligible public leaf identity offered by Chromium's native store lookup. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityCandidate { + pub(crate) index: i32, + not_before: i64, + not_after: i64, + /// SHA-256 of the exact public DER certificate, never its private key. + pub fingerprint_sha256: String, +} + +/// One exact native handshake request. Closing/revoking the tab invalidates it. +#[derive(Clone, Debug)] +pub struct IdentityRequest { + pub(crate) id: u64, + pub(crate) epoch: u64, + /// Canonical credential-free HTTPS origin reported by Chromium. + pub origin: String, + /// Valid, non-CA identities in the server's native candidate list. + pub candidates: Vec, + expires_at: Instant, +} + +impl IdentityRequest { + pub(crate) fn new( + id: u64, + epoch: u64, + origin: &str, + certificates: &[&[u8]], + ) -> Result { + if id == 0 || certificates.is_empty() || certificates.len() > MAX_CANDIDATES { + return Err(PkiError::InvalidIdentity); + } + exact_https_origin(origin)?; + let mut candidates = Vec::with_capacity(certificates.len()); + for (index, der) in certificates.iter().enumerate() { + // A malformed native list fails closed; valid but ineligible leafs + // are excluded without renumbering Chromium's candidate indexes. + if der.is_empty() || der.len() > MAX_CERTIFICATE_BYTES { + return Err(PkiError::InvalidIdentity); + } + if let Some((not_before, not_after)) = eligible(der)? { + let fingerprint_sha256 = fingerprint(der); + if candidates.iter().any(|candidate: &IdentityCandidate| { + candidate.fingerprint_sha256 == fingerprint_sha256 + }) { + return Err(PkiError::InvalidIdentity); + } + candidates.push(IdentityCandidate { + index: i32::try_from(index).map_err(|_| PkiError::InvalidIdentity)?, + not_before, + not_after, + fingerprint_sha256, + }); + } + } + if candidates.is_empty() { + return Err(PkiError::InvalidIdentity); + } + Ok(Self { + id, + epoch, + origin: origin.into(), + candidates, + expires_at: Instant::now() + REVIEW_TIMEOUT, + }) + } + + /// Native dialogs must dismiss expired reviews without selecting a key. + #[must_use] + pub fn is_expired(&self) -> bool { + Instant::now() >= self.expires_at + } + + pub(crate) fn candidate_index(&self, reviewed_fingerprint: &str) -> Option { + if self.is_expired() { + return None; + } + let now = + i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs()).ok()?; + self.candidates + .iter() + .find(|candidate| { + candidate.fingerprint_sha256 == reviewed_fingerprint + && candidate.not_before <= now + && now < candidate.not_after + }) + .map(|candidate| candidate.index) + } +} + +fn exact_https_origin(origin: &str) -> Result<(), PkiError> { + if origin.len() > 8192 { + return Err(PkiError::InvalidIdentity); + } + let value = url::Url::parse(origin).map_err(|_| PkiError::InvalidIdentity)?; + if value.scheme() != "https" + || value.host_str().is_none() + || !value.username().is_empty() + || value.password().is_some() + || value.query().is_some() + || value.fragment().is_some() + || value.origin().ascii_serialization() != origin + { + return Err(PkiError::InvalidIdentity); + } + Ok(()) +} + +fn eligible(der: &[u8]) -> Result, PkiError> { + colossus_native_browser_pki::identity_validity(der).map_err(|_| PkiError::InvalidIdentity) +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/native-browser/src/pki/selection/tests.rs b/apps/desktop/native-browser/src/pki/selection/tests.rs new file mode 100644 index 000000000..758e5aec9 --- /dev/null +++ b/apps/desktop/native-browser/src/pki/selection/tests.rs @@ -0,0 +1,75 @@ +use super::*; +use rcgen::{CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; + +fn leaf(mut params: CertificateParams) -> Vec { + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + params + .self_signed(&KeyPair::generate().unwrap()) + .unwrap() + .der() + .to_vec() +} + +#[test] +fn review_matches_only_exact_native_origin_and_certificate_fingerprint() { + let der = leaf(CertificateParams::new(Vec::::new()).unwrap()); + let review = IdentityRequest::new(7, 9, "https://example.com", &[&der]).unwrap(); + assert_eq!(review.candidate_index(&fingerprint(&der)), Some(0)); + assert_eq!(review.candidate_index(&"a".repeat(64)), None); + for origin in [ + "http://example.com", + "https://example.com/", + "https://example.com:443", + "https://user@example.com", + "https://example.com?x=1", + "https://EXAMPLE.com", + "https://example.com/path", + ] { + assert!( + IdentityRequest::new(7, 9, origin, &[&der]).is_err(), + "{origin}" + ); + } + assert!(IdentityRequest::new(7, 9, "https://example.com:8443", &[&der]).is_ok()); +} + +#[test] +fn invalid_expired_ca_and_wrong_usage_candidates_do_not_select_keys() { + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca = leaf(params); + assert!(IdentityRequest::new(1, 1, "https://example.com", &[&ca]).is_err()); + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.not_before = rcgen::date_time_ymd(2000, 1, 1); + params.not_after = rcgen::date_time_ymd(2001, 1, 1); + let expired = leaf(params); + assert!(IdentityRequest::new(1, 1, "https://example.com", &[&expired]).is_err()); + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let wrong_usage = params.self_signed(&KeyPair::generate().unwrap()).unwrap(); + assert!( + IdentityRequest::new(1, 1, "https://example.com", &[wrong_usage.der().as_ref()]).is_err() + ); + assert!(IdentityRequest::new(1, 1, "https://example.com", &[b"malformed"]).is_err()); +} + +#[test] +fn reviews_expire_and_preserve_original_candidate_indexes_without_ambiguity() { + let valid = leaf(CertificateParams::new(Vec::::new()).unwrap()); + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let excluded = leaf(params); + let mut review = + IdentityRequest::new(7, 9, "https://example.com", &[&excluded, &valid]).unwrap(); + assert_eq!(review.candidate_index(&fingerprint(&valid)), Some(1)); + review.candidates[0].not_after = 1; + assert_eq!(review.candidate_index(&fingerprint(&valid)), None); + review.expires_at = Instant::now(); + assert!(review.is_expired()); + assert_eq!(review.candidate_index(&fingerprint(&valid)), None); + assert!(IdentityRequest::new(7, 9, "https://example.com", &[&valid, &valid]).is_err()); + assert!( + IdentityRequest::new(7, 9, "https://example.com", &vec![valid.as_slice(); 65]).is_err() + ); +} diff --git a/apps/desktop/native-browser/src/pki/windows.rs b/apps/desktop/native-browser/src/pki/windows.rs new file mode 100644 index 000000000..ea4d1d9fa --- /dev/null +++ b/apps/desktop/native-browser/src/pki/windows.rs @@ -0,0 +1,155 @@ +//! Current User stores only; keys are never marked exportable or overwritten. + +use windows_sys::Win32::Security::Cryptography::{ + CERT_CONTEXT, CERT_KEY_PROV_INFO_PROP_ID, CERT_STORE_ADD_NEW, CRYPT_INTEGER_BLOB, + CRYPT_USER_KEYSET, CertAddCertificateContextToStore, CertCloseStore, + CertCreateCertificateContext, CertEnumCertificatesInStore, CertFreeCertificateContext, + CertGetCertificateContextProperty, CertOpenSystemStoreW, HCERTSTORE, PFXImportCertStore, + PFXVerifyPassword, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING, +}; +use zeroize::Zeroizing; + +use super::{PkiError, fingerprint}; + +struct Store(HCERTSTORE); +impl Drop for Store { + fn drop(&mut self) { + // SAFETY: This owner holds exactly one returned store handle. + unsafe { + CertCloseStore(self.0, 0); + } + } +} +struct Certificate(*const CERT_CONTEXT); +impl Drop for Certificate { + fn drop(&mut self) { + // SAFETY: This owner holds exactly one certificate context reference. + unsafe { + CertFreeCertificateContext(self.0); + } + } +} + +fn user_store(name: &str) -> Result { + let name = name.encode_utf16().chain(Some(0)).collect::>(); + // SAFETY: Null provider and fixed ROOT/MY names select Current User stores. + let store = unsafe { CertOpenSystemStoreW(0, name.as_ptr()) }; + if store.is_null() { + Err(PkiError::Unavailable) + } else { + Ok(Store(store)) + } +} + +pub(super) fn import_ca(der: &[u8]) -> Result<(), PkiError> { + let store = user_store("ROOT")?; + // SAFETY: Validated bounded DER is borrowed for this call; API returns a ref. + let context = unsafe { + CertCreateCertificateContext( + X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, + der.as_ptr(), + u32::try_from(der.len()).map_err(|_| PkiError::InvalidCertificate)?, + ) + }; + if context.is_null() { + return Err(PkiError::InvalidCertificate); + } + let context = Certificate(context); + // ADD_NEW prevents silently replacing an existing trust record/properties. + // SAFETY: Both handles live through this synchronous native call. + if unsafe { + CertAddCertificateContextToStore( + store.0, + context.0, + CERT_STORE_ADD_NEW, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(PkiError::Unavailable); + } + Ok(()) +} + +pub(super) fn import_pfx(bytes: &[u8], password: &str) -> Result, PkiError> { + let mut password = Zeroizing::new(password.encode_utf16().chain(Some(0)).collect::>()); + let blob = CRYPT_INTEGER_BLOB { + cbData: u32::try_from(bytes.len()).map_err(|_| PkiError::InvalidIdentity)?, + pbData: bytes.as_ptr().cast_mut(), + }; + // SAFETY: Borrowed PFX and zeroizing password remain alive. First prove + // password/structure before the persistent key import has side effects. + if unsafe { PFXVerifyPassword(&raw const blob, password.as_ptr(), 0) } == 0 { + return Err(PkiError::InvalidIdentity); + } + let target = user_store("MY")?; + // CRYPT_USER_KEYSET creates user keys; neither CRYPT_EXPORTABLE nor + // PKCS12_ALLOW_OVERWRITE_KEY is requested. Never use NO_PERSIST_KEY for the + // effective import: Chromium helpers must access the native persisted key. + // SAFETY: Native buffers are valid for synchronous import; no key leaves OS. + let imported = + unsafe { PFXImportCertStore(&raw const blob, password.as_mut_ptr(), CRYPT_USER_KEYSET) }; + if imported.is_null() { + return Err(PkiError::StoreChangeUnknown); + } + let imported = Store(imported); + let mut previous = std::ptr::null(); + let mut fingerprints = Vec::new(); + loop { + // Enumeration frees the previous reference on each call, including end. + // SAFETY: Store lives throughout enumeration; previous is API-owned ref. + let certificate = unsafe { CertEnumCertificatesInStore(imported.0, previous) }; + if certificate.is_null() { + break; + } + previous = certificate; + if fingerprints.len() >= 64 { + // SAFETY: Enumeration will not consume this remaining reference. + unsafe { + CertFreeCertificateContext(certificate); + } + return Err(PkiError::StoreChangeUnknown); + } + let mut property_size = 0; + // Only identities with a persisted provider binding enter MY. Root and + // intermediate certificates in a PFX never become new trust anchors. + // SAFETY: Query with null data obtains property length only. + let has_key = unsafe { + CertGetCertificateContextProperty( + certificate, + CERT_KEY_PROV_INFO_PROP_ID, + std::ptr::null_mut(), + &raw mut property_size, + ) + } != 0; + if !has_key { + continue; + } + // SAFETY: Context's DER buffer lives until next enumeration step. + let der = unsafe { + std::slice::from_raw_parts( + (*certificate).pbCertEncoded, + (*certificate).cbCertEncoded as usize, + ) + }; + if unsafe { + CertAddCertificateContextToStore( + target.0, + certificate, + CERT_STORE_ADD_NEW, + std::ptr::null_mut(), + ) + } == 0 + { + unsafe { + CertFreeCertificateContext(certificate); + } + return Err(PkiError::StoreChangeUnknown); + } + fingerprints.push(fingerprint(der)); + } + if fingerprints.is_empty() { + return Err(PkiError::StoreChangeUnknown); + } + Ok(fingerprints) +} diff --git a/apps/desktop/native-browser/src/presentation.rs b/apps/desktop/native-browser/src/presentation.rs new file mode 100644 index 000000000..9a36cda74 --- /dev/null +++ b/apps/desktop/native-browser/src/presentation.rs @@ -0,0 +1,480 @@ +//! Trusted owned native pixel views for a separately contained offscreen host. +//! This adapter does not grant browser authority or enable production automation. +mod callbacks; +mod ffi; +mod lifecycle; +#[cfg(all(windows, feature = "native-test-driver"))] +mod windows_acceptance; +use crate::BrowserError; +#[cfg(feature = "native-test-driver")] +use colossus_browser_presentation::Frame; +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +use colossus_browser_presentation::PresentationError; +use colossus_browser_presentation::{FrameCodec, Input, LatestFrames, Lease, LeaseGuard}; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; +use tauri::Window; +use tokio::sync::oneshot; +#[cfg(all(windows, feature = "native-test-driver"))] +pub use windows_acceptance::WindowsScreenEvidence; +use zeroize::Zeroizing; + +struct Owner { + guard: Mutex, + input: Box, + focus: Box, + frames: Mutex, + scheduled: AtomicBool, + #[cfg(feature = "native-test-driver")] + accepted_frame: Mutex>, +} + +/// One authenticated frame that the native view synchronously accepted and made visible. +#[cfg(feature = "native-test-driver")] +#[derive(Clone, Copy, Debug)] +pub(crate) struct AcceptedNativeFrame { + pub lease: Lease, + pub sequence: u64, + pub center_bgra: [u8; 4], + pub count: u64, +} + +/// Fixed, private `AppKit` focus facts for the source-only Mac acceptance run. +/// No window title, process identity, pointer, or page data is exposed. +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[derive(Clone, Copy)] +pub struct AcceptanceFocusState(u32); + +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +impl AcceptanceFocusState { + /// Whether the retained Tauri parent still belongs to a native window. + #[must_use] + pub fn parent_attached(self) -> bool { + self.0 & 1 != 0 + } + + /// Whether that native window belongs to this Desktop application. + #[must_use] + pub fn own_window(self) -> bool { + self.0 & 2 != 0 + } + + /// Whether the native window is visible. + #[must_use] + pub fn window_visible(self) -> bool { + self.0 & 4 != 0 + } + + /// Whether `NSApp` is active in this desktop session. + #[must_use] + pub fn app_active(self) -> bool { + self.0 & 8 != 0 + } + + /// Whether the retained parent window reports that it is key. + #[must_use] + pub fn window_key(self) -> bool { + self.0 & 16 != 0 + } + + /// Whether `NSApp` reports the retained parent as its key window. + #[must_use] + pub fn app_key_matches(self) -> bool { + self.0 & 32 != 0 + } + + /// Whether this exact Desktop window is active and key under `AppKit`. + #[must_use] + pub fn foreground(self) -> bool { + self.0 == 0b11_1111 + } +} + +#[cfg(feature = "native-test-driver")] +fn center_bgra(frame: &Frame) -> Option<[u8; 4]> { + let index = u64::from(frame.lease.pixel_height / 2) + .checked_mul(u64::from(frame.lease.pixel_width))? + .checked_add(u64::from(frame.lease.pixel_width / 2))? + .checked_mul(4)?; + let offset = usize::try_from(index).ok()?; + frame + .pixels + .get(offset..offset.checked_add(4)?)? + .try_into() + .ok() +} +/// Native view owned by Desktop, consuming authenticated page pixels and events. +/// The supervisor must bind callbacks to a separately authenticated input channel. +pub struct PresentationSurface { + window: Window, + handle: AtomicUsize, + owner: Arc, + codec: Mutex, +} +impl PresentationSurface { + /// Create a hidden native view from the trusted application window and enrollment. + /// + /// # Errors + /// Rejects invalid leases, unavailable native parents or main-thread scheduling. + pub async fn create( + window: &Window, + lease: Lease, + enrollment_digest: [u8; 32], + key: Zeroizing<[u8; 32]>, + input: impl Fn(Lease, Input, u32) + Send + Sync + 'static, + focus: impl Fn(Lease, bool) + Send + Sync + 'static, + ) -> Result { + let guard = LeaseGuard::new(lease, Instant::now(), Duration::from_millis(1500)) + .map_err(|_| BrowserError::Unavailable)?; + let codec = FrameCodec::new(key, enrollment_digest, lease) + .map_err(|_| BrowserError::Unavailable)?; + let frames = LatestFrames::new(lease).map_err(|_| BrowserError::Unavailable)?; + let owner = Arc::new(Owner { + guard: Mutex::new(guard), + input: Box::new(input), + focus: Box::new(focus), + frames: Mutex::new(frames), + scheduled: AtomicBool::new(false), + #[cfg(feature = "native-test-driver")] + accepted_frame: Mutex::new(None), + }); + let native_owner = Arc::clone(&owner); + let native_window = window.clone(); + let (send, receive) = oneshot::channel(); + window + .run_on_main_thread(move || { + let parent = parent(&native_window); + let retained = Arc::into_raw(native_owner); + // SAFETY: native view borrows an Arc retained until acknowledged destruction; callbacks never unwind. + let handle = parent.map_or(0, |parent| unsafe { + ffi::colossus_presenter_create( + parent, + retained.cast_mut().cast(), + callbacks::input, + callbacks::focus, + ) + }); + if handle != 0 { + // SAFETY: fresh native view, exact trusted viewport epoch and bounded TTL. + let _ = unsafe { + ffi::colossus_presenter_lease(handle, lease.viewport_generation, 1500) + }; + } + if handle == 0 { + // SAFETY: no native view retained the owner when creation failed. + unsafe { + drop(Arc::from_raw(retained)); + } + } + if send.send(handle).is_err() && handle != 0 { + // SAFETY: exact freshly created view on its owning UI thread. + if unsafe { ffi::colossus_presenter_destroy(handle) } == 0 { + // SAFETY: the view acknowledged removing its last callback reference. + unsafe { + drop(Arc::from_raw(retained)); + } + } + } + }) + .map_err(|_| BrowserError::Closed)?; + let handle = receive.await.map_err(|_| BrowserError::Closed)?; + if handle == 0 { + return Err(BrowserError::Unavailable); + } + Ok(Self { + window: window.clone(), + handle: AtomicUsize::new(handle), + owner, + codec: Mutex::new(codec), + }) + } + /// Consume one authenticated frame and paint only while native visibility is live. + /// + /// # Errors + /// Rejects replayed, stale, malformed or unauthenticated bytes and expired leases. + pub fn present(&self, envelope: Vec) -> Result<(), BrowserError> { + let frame = self + .codec + .lock() + .map_err(|_| BrowserError::Closed)? + .decode(envelope) + .inspect_err(|error| { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + trace_frame_rejection("decode", *error); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = error; + }) + .map_err(|_| BrowserError::Closed)?; + self.owner + .frames + .lock() + .map_err(|_| BrowserError::Closed)? + .push(frame) + .inspect_err(|error| { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + trace_frame_rejection("queue", *error); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = error; + }) + .map_err(|_| BrowserError::Closed)?; + schedule( + &self.window, + &self.owner, + self.handle.load(Ordering::Acquire), + ) + } + + /// Observe an authenticated frame synchronously accepted by the native view. + /// This does not establish that macOS composited the view in the foreground. + #[cfg(feature = "native-test-driver")] + pub(crate) fn acceptance_accepted_frame( + &self, + ) -> Result, BrowserError> { + Ok(*self + .owner + .accepted_frame + .lock() + .map_err(|_| BrowserError::Closed)?) + } + + /// Query whether the actual native view is hidden and has cleared pixels. + /// The caller must first establish that a frame reached this same view. + /// + /// # Errors + /// Rejects a retired view or failed main-thread observation. + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + pub(crate) async fn acceptance_hidden(&self) -> Result { + let handle = self.handle.load(Ordering::Acquire); + if handle == 0 { + return Err(BrowserError::Closed); + } + let (send, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + // SAFETY: the exact retained view is queried on its owning UI thread. + let status = unsafe { ffi::colossus_presenter_acceptance_hidden(handle) }; + let _ = send.send(match status { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(BrowserError::Closed), + }); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + } + + /// Update trusted native bounds; logical coordinates on macOS, physical on Windows. + /// + /// # Errors + /// Rejects invalid dimensions, closed view or main-thread scheduling failure. + pub fn set_bounds(&self, x: i32, y: i32, width: i32, height: i32) -> Result<(), BrowserError> { + if x < 0 || y < 0 || !(1..=16384).contains(&width) || !(1..=16384).contains(&height) { + return Err(BrowserError::Unavailable); + } + let handle = self.handle.load(Ordering::Acquire); + self.window + .run_on_main_thread(move || { + // SAFETY: exact native view/owning UI; bounds validated before scheduling. + let _ = unsafe { + ffi::colossus_presenter_bounds( + handle, + ffi::Bounds { + x, + y, + width, + height, + }, + ) + }; + }) + .map_err(|_| BrowserError::Closed) + } + /// Renew an unchanged visible lease. An expired view requires fresh native ownership. + /// + /// # Errors + /// Rejects hidden, expired or changed bindings. + pub fn renew(&self, lease: Lease) -> Result<(), BrowserError> { + self.renew_for(lease, Duration::from_millis(1500)) + } + /// Renew only the remaining trusted native visibility heartbeat. + /// + /// # Errors + /// Rejects hidden, expired, changed or excessive leases. + pub fn renew_for(&self, lease: Lease, ttl: Duration) -> Result<(), BrowserError> { + self.owner + .guard + .lock() + .map_err(|_| BrowserError::Closed)? + .renew(lease, Instant::now(), ttl) + .map_err(|_| BrowserError::Closed)?; + let handle = self.handle.load(Ordering::Acquire); + let until = Instant::now() + ttl; + self.window + .run_on_main_thread(move || { + let remaining = + u32::try_from(until.saturating_duration_since(Instant::now()).as_millis()) + .unwrap_or(0); + // SAFETY: native owned view; expired native epochs refuse renewal too. + let _ = unsafe { + ffi::colossus_presenter_lease(handle, lease.viewport_generation, remaining) + }; + }) + .map_err(|_| BrowserError::Closed) + } +} +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +fn trace_frame_rejection(stage: &'static str, error: PresentationError) { + let failure = match error { + PresentationError::Invalid => "invalid", + PresentationError::Stale => "stale", + PresentationError::Unauthenticated => "unauthenticated", + PresentationError::Hidden => "hidden", + PresentationError::LimitExceeded => "limit_exceeded", + PresentationError::OutcomeUnknown => "outcome_unknown", + }; + eprintln!("EVIDENCE mac-remote-frame stage={stage} failure={failure}"); +} +#[cfg(windows)] +fn parent(window: &Window) -> Option { + window.hwnd().ok().map(|handle| handle.0 as usize) +} +#[cfg(target_os = "macos")] +fn parent(window: &Window) -> Option { + window.ns_view().ok().map(|handle| handle as usize) +} + +/// Request activation of the diagnostic fixture's own Desktop window and +/// independently observe bounded `AppKit` foreground facts. An activation +/// request alone is never foreground evidence. +/// +/// # Errors +/// Rejects a missing parent, a window outside this application, or a failed +/// main-thread dispatch. This source-only helper cannot create a CEF browser. +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +pub async fn acceptance_focus_state( + window: &Window, + request_activation: bool, +) -> Result { + let native_window = window.clone(); + let (send, receive) = oneshot::channel(); + window + .run_on_main_thread(move || { + let result = parent(&native_window) + .ok_or(BrowserError::Closed) + .and_then(|parent| { + if request_activation { + // SAFETY: the parent is the fixture's retained Tauri NSView; + // native admission checks ownership on this main thread. + let status = + unsafe { ffi::colossus_presenter_acceptance_activate_parent(parent) }; + if status != 0 { + return Err(BrowserError::Unavailable); + } + } + let mut flags = 0_u32; + // SAFETY: the retained Tauri parent is observed only on its + // owning main thread; native output is one bounded bitmask. + let status = unsafe { + ffi::colossus_presenter_acceptance_focus_state(parent, &raw mut flags) + }; + if status != 0 || flags & !0b11_1111 != 0 { + return Err(BrowserError::Unavailable); + } + Ok(AcceptanceFocusState(flags)) + }); + let _ = send.send(result); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? +} + +fn schedule(window: &Window, owner: &Arc, handle: usize) -> Result<(), BrowserError> { + if owner.scheduled.swap(true, Ordering::AcqRel) { + return Ok(()); + } + let scheduled_owner = Arc::clone(owner); + let native_window = window.clone(); + let result = window.run_on_main_thread(move || { + let owner = scheduled_owner; + let frame = owner + .frames + .lock() + .ok() + .and_then(|mut frames| frames.take_latest()); + if let Some(frame) = frame { + let live = owner + .guard + .lock() + .is_ok_and(|guard| guard.visible(Instant::now()) && guard.lease() == frame.lease); + // No lock is held while native callbacks can re-enter ownership. + if live { + // SAFETY: validated owned bounded BGRA remains borrowed through synchronous copy. + if unsafe { + ffi::colossus_presenter_frame( + handle, + frame.lease.pixel_width, + frame.lease.pixel_height, + frame.pixels.as_ptr(), + frame.pixels.len(), + ) + } == 0 + { + // SAFETY: exact owned view on its UI thread with a live native epoch. + let shown = unsafe { ffi::colossus_presenter_visible(handle, 1) } == 0; + #[cfg(feature = "native-test-driver")] + if shown + && let Some(center_bgra) = center_bgra(&frame) + && let Ok(mut accepted) = owner.accepted_frame.lock() + { + let count = accepted + .as_ref() + .map_or(0, |last| last.count) + .saturating_add(1); + *accepted = Some(AcceptedNativeFrame { + lease: frame.lease, + sequence: frame.sequence, + center_bgra, + count, + }); + } + #[cfg(not(feature = "native-test-driver"))] + let _ = shown; + } + } else { + // SAFETY: native hide clears cached pixels/focus on expiry. + let _ = unsafe { ffi::colossus_presenter_visible(handle, 0) }; + } + } + owner.scheduled.store(false, Ordering::Release); + let pending = owner + .frames + .lock() + .is_ok_and(|frames| frames.retained_bytes() != 0); + if pending { + let _ = schedule(&native_window, &owner, handle); + } + }); + if result.is_err() { + owner.scheduled.store(false, Ordering::Release); + } + result.map_err(|_| BrowserError::Closed) +} diff --git a/apps/desktop/native-browser/src/presentation/callbacks.rs b/apps/desktop/native-browser/src/presentation/callbacks.rs new file mode 100644 index 000000000..8725047fa --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/callbacks.rs @@ -0,0 +1,111 @@ +use super::{Owner, ffi}; +use colossus_browser_presentation::Input; +use std::{ + ffi::c_void, + panic::{AssertUnwindSafe, catch_unwind}, + sync::Arc, + time::Instant, +}; + +pub(super) unsafe extern "C" fn focus(owner: *mut c_void, focused: i32) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if owner.is_null() { + return; + } + // SAFETY: native view retains an Arc at callback entry. This extra retain + // keeps reentrant destruction from freeing the owner during the callback. + let owner = unsafe { + Arc::increment_strong_count(owner.cast::()); + Arc::from_raw(owner.cast::()) + }; + let Ok(mut guard) = owner.guard.lock() else { + return; + }; + let focused = focused == 1; + if guard.focus(focused, Instant::now()).is_ok() { + let lease = guard.lease(); + drop(guard); + (owner.focus)(lease, focused); + } + })); +} +pub(super) unsafe extern "C" fn input(owner: *mut c_void, raw: *const ffi::Input) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if owner.is_null() { + return; + } + // SAFETY: native owner retain is live at callback entry; an independent + // callback retain survives reentrant destruction of the native view. + let owner = unsafe { + Arc::increment_strong_count(owner.cast::()); + Arc::from_raw(owner.cast::()) + }; + // SAFETY: native synchronous callback supplies this borrowed input record. + let Some(raw) = (unsafe { raw.as_ref() }) else { + return; + }; + if raw.version != 1 || raw.modifiers & !0x1fff != 0 { + return; + } + let input = match raw.kind { + 1 => Input::MouseMove { + x: u32::try_from(raw.x).unwrap_or(u32::MAX), + y: u32::try_from(raw.y).unwrap_or(u32::MAX), + }, + 2 | 3 => Input::MouseButton { + x: u32::try_from(raw.x).unwrap_or(u32::MAX), + y: u32::try_from(raw.y).unwrap_or(u32::MAX), + button: u8::try_from(raw.button).unwrap_or(u8::MAX), + pressed: raw.kind == 2, + }, + 4 => Input::MouseWheel { + x: u32::try_from(raw.x).unwrap_or(u32::MAX), + y: u32::try_from(raw.y).unwrap_or(u32::MAX), + delta_x: raw.wheel_x, + delta_y: raw.wheel_y, + }, + 5 | 6 => { + let Ok(code) = u16::try_from(raw.key_code) else { + return; + }; + Input::Key { + code, + pressed: raw.kind == 5, + } + } + 7 => { + let Some(character) = u32::try_from(raw.key_code).ok().and_then(char::from_u32) + else { + return; + }; + Input::Character { + text: character.to_string(), + } + } + 8 => { + if raw.text.is_null() || raw.text_units == 0 || raw.text_units > 4096 { + return; + } + // SAFETY: native callback borrows this bounded UTF16 buffer only during this call. + let units = unsafe { std::slice::from_raw_parts(raw.text, raw.text_units) }; + let Ok(text) = String::from_utf16(units) else { + return; + }; + Input::ImeCommit { text } + } + 9 => Input::ImeCancel, + _ => return, + }; + let Ok(guard) = owner.guard.lock() else { + return; + }; + if guard + .authorize_input(guard.lease(), &input, Instant::now()) + .is_ok() + { + let lease = guard.lease(); + drop(guard); + (owner.input)(lease, input, raw.modifiers); + } + })); +} diff --git a/apps/desktop/native-browser/src/presentation/ffi.rs b/apps/desktop/native-browser/src/presentation/ffi.rs new file mode 100644 index 000000000..88329fdc4 --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/ffi.rs @@ -0,0 +1,72 @@ +//! Private native pixel-view ABI; pointers are never web IPC values. +use std::ffi::c_void; +#[repr(C)] +pub(super) struct Bounds { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} +#[repr(C)] +pub(super) struct Input { + pub version: u32, + pub kind: u32, + pub modifiers: u32, + pub x: i32, + pub y: i32, + pub button: i32, + pub wheel_x: i32, + pub wheel_y: i32, + pub key_code: i32, + pub text: *const u16, + pub text_units: usize, +} +#[cfg(all(windows, feature = "native-test-driver"))] +#[derive(Default)] +#[repr(C)] +pub(super) struct WindowsScreen { + pub width: u32, + pub height: u32, + pub dpi: u32, + pub foreground: u32, + pub visible: u32, + pub hit_test: u32, + pub center_bgra: [u8; 4], +} +unsafe extern "C" { + pub(super) fn colossus_presenter_create( + parent: usize, + owner: *mut c_void, + input: unsafe extern "C" fn(*mut c_void, *const Input), + focus: unsafe extern "C" fn(*mut c_void, i32), + ) -> usize; + pub(super) fn colossus_presenter_frame( + view: usize, + width: u32, + height: u32, + pixels: *const u8, + bytes: usize, + ) -> i32; + pub(super) fn colossus_presenter_bounds(view: usize, bounds: Bounds) -> i32; + pub(super) fn colossus_presenter_visible(view: usize, visible: i32) -> i32; + pub(super) fn colossus_presenter_lease(view: usize, epoch: u64, lease_ms: u32) -> i32; + pub(super) fn colossus_presenter_destroy(view: usize) -> i32; + #[cfg(all(windows, feature = "native-test-driver"))] + pub(super) fn colossus_presenter_screen_probe(view: usize, screen: *mut WindowsScreen) -> i32; + #[cfg(all(windows, feature = "native-test-driver"))] + pub(super) fn colossus_presenter_screen_capture( + view: usize, + width: u32, + height: u32, + pixels: *mut u8, + bytes: usize, + ) -> i32; + #[cfg(all(windows, feature = "native-test-driver"))] + pub(super) fn colossus_presenter_fixture_input(view: usize) -> i32; + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + pub(super) fn colossus_presenter_acceptance_activate_parent(parent: usize) -> i32; + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + pub(super) fn colossus_presenter_acceptance_focus_state(parent: usize, flags: *mut u32) -> i32; + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + pub(super) fn colossus_presenter_acceptance_hidden(view: usize) -> i32; +} diff --git a/apps/desktop/native-browser/src/presentation/lifecycle.rs b/apps/desktop/native-browser/src/presentation/lifecycle.rs new file mode 100644 index 000000000..6ac0855de --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/lifecycle.rs @@ -0,0 +1,125 @@ +use super::{Owner, PresentationSurface, ffi}; +use crate::BrowserError; +use colossus_browser_presentation::Lease; +use std::{ + sync::{Arc, atomic::Ordering}, + time::{Duration, Instant}, +}; +use tokio::sync::oneshot; + +impl PresentationSurface { + fn revoke(&self) -> Result<(), BrowserError> { + self.owner + .guard + .lock() + .map_err(|_| BrowserError::Closed)? + .hide(); + self.owner + .frames + .lock() + .map_err(|_| BrowserError::Closed)? + .clear(); + Ok(()) + } + /// Revoke input synchronously and schedule native pixel/focus removal. + /// + /// # Errors + /// Reports poisoned ownership or unavailable UI scheduling. + pub fn hide(&self) -> Result<(), BrowserError> { + self.revoke()?; + let handle = self.handle.load(Ordering::Acquire); + self.window + .run_on_main_thread(move || { + // SAFETY: exact native identity; retired identities cannot route reused OS handles. + let _ = unsafe { ffi::colossus_presenter_visible(handle, 0) }; + }) + .map_err(|_| BrowserError::Closed) + } + /// Wait until native pixels and input focus are removed on the owning UI thread. + /// + /// # Errors + /// Scheduling, native removal or missing acknowledgment never proves a hide barrier. + pub async fn hide_confirmed(&self) -> Result<(), BrowserError> { + self.revoke()?; + let handle = self.handle.load(Ordering::Acquire); + let (send, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + // SAFETY: exact native view; acknowledgment is sent only after actual native removal. + let status = unsafe { ffi::colossus_presenter_visible(handle, 0) }; + let _ = send.send(status == 0); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + .then_some(()) + .ok_or(BrowserError::Closed) + } + /// Renew an unchanged lease only after the real native timer acknowledges it. + /// + /// # Errors + /// An expired native epoch requires a fresh view; scheduling cannot resurrect it. + pub async fn renew_confirmed(&self, lease: Lease, ttl: Duration) -> Result<(), BrowserError> { + self.owner + .guard + .lock() + .map_err(|_| BrowserError::Closed)? + .renew(lease, Instant::now(), ttl) + .map_err(|_| BrowserError::Closed)?; + let until = Instant::now() + ttl; + let handle = self.handle.load(Ordering::Acquire); + let (send, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + let remaining = + u32::try_from(until.saturating_duration_since(Instant::now()).as_millis()) + .unwrap_or(0); + // SAFETY: exact native epoch and TTL measured after main-thread scheduling delay. + let status = unsafe { + ffi::colossus_presenter_lease(handle, lease.viewport_generation, remaining) + }; + let _ = send.send(status == 0); + }) + .map_err(|_| BrowserError::Closed)?; + let accepted = tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + if !accepted { + self.revoke()?; + return Err(BrowserError::Closed); + } + self.owner + .guard + .lock() + .map_err(|_| BrowserError::Closed)? + .renew( + lease, + Instant::now(), + until.saturating_duration_since(Instant::now()), + ) + .map_err(|_| BrowserError::Closed) + } +} +impl Drop for PresentationSurface { + fn drop(&mut self) { + let _ = self.revoke(); + let handle = self.handle.swap(0, Ordering::AcqRel); + if handle == 0 { + return; + } + let retained = Arc::as_ptr(&self.owner) as usize; + let _ = self.window.run_on_main_thread(move || { + // SAFETY: native view retains this separate Arc until acknowledged destruction. + if unsafe { ffi::colossus_presenter_destroy(handle) } == 0 { + // SAFETY: release exactly the Arc::into_raw retain installed at creation. + unsafe { + drop(Arc::from_raw(retained as *const Owner)); + } + } + // Unacknowledged cleanup deliberately retains callback ownership. + }); + } +} diff --git a/apps/desktop/native-browser/src/presentation/windows_acceptance.rs b/apps/desktop/native-browser/src/presentation/windows_acceptance.rs new file mode 100644 index 000000000..8eae47ea5 --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/windows_acceptance.rs @@ -0,0 +1,99 @@ +//! Closed native fixture probes; never renderer IPC or production authority. +use super::{PresentationSurface, ffi}; +use crate::BrowserError; +use std::{sync::atomic::Ordering, time::Duration}; +use tokio::sync::oneshot; + +/// Actual foreground Windows desktop pixels covering this exact native view. +#[derive(Debug)] +pub struct WindowsScreenEvidence { + /// Physical client width. + pub width: u32, + /// Physical client height. + pub height: u32, + /// Actual native window DPI. + pub dpi: u32, + /// The native parent is the foreground window. + pub foreground: bool, + /// The native child is visible. + pub visible: bool, + /// Windows hit testing identifies this exact presenter at its center. + pub hit_test: bool, + /// Center pixel sampled from the desktop DC. + pub center_bgra: [u8; 4], + /// Top-down BGRA bytes captured from the desktop DC, bounded to one viewport. + pub pixels: Vec, +} + +impl PresentationSurface { + pub(crate) async fn screen(&self) -> Result { + let handle = self.handle.load(Ordering::Acquire); + let (send, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + let result = (|| { + let mut probe = ffi::WindowsScreen::default(); + // SAFETY: live opaque identity on its UI thread and correctly sized output. + if unsafe { ffi::colossus_presenter_screen_probe(handle, &raw mut probe) } != 0 + { + return Err(BrowserError::Unavailable); + } + let bytes = + usize::try_from(u64::from(probe.width) * u64::from(probe.height) * 4) + .ok() + .filter(|bytes| { + *bytes <= colossus_browser_presentation::MAX_FRAME_BYTES + }) + .ok_or(BrowserError::Unavailable)?; + let mut pixels = vec![0; bytes]; + // SAFETY: bounded owned output and exact dimensions just measured by Windows. + if unsafe { + ffi::colossus_presenter_screen_capture( + handle, + probe.width, + probe.height, + pixels.as_mut_ptr(), + pixels.len(), + ) + } != 0 + { + return Err(BrowserError::Unavailable); + } + Ok(WindowsScreenEvidence { + width: probe.width, + height: probe.height, + dpi: probe.dpi, + foreground: probe.foreground != 0, + visible: probe.visible != 0, + hit_test: probe.hit_test != 0, + center_bgra: probe.center_bgra, + pixels, + }) + })(); + let _ = send.send(result); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + } + + pub(crate) async fn fixture_input(&self) -> Result<(), BrowserError> { + let handle = self.handle.load(Ordering::Acquire); + let (send, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + // SAFETY: source-only fixed input sequence for the exact synthetic fixture view. + let status = unsafe { ffi::colossus_presenter_fixture_input(handle) }; + let _ = send.send(status == 0); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + .then_some(()) + .ok_or(BrowserError::Unavailable) + } +} diff --git a/apps/desktop/native-browser/src/remote.rs b/apps/desktop/native-browser/src/remote.rs new file mode 100644 index 000000000..cba14f7b9 --- /dev/null +++ b/apps/desktop/native-browser/src/remote.rs @@ -0,0 +1,502 @@ +//! Trusted Desktop attachment to an independently supervised offscreen host. +mod actor; +mod handoff; +mod input_queue; +mod lifecycle; +mod viewport; +#[cfg(all(windows, feature = "native-test-driver"))] +mod windows_acceptance; +use crate::{ + BrowserError, EventSink, NavigationAction, PageState, contained::PresentationRole, + presentation::PresentationSurface, +}; +use colossus_browser_presentation::{Configure, HumanCommand, Input, Lease, PresentationClient}; +#[cfg(all(any(windows, target_os = "macos"), feature = "native-test-driver"))] +use colossus_browser_presentation::{PageState as NativePageState, PresentationError}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, +}; +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +use std::time::Duration; +use std::time::Instant; +use tauri::{Rect, Window}; +use tokio::sync::mpsc; +use url::Url; + +pub use crate::contained::RemoteHostOwner; +#[derive(Clone, Copy, PartialEq)] +struct Bounds { + x: i32, + y: i32, + width: u32, + height: u32, + scale_milli: u32, +} +struct State { + configure: Configure, + lease: Lease, + bounds: Option, + applied: Option, + visible: bool, + heartbeat: Instant, + surface: Option>, +} +enum Event { + Focus(Lease, bool), + Input(Lease, Input, u32), + Human(HumanCommand), +} +struct Owner { + window: Window, + client: PresentationClient, + digest: [u8; 32], + native: Arc, + sink: EventSink, + state: Mutex, + events: mpsc::Sender, + revision: AtomicU64, + viewport: AtomicU64, + handles: AtomicUsize, + closing: AtomicBool, + updating: AtomicBool, + read_only: AtomicBool, + operation: tokio::sync::Mutex<()>, +} + +// Source-only, closed-vocabulary attach evidence. These enum variants contain +// no URL, enrollment material, path, native pointer or channel payload. +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +enum AttachFailure { + Configure(PresentationError), + NativeView(BrowserError), +} +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +impl AttachFailure { + const fn token(self) -> &'static str { + match self { + Self::Configure(PresentationError::Invalid) => "configure_invalid", + Self::Configure(PresentationError::Stale) => "configure_stale", + Self::Configure(PresentationError::Unauthenticated) => "configure_unauthenticated", + Self::Configure(PresentationError::Hidden) => "configure_hidden", + Self::Configure(PresentationError::LimitExceeded) => "configure_limit_exceeded", + Self::Configure(PresentationError::OutcomeUnknown) => "configure_outcome_unknown", + Self::NativeView(BrowserError::InvalidAddress) => "native_view_invalid_address", + Self::NativeView(BrowserError::Unavailable) => "native_view_unavailable", + Self::NativeView(BrowserError::Closed) => "native_view_closed", + Self::NativeView(BrowserError::Stale) => "native_view_stale", + Self::NativeView(BrowserError::TimedOut) => "native_view_timed_out", + Self::NativeView(BrowserError::ComponentMissing) => "native_view_component_missing", + Self::NativeView(BrowserError::BootstrapRequired) => "native_view_bootstrap_required", + Self::NativeView(BrowserError::NativeAcceptanceRequired) => { + "native_view_acceptance_required" + } + } + } +} +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +fn trace_attach_failure(failure: AttachFailure) { + eprintln!("EVIDENCE mac-remote-attach failure={}", failure.token()); +} +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +fn trace_frame_channel_failure(stage: &'static str, error: PresentationError, elapsed: Duration) { + let failure = match error { + PresentationError::Invalid => "invalid", + PresentationError::Stale => "stale", + PresentationError::Unauthenticated => "unauthenticated", + PresentationError::Hidden => "hidden", + PresentationError::LimitExceeded => "limit_exceeded", + PresentationError::OutcomeUnknown => "outcome_unknown", + }; + let timing = if elapsed >= Duration::from_millis(700) { + "after_700ms" + } else { + "before_700ms" + }; + eprintln!("EVIDENCE mac-remote-frame stage={stage} failure={failure} timing={timing}"); +} +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +fn trace_frame_browser_failure(stage: &'static str, error: BrowserError) { + eprintln!( + "EVIDENCE mac-remote-frame stage={stage} failure={}", + AttachFailure::NativeView(error).token() + ); +} +/// Cloneable human guest handle; page pixels travel only on the private native channel. +pub struct RemoteSurface(Arc); + +/// Debug evidence that a supervised remote view submitted authenticated pixels +/// to a live native presenter. Foreground OS compositing needs separate evidence. +#[cfg(all(any(windows, target_os = "macos"), feature = "native-test-driver"))] +#[derive(Clone, Debug)] +pub struct RemoteAcceptanceProbe { + /// Metadata and exact document target from the authenticated native channel. + pub page: NativePageState, + /// The trusted viewport remains visible and within its active lease. + pub visible: bool, + /// Native-applied logical viewport width. + pub width: u32, + /// Native-applied logical viewport height. + pub height: u32, + /// Last authenticated frame synchronously accepted by this native view. + pub frame: Option, +} + +/// Debug-only per-document pixels accepted by the native view; not OS-compositor evidence. +#[cfg(all(any(windows, target_os = "macos"), feature = "native-test-driver"))] +#[derive(Clone, Copy, Debug)] +pub struct RemoteAcceptedFrame { + /// Exact authenticated lease, including native document and viewport generations. + pub lease: Lease, + /// Host sequence authenticated with the frame pixels and lease. + pub sequence: u64, + /// Center BGRA pixel copied from the accepted frame after native acknowledgement. + pub center_bgra: [u8; 4], + /// Synchronous frame-and-visible acknowledgements for this surface instance. + pub count: u64, +} +impl Clone for RemoteSurface { + fn clone(&self) -> Self { + self.0.handles.fetch_add(1, Ordering::Relaxed); + Self(Arc::clone(&self.0)) + } +} +impl RemoteSurface { + /// Diagnostic loss of this viewer's outer stream. The separate native + /// admission owner must still acknowledge detach; this does not close Core's writer. + #[cfg(all(any(windows, target_os = "macos"), feature = "native-test-driver"))] + pub fn acceptance_disconnect_presentation(&self) { + self.0.client.disconnect(); + } + /// Read native presentation evidence for a source-only acceptance fixture. + /// + /// # Errors + /// Reports closed or poisoned native view state. + #[cfg(all(any(windows, target_os = "macos"), feature = "native-test-driver"))] + pub async fn acceptance_probe(&self) -> Result { + let lease = self.0.state.lock().map_err(|_| BrowserError::Closed)?.lease; + #[cfg(all(target_os = "macos", debug_assertions))] + let observed_at = Instant::now(); + let page = self.0.client.observe(lease).await.map_err(|error| { + #[cfg(all(target_os = "macos", debug_assertions))] + trace_frame_channel_failure("probe_observe", error, observed_at.elapsed()); + match error { + PresentationError::Stale | PresentationError::Hidden => BrowserError::Stale, + _ => BrowserError::Closed, + } + })?; + let state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + if state.lease != lease || state.configure.target != page.target { + return Err(BrowserError::Stale); + } + let bounds = state.applied.ok_or(BrowserError::Unavailable)?; + let surface = state.surface.as_ref().ok_or(BrowserError::Unavailable)?; + let frame = surface + .acceptance_accepted_frame()? + .map(|accepted| RemoteAcceptedFrame { + lease: accepted.lease, + sequence: accepted.sequence, + center_bgra: accepted.center_bgra, + count: accepted.count, + }); + if frame.is_some_and(|accepted| accepted.lease != lease) { + return Err(BrowserError::Stale); + } + Ok(RemoteAcceptanceProbe { + page, + visible: state.visible + && !self.0.closing.load(Ordering::Acquire) + && state.heartbeat.elapsed() < std::time::Duration::from_millis(1500), + width: bounds.width, + height: bounds.height, + frame, + }) + } + + /// Observe native pixel removal from the same view that accepted a frame + /// before the Desktop viewport was occluded. A local hide request alone is + /// insufficient evidence. + /// + /// # Errors + /// Rejects a missing or retired native presenter. + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + pub async fn acceptance_hidden_after( + &self, + previous: RemoteAcceptedFrame, + ) -> Result { + let surface = { + let state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + if state.visible { + return Ok(false); + } + state.surface.clone().ok_or(BrowserError::Unavailable)? + }; + let accepted = surface.acceptance_accepted_frame()?; + if !accepted.is_some_and(|accepted| { + accepted.lease == previous.lease + && accepted.sequence == previous.sequence + && accepted.count == previous.count + }) { + return Ok(false); + } + surface.acceptance_hidden().await + } + /// Attach a supervisor-admitted human session to the actual Desktop native window. + /// + /// # Errors + /// Rejects agent ownership, bad enrollment, unavailable native views or stale targets. + pub async fn create( + window: &Window, + client: PresentationClient, + configure: Configure, + digest: [u8; 32], + native: Arc, + sink: EventSink, + ) -> Result { + Self::attach(window, client, configure, digest, native, sink, false).await + } + /// Attach a native-admitted view of the exact agent page with all human effects disabled. + /// + /// # Errors + /// Requires independently admitted viewer enrollment and exact current control generation. + pub async fn create_read_only( + window: &Window, + client: PresentationClient, + configure: Configure, + digest: [u8; 32], + native: Arc, + sink: EventSink, + ) -> Result { + Self::attach(window, client, configure, digest, native, sink, true).await + } + #[allow(clippy::too_many_arguments)] + async fn attach( + window: &Window, + client: PresentationClient, + configure: Configure, + digest: [u8; 32], + native: Arc, + sink: EventSink, + read_only: bool, + ) -> Result { + let mut pending = lifecycle::Pending(Some(Arc::clone(&native))); + let role = if read_only { + PresentationRole::ReadOnly + } else { + PresentationRole::Human + }; + if !role.accepts_generation(configure.control_generation) { + return Err(BrowserError::Unavailable); + } + configure + .validate() + .map_err(|_| BrowserError::Unavailable)?; + let lease = client + .configure(configure.clone()) + .await + .map_err(|failure| { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + trace_attach_failure(AttachFailure::Configure(failure)); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = failure; + BrowserError::Closed + })?; + let (events, receiver) = mpsc::channel(input_queue::CAPACITY); + let surface = actor::create_surface(window, &client, &events, lease, digest, !read_only) + .await + .inspect_err(|failure| { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + trace_attach_failure(AttachFailure::NativeView(*failure)); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = failure; + })?; + let viewport = configure.viewport_generation; + let owner = Arc::new(Owner { + window: window.clone(), + client, + digest, + native, + sink, + state: Mutex::new(State { + configure, + lease, + bounds: None, + applied: None, + visible: false, + heartbeat: Instant::now(), + surface: Some(Arc::new(surface)), + }), + events, + revision: AtomicU64::new(1), + viewport: AtomicU64::new(viewport), + handles: AtomicUsize::new(1), + closing: AtomicBool::new(false), + updating: AtomicBool::new(false), + read_only: AtomicBool::new(read_only), + operation: tokio::sync::Mutex::new(()), + }); + actor::start(&owner, receiver); + pending.0.take(); + Ok(Self(owner)) + } + /// Revoke human input immediately and remove native pixels through the owning UI loop. + /// + /// # Errors + /// Reports a poisoned or unavailable native view. + pub fn hide(&self) -> Result<(), BrowserError> { + lifecycle::hide(&self.0) + } + /// Schedule fresh native visibility under the supervisor's current opaque target. + /// + /// # Errors + /// Rejects closing handles and bounded native scheduling failure. + pub fn show(&self) -> Result<(), BrowserError> { + if self.0.closing.load(Ordering::Acquire) { + return Err(BrowserError::Closed); + } + { + let mut state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + // A fresh pulse for unchanged visibility must not invalidate a + // configuration already awaiting native acknowledgement. Bounds + // and visibility transitions independently advance the revision. + if !state.visible { + self.0.revision.fetch_add(1, Ordering::AcqRel); + } + state.visible = true; + state.heartbeat = Instant::now(); + } + viewport::update(&self.0); + Ok(()) + } + /// Store validated native viewport bounds, independent of page-authored coordinates. + /// + /// # Errors + /// Rejects invalid bounds, excessive pixel allocation or a closed window. + pub fn set_bounds(&self, bounds: Rect) -> Result<(), BrowserError> { + let scale = self + .0 + .window + .scale_factor() + .map_err(|_| BrowserError::Closed)?; + let position = bounds.position.to_logical::(scale); + let size = bounds.size.to_logical::(scale); + let values = [ + position.x, + position.y, + size.width, + size.height, + scale * 1000.0, + ]; + if values + .iter() + .any(|value| !value.is_finite() || *value < 0.0 || *value > 16384.0) + || size.width < 16.0 + || size.height < 16.0 + { + return Err(BrowserError::Unavailable); + } + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let bounds = Bounds { + x: position.x.round() as i32, + y: position.y.round() as i32, + width: size.width.round() as u32, + height: size.height.round() as u32, + scale_milli: (scale * 1000.0).round() as u32, + }; + let mut state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + if state.bounds != Some(bounds) { + state.bounds = Some(bounds); + self.0.revision.fetch_add(1, Ordering::AcqRel); + } + Ok(()) + } + /// Submit an explicitly human navigation without changing origin or agent grants. + /// + /// # Errors + /// Rejects a closing view or full bounded native event queue. + pub fn navigate(&self, url: &Url) -> Result<(), BrowserError> { + if self.0.read_only.load(Ordering::Acquire) { + return Err(BrowserError::Unavailable); + } + self.0 + .events + .try_send(Event::Human(HumanCommand::Navigate { + url: url.to_string(), + })) + .map_err(|_| BrowserError::Closed) + } + /// Apply the closed human navigation vocabulary on the actual contained page. + /// + /// # Errors + /// Reports native or private-channel rejection without uncertain-effect retry. + pub async fn control(&self, action: NavigationAction) -> Result<(), BrowserError> { + if self.0.read_only.load(Ordering::Acquire) { + return Err(BrowserError::Unavailable); + } + let command = match action { + NavigationAction::Back => HumanCommand::Back, + NavigationAction::Forward => HumanCommand::Forward, + NavigationAction::Reload => HumanCommand::Reload, + NavigationAction::Stop => HumanCommand::Stop, + }; + actor::human(&self.0, command).await + } + /// Read bounded native metadata; renderer observations grant no authority. + /// + /// # Errors + /// Reports closed or stale private native ownership. + pub async fn inspect(&self) -> Result { + actor::inspect(&self.0).await + } + /// Hide through a native acknowledgment, then close the entire owned host envelope. + /// + /// # Errors + /// Unknown cleanup stays retained by both this handle and native composition. + pub async fn close(&self) -> Result<(), BrowserError> { + lifecycle::close(&self.0).await + } + /// Whether trusted native human navigation/input was admitted for this attachment. + #[must_use] + pub fn human_control_available(&self) -> bool { + !self.0.read_only.load(Ordering::Acquire) + } + + /// Canonical conversation retained by this exact native runtime admission. + #[must_use] + pub fn conversation_id(&self) -> &str { + self.0.native.conversation_id() + } + + /// Exact independently contained browser context. + #[must_use] + pub fn session_id(&self) -> &str { + self.0.native.session_id() + } + + /// Transfer this exact page to a registered run and retain a read-only native view. + /// + /// # Errors + /// Uncertain transfer remains fenced and cannot restore human input. + pub async fn handoff(&self, run_id: &str) -> Result<(), BrowserError> { + handoff::transfer(&self.0, run_id).await + } +} +impl Drop for RemoteSurface { + fn drop(&mut self) { + if self.0.handles.fetch_sub(1, Ordering::AcqRel) == 1 { + let _ = lifecycle::hide(&self.0); + let owner = Arc::clone(&self.0); + tauri::async_runtime::spawn(async move { + let _ = lifecycle::close(&owner).await; + }); + } + } +} diff --git a/apps/desktop/native-browser/src/remote/actor.rs b/apps/desktop/native-browser/src/remote/actor.rs new file mode 100644 index 000000000..baa9a697c --- /dev/null +++ b/apps/desktop/native-browser/src/remote/actor.rs @@ -0,0 +1,259 @@ +use super::{Event, Owner, lifecycle}; +use crate::{BrowserError, BrowserEvent, PageState, presentation::PresentationSurface}; +use colossus_browser_presentation::{HumanCommand, Lease, PresentationClient, PresentationCommand}; +use std::{ + sync::{Arc, atomic::Ordering}, + time::{Duration, Instant}, +}; +use tauri::Window; +use tokio::sync::mpsc; + +pub(super) async fn create_surface( + window: &Window, + client: &PresentationClient, + events: &mpsc::Sender, + lease: Lease, + digest: [u8; 32], + human_input: bool, +) -> Result { + let inputs = events.clone(); + let focuses = events.clone(); + let input_client = client.clone(); + let focus_client = client.clone(); + PresentationSurface::create( + window, + lease, + digest, + client.surface_key(), + move |lease, input, modifiers| { + if !human_input { + return; + } + if let Err(error) = + super::input_queue::enqueue(&inputs, Event::Input(lease, input, modifiers)) + { + super::input_queue::trace("input", error); + input_client.disconnect(); + } + }, + move |lease, focused| { + if !human_input { + return; + } + if let Err(error) = super::input_queue::enqueue(&focuses, Event::Focus(lease, focused)) + { + super::input_queue::trace("focus", error); + focus_client.disconnect(); + } + }, + ) + .await +} +pub(super) fn start(owner: &Arc, events: mpsc::Receiver) { + start_events(owner, events); + start_frames(owner); +} +fn start_events(owner: &Arc, mut events: mpsc::Receiver) { + let weak = Arc::downgrade(owner); + tauri::async_runtime::spawn(async move { + while let Some(event) = events.recv().await { + let Some(owner) = weak.upgrade() else { + break; + }; + if owner.closing.load(Ordering::Acquire) { + break; + } + if let Event::Human(command) = event { + if human(&owner, command).await.is_err() { + (owner.sink)(BrowserEvent::Failed); + } + continue; + } + let _operation = owner.operation.lock().await; + if owner.read_only.load(Ordering::Acquire) { + continue; + } + let current = owner.state.lock().ok().and_then(|state| { + (state.visible + && state.applied.is_some() + // The operation lock excludes applying a viewport change. + // A queued renewal must not discard focus/key events for + // the still-current geometry and exact native lease. + && state.applied == state.bounds + && state.heartbeat.elapsed() < Duration::from_millis(1500)) + .then_some(state.lease) + }); + if matches!(&event, Event::Focus(lease, _) | Event::Input(lease, _, _) if Some(*lease) != current) + { + continue; + } + let started = Instant::now(); + let stage = if matches!(&event, Event::Focus(..)) { + "focus_event" + } else { + "input_event" + }; + let result = match event { + Event::Focus(lease, focused) => owner.client.focus(lease, focused).await, + Event::Input(lease, input, modifiers) => { + owner.client.input(lease, input, modifiers).await + } + Event::Human(_) => unreachable!( + "human navigation is handled before acquiring the native input owner" + ), + }; + match result { + Ok(()) => {} + Err( + colossus_browser_presentation::PresentationError::Stale + | colossus_browser_presentation::PresentationError::Hidden, + ) => super::viewport::update(&owner), + Err(error) => { + #[cfg(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + ))] + super::trace_frame_channel_failure(stage, error, started.elapsed()); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = (stage, error, started); + fail(&owner); + break; + } + } + } + }); +} +fn start_frames(owner: &Arc) { + let weak = Arc::downgrade(owner); + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(50)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Some(owner) = weak.upgrade() else { + break; + }; + if owner.closing.load(Ordering::Acquire) { + break; + } + let current = owner.state.lock().ok().and_then(|state| { + (state.visible + && state.applied.is_some() + && !owner.updating.load(Ordering::Acquire) + && state.heartbeat.elapsed() < Duration::from_millis(1500)) + .then(|| (state.lease, state.surface.clone())) + }); + let Some((lease, Some(surface))) = current else { + continue; + }; + let started = Instant::now(); + match owner.client.next_frame(lease).await { + Ok(Some(frame)) => { + // A resize, occlusion or document transition may replace + // this surface while the authenticated frame is in flight. + // Drop its obsolete pixels without reviving the old view or + // disconnecting the independently current attachment. + let current = owner.state.lock().is_ok_and(|state| { + state.lease == lease + && state.visible + && state.applied.is_some() + && state + .surface + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &surface)) + }); + if !current { + continue; + } + if let Err(error) = surface.present(frame) { + #[cfg(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + ))] + super::trace_frame_browser_failure("surface_present", error); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = error; + fail(&owner); + break; + } + } + Ok(None) => {} + Err( + colossus_browser_presentation::PresentationError::Stale + | colossus_browser_presentation::PresentationError::Hidden, + ) => { + super::viewport::update(&owner); + } + Err(error) => { + #[cfg(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + ))] + super::trace_frame_channel_failure("poll", error, started.elapsed()); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = (error, started); + fail(&owner); + break; + } + } + } + }); +} +pub(super) fn fail(owner: &Arc) { + let _ = lifecycle::hide(owner); + owner.client.disconnect(); + (owner.sink)(BrowserEvent::Crashed); +} +pub(super) async fn human(owner: &Arc, command: HumanCommand) -> Result<(), BrowserError> { + let _operation = owner.operation.lock().await; + if owner.closing.load(Ordering::Acquire) || owner.read_only.load(Ordering::Acquire) { + return Err(BrowserError::Closed); + } + let lease = { + let state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + if !state.visible + || state.applied.is_none() + || state.heartbeat.elapsed() >= Duration::from_millis(1500) + { + return Err(BrowserError::Closed); + } + state.lease + }; + owner + .client + .command(PresentationCommand::Human { lease, command }) + .await + .map_err(|_| BrowserError::Closed)?; + (owner.sink)(BrowserEvent::Loading(true)); + Ok(()) +} +pub(super) async fn inspect(owner: &Arc) -> Result { + let lease = owner.state.lock().map_err(|_| BrowserError::Closed)?.lease; + let state = owner + .client + .observe(lease) + .await + .map_err(|_| BrowserError::Closed)?; + Ok(PageState { + url: state.url, + title: state.title, + loading: state.loading, + can_go_back: state.can_go_back, + can_go_forward: state.can_go_forward, + }) +} diff --git a/apps/desktop/native-browser/src/remote/handoff.rs b/apps/desktop/native-browser/src/remote/handoff.rs new file mode 100644 index 000000000..a35f76615 --- /dev/null +++ b/apps/desktop/native-browser/src/remote/handoff.rs @@ -0,0 +1,58 @@ +use super::{Owner, actor, lifecycle, viewport}; +use crate::BrowserError; +use std::sync::{Arc, atomic::Ordering}; + +pub(super) async fn transfer(owner: &Arc, run_id: &str) -> Result<(), BrowserError> { + let operation = owner.operation.lock().await; + if owner.closing.load(Ordering::Acquire) || owner.read_only.swap(true, Ordering::AcqRel) { + return Err(BrowserError::Unavailable); + } + // Revoke locally before any await. Queued input checks the same atomic flag; + // a dropped or lost transfer acknowledgment cannot restore human effects. + owner.revision.fetch_add(1, Ordering::AcqRel); + let (surface, old, lease) = { + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.applied = None; + (state.surface.clone(), state.configure.clone(), state.lease) + }; + lifecycle::hide_surface(surface.as_ref()).await?; + lifecycle::hide_host(owner, lease).await?; + let configure = owner.native.handoff(run_id).await?; + configure + .validate() + .map_err(|_| BrowserError::Unavailable)?; + if configure.session != old.session + || configure.target.tab_id != old.target.tab_id + || configure.control_generation == 0 + || configure.viewport_generation <= old.viewport_generation + { + return Err(BrowserError::Unavailable); + } + let lease = owner + .client + .configure(configure.clone()) + .await + .map_err(|_| BrowserError::Closed)?; + let next = actor::create_surface( + &owner.window, + &owner.client, + &owner.events, + lease, + owner.digest, + false, + ) + .await?; + owner + .viewport + .store(configure.viewport_generation, Ordering::Release); + { + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.configure = configure; + state.lease = lease; + state.surface = Some(Arc::new(next)); + state.applied = None; + } + drop(operation); + viewport::update(owner); + Ok(()) +} diff --git a/apps/desktop/native-browser/src/remote/input_queue.rs b/apps/desktop/native-browser/src/remote/input_queue.rs new file mode 100644 index 000000000..ade877ff2 --- /dev/null +++ b/apps/desktop/native-browser/src/remote/input_queue.rs @@ -0,0 +1,113 @@ +//! Bounded FIFO preserves native focus, raw key, Unicode commit and release order. +use super::Event; +use tokio::sync::mpsc; + +// AppKit can deliver a complete key/text/up burst before the asynchronous +// authenticated acknowledgements drain it. The bound remains finite; leases +// are revalidated when each event is actually consumed. +pub(super) const CAPACITY: usize = 256; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum Rejection { + Full, + Closed, +} +pub(super) fn enqueue(sender: &mpsc::Sender, event: Event) -> Result<(), Rejection> { + sender.try_send(event).map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => Rejection::Full, + mpsc::error::TrySendError::Closed(_) => Rejection::Closed, + }) +} +pub(super) fn trace(kind: &'static str, error: Rejection) { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + eprintln!( + "EVIDENCE mac-native-input stage={kind}_queue reason={}", + match error { + Rejection::Full => "full", + Rejection::Closed => "closed", + } + ); + #[cfg(not(all(target_os = "macos", feature = "native-test-driver", debug_assertions)))] + let _ = (kind, error); +} + +#[cfg(test)] +mod tests { + use super::*; + use colossus_browser_presentation::{Input, Lease}; + fn lease() -> Lease { + Lease { + tab: 1, + session_generation: 1, + control_generation: 0, + viewport_generation: 2, + document_generation: 1, + pixel_width: 2004, + pixel_height: 1280, + } + } + fn keys() -> Vec { + "Mac input café😀x" + .chars() + .enumerate() + .flat_map(|(index, character)| { + [ + Input::Key { + code: 65 + index as u16, + pressed: true, + }, + Input::ImeCommit { + text: character.to_string(), + }, + Input::Key { + code: 65 + index as u16, + pressed: false, + }, + ] + }) + .collect() + } + #[test] + fn ordinary_mac_key_text_release_burst_exceeds32_and_preserves_fifo_under256() { + let inputs = keys(); + assert!(inputs.len() > 32); + let (small, _reader) = mpsc::channel(32); + enqueue(&small, Event::Focus(lease(), true)).unwrap(); + assert!( + inputs + .iter() + .cloned() + .any(|input| enqueue(&small, Event::Input(lease(), input, 0)) + == Err(Rejection::Full)) + ); + let (sender, mut reader) = mpsc::channel(CAPACITY); + enqueue(&sender, Event::Focus(lease(), true)).unwrap(); + for input in &inputs { + enqueue(&sender, Event::Input(lease(), input.clone(), 0)).unwrap(); + } + assert!(matches!(reader.try_recv().unwrap(),Event::Focus(actual,true) if actual==lease())); + for expected in inputs { + assert!( + matches!(reader.try_recv().unwrap(),Event::Input(actual,input,0) + if actual==lease()&&input==expected) + ); + } + assert!(reader.try_recv().is_err()); + } + #[test] + fn actual_overflow_and_closed_receiver_remain_explicit_rejections() { + let (sender, mut reader) = mpsc::channel(CAPACITY); + for _ in 0..CAPACITY { + enqueue(&sender, Event::Focus(lease(), false)).unwrap(); + } + assert_eq!( + enqueue(&sender, Event::Focus(lease(), true)), + Err(Rejection::Full) + ); + assert!(matches!(reader.try_recv().unwrap(), Event::Focus(_, false))); + drop(reader); + assert_eq!( + enqueue(&sender, Event::Focus(lease(), true)), + Err(Rejection::Closed) + ); + } +} diff --git a/apps/desktop/native-browser/src/remote/lifecycle.rs b/apps/desktop/native-browser/src/remote/lifecycle.rs new file mode 100644 index 000000000..6c140a3df --- /dev/null +++ b/apps/desktop/native-browser/src/remote/lifecycle.rs @@ -0,0 +1,80 @@ +use super::{Owner, RemoteHostOwner}; +use crate::{BrowserError, presentation::PresentationSurface}; +use std::sync::{Arc, atomic::Ordering}; + +pub(super) struct Pending(pub Option>); +impl Drop for Pending { + fn drop(&mut self) { + if let Some(native) = self.0.take() { + tauri::async_runtime::spawn(async move { + let _ = native.close().await; + }); + } + } +} +pub(super) fn hide(owner: &Arc) -> Result<(), BrowserError> { + owner.revision.fetch_add(1, Ordering::AcqRel); + let (surface, lease) = { + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.visible = false; + state.applied = None; + (state.surface.clone(), state.lease) + }; + let result = surface.as_ref().map_or(Ok(()), |surface| surface.hide()); + let owner = Arc::clone(owner); + tauri::async_runtime::spawn(async move { + let _operation = owner.operation.lock().await; + if owner.state.lock().is_ok_and(|state| state.lease == lease) + && hide_host(&owner, lease).await.is_err() + { + owner.client.disconnect(); + } + }); + result +} +pub(super) async fn close(owner: &Arc) -> Result<(), BrowserError> { + owner.closing.store(true, Ordering::Release); + hide(owner)?; + let _operation = owner.operation.lock().await; + let (surface, lease) = { + let state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + (state.surface.clone(), state.lease) + }; + if let Some(surface) = surface { + surface.hide_confirmed().await?; + } + let _ = hide_host(owner, lease).await; + owner.client.disconnect(); + // The separate native owner must positively acknowledge detach/cleanup. + // That receipt also reconciles an already ended frame stream: an active + // writer's view is confirmed hidden, or Core has confirmed host shutdown. + owner.native.close().await?; + owner + .state + .lock() + .map_err(|_| BrowserError::Closed)? + .surface + .take(); + Ok(()) +} +pub(super) async fn hide_host( + owner: &Owner, + lease: colossus_browser_presentation::Lease, +) -> Result<(), BrowserError> { + match owner.client.hide(lease).await { + Ok(()) + | Err( + colossus_browser_presentation::PresentationError::Stale + | colossus_browser_presentation::PresentationError::Hidden, + ) => Ok(()), + Err(_) => Err(BrowserError::Closed), + } +} +pub(super) async fn hide_surface( + surface: Option<&Arc>, +) -> Result<(), BrowserError> { + if let Some(surface) = surface { + surface.hide_confirmed().await?; + } + Ok(()) +} diff --git a/apps/desktop/native-browser/src/remote/viewport.rs b/apps/desktop/native-browser/src/remote/viewport.rs new file mode 100644 index 000000000..bfe53ab0a --- /dev/null +++ b/apps/desktop/native-browser/src/remote/viewport.rs @@ -0,0 +1,238 @@ +use super::{ + Owner, + actor::{create_surface, fail}, + lifecycle, +}; +use crate::BrowserError; +#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +use std::time::Instant; +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +pub(super) fn update(owner: &Arc) { + if owner.updating.swap(true, Ordering::AcqRel) { + return; + } + let owner = Arc::clone(owner); + tauri::async_runtime::spawn(async move { + let mut applied; + loop { + applied = owner.revision.load(Ordering::Acquire); + match apply(&owner, applied).await { + Ok(()) => {} + Err(BrowserError::Stale) => { + if let Err(error) = wait_for_target(&owner).await { + #[cfg(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + ))] + super::trace_frame_browser_failure("viewport_target_wait", error); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = error; + fail(&owner); + } + break; + } + Err(error) => { + #[cfg(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + ))] + super::trace_frame_browser_failure("viewport_apply", error); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = error; + fail(&owner); + break; + } + } + if owner.revision.load(Ordering::Acquire) == applied { + break; + } + } + owner.updating.store(false, Ordering::Release); + if owner.revision.load(Ordering::Acquire) != applied + && !owner.closing.load(Ordering::Acquire) + { + let visible = owner.state.lock().is_ok_and(|state| state.visible); + if visible { + update(&owner); + } + } + }); +} +async fn apply(owner: &Arc, revision: u64) -> Result<(), BrowserError> { + let _operation = owner.operation.lock().await; + let (mut configure, lease, bounds, applied, surface) = { + let state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + if !state.visible || owner.closing.load(Ordering::Acquire) { + return Ok(()); + } + ( + state.configure.clone(), + state.lease, + state.bounds, + state.applied, + state.surface.clone(), + ) + }; + let Some(bounds) = bounds else { + return Ok(()); + }; + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + let observed_at = Instant::now(); + let state = owner.client.observe(lease).await.map_err(|error| { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + super::trace_frame_channel_failure("viewport_observe", error, observed_at.elapsed()); + presentation_error(error) + })?; + let ttl = remaining(owner, revision) + .inspect_err(|error| trace_apply("viewport_ttl_after_observe", *error))?; + if applied == Some(bounds) + && state.target == configure.target + && let Some(surface) = &surface + && surface + .renew_confirmed(lease, Duration::from_millis(u64::from(ttl))) + .await + .is_ok() + && owner.client.renew(lease, ttl).await.is_ok() + { + return Ok(()); + } + { + // Revoke the previous layout before hiding either endpoint. Ordinary + // renewals above retain it, so their current frames remain admissible. + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.applied = None; + } + lifecycle::hide_surface(surface.as_ref()) + .await + .inspect_err(|error| trace_apply("viewport_hide_surface", *error))?; + lifecycle::hide_host(owner, lease) + .await + .inspect_err(|error| trace_apply("viewport_hide_host", *error))?; + configure.target = state.target; + configure.width = bounds.width; + configure.height = bounds.height; + configure.scale_milli = bounds.scale_milli; + configure.lease_ms = remaining(owner, revision) + .inspect_err(|error| trace_apply("viewport_ttl_before_configure", *error))?; + configure.viewport_generation = owner + .viewport + .fetch_add(1, Ordering::AcqRel) + .checked_add(1) + .ok_or(BrowserError::Closed)?; + let lease = owner + .client + .configure(configure.clone()) + .await + .map_err(presentation_error) + .inspect_err(|error| trace_apply("viewport_configure", *error))?; + let next = create_surface( + &owner.window, + &owner.client, + &owner.events, + lease, + owner.digest, + !owner.read_only.load(Ordering::Acquire), + ) + .await + .inspect_err(|error| trace_apply("viewport_create_surface", *error))?; + let ttl = match remaining(owner, revision) { + Ok(ttl) => ttl, + Err(error) => { + trace_apply("viewport_ttl_after_create", error); + next.hide_confirmed().await?; + lifecycle::hide_host(owner, lease).await?; + return Err(error); + } + }; + next.renew_confirmed(lease, Duration::from_millis(u64::from(ttl))) + .await + .inspect_err(|error| trace_apply("viewport_renew_surface", *error))?; + set_native_bounds(&next, bounds) + .inspect_err(|error| trace_apply("viewport_set_bounds", *error))?; + if owner.revision.load(Ordering::Acquire) != revision || owner.closing.load(Ordering::Acquire) { + next.hide_confirmed().await?; + lifecycle::hide_host(owner, lease).await?; + } + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.configure = configure; + state.lease = lease; + state.applied = (owner.revision.load(Ordering::Acquire) == revision).then_some(bounds); + state.surface = Some(Arc::new(next)); + Ok(()) +} +fn set_native_bounds( + surface: &crate::presentation::PresentationSurface, + bounds: super::Bounds, +) -> Result<(), BrowserError> { + let factor = if cfg!(windows) { + f64::from(bounds.scale_milli) / 1000.0 + } else { + 1.0 + }; + #[allow(clippy::cast_possible_truncation)] + surface.set_bounds( + (f64::from(bounds.x) * factor).round() as i32, + (f64::from(bounds.y) * factor).round() as i32, + (f64::from(bounds.width) * factor).round() as i32, + (f64::from(bounds.height) * factor).round() as i32, + ) +} +fn trace_apply(stage: &'static str, error: BrowserError) { + #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + super::trace_frame_browser_failure(stage, error); + #[cfg(not(all(target_os = "macos", feature = "native-test-driver", debug_assertions)))] + let _ = (stage, error); +} +fn presentation_error(error: colossus_browser_presentation::PresentationError) -> BrowserError { + match error { + colossus_browser_presentation::PresentationError::Stale + | colossus_browser_presentation::PresentationError::Hidden => BrowserError::Stale, + _ => BrowserError::Closed, + } +} +async fn wait_for_target(owner: &Owner) -> Result<(), BrowserError> { + let _operation = owner.operation.lock().await; + let (surface, lease) = { + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.applied = None; + (state.surface.clone(), state.lease) + }; + lifecycle::hide_surface(surface.as_ref()).await?; + lifecycle::hide_host(owner, lease).await +} +fn remaining(owner: &Owner, revision: u64) -> Result { + let state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + if !state.visible + || owner.closing.load(Ordering::Acquire) + || owner.revision.load(Ordering::Acquire) != revision + { + return Err(BrowserError::Stale); + } + // Re-read the latest trusted pulse after native/channel awaits. Preserve + // its original 1.5s absolute lifetime rather than refreshing it here. + u16::try_from( + Duration::from_millis(1500) + .saturating_sub(state.heartbeat.elapsed()) + .as_millis(), + ) + .ok() + .filter(|ttl| *ttl > 0) + // Visibility expiry retires this viewport; it does not establish that the + // independently owned browser or its authenticated channel has crashed. + // A later trusted visible pulse may configure a fresh lease after hiding. + .ok_or(BrowserError::Stale) +} diff --git a/apps/desktop/native-browser/src/remote/windows_acceptance.rs b/apps/desktop/native-browser/src/remote/windows_acceptance.rs new file mode 100644 index 000000000..6d01470ce --- /dev/null +++ b/apps/desktop/native-browser/src/remote/windows_acceptance.rs @@ -0,0 +1,40 @@ +//! Closed Windows source-fixture probes for the owned remote presenter. +use super::RemoteSurface; +use crate::{BrowserError, presentation::WindowsScreenEvidence}; + +impl RemoteSurface { + /// Capture OS compositor evidence for the source-only synthetic acceptance page. + /// + /// # Errors + /// Requires a live native presenter, its owning UI thread and foreground parent. + pub async fn acceptance_windows_screen(&self) -> Result { + let surface = self + .0 + .state + .lock() + .map_err(|_| BrowserError::Closed)? + .surface + .clone() + .ok_or(BrowserError::Unavailable)?; + surface.screen().await + } + + /// Dispatch the closed native key/Unicode sequence to the synthetic field. + /// + /// # Errors + /// Human control must remain active; the ordinary input guard rechecks every event. + pub async fn acceptance_windows_input(&self) -> Result<(), BrowserError> { + if !self.human_control_available() { + return Err(BrowserError::Unavailable); + } + let surface = self + .0 + .state + .lock() + .map_err(|_| BrowserError::Closed)? + .surface + .clone() + .ok_or(BrowserError::Unavailable)?; + surface.fixture_input().await + } +} diff --git a/apps/desktop/native-browser/src/types.rs b/apps/desktop/native-browser/src/types.rs index 0c0e13b48..d65f7572c 100644 --- a/apps/desktop/native-browser/src/types.rs +++ b/apps/desktop/native-browser/src/types.rs @@ -13,7 +13,7 @@ pub enum NavigationAction { } /// Native observations. Titles and URLs are untrusted display data. -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PageState { pub url: String, @@ -32,6 +32,10 @@ pub enum BrowserEvent { Blocked, Download, Popup(String), + /// Chromium rejected a certificate; no TLS-error override is available. + TlsFailed, + /// No reviewed, exact-origin native client identity was selected. + AuthenticationRequired, } /// The manager owns the sink and validates the tab's lifecycle on delivery. @@ -43,7 +47,12 @@ pub enum BrowserError { InvalidAddress, Unavailable, Closed, + /// The native owner is changing the document; hide pixels until a fresh target is admitted. + Stale, TimedOut, + ComponentMissing, + BootstrapRequired, + NativeAcceptanceRequired, } impl std::fmt::Display for BrowserError { @@ -52,7 +61,11 @@ impl std::fmt::Display for BrowserError { Self::InvalidAddress => "Enter an HTTP or HTTPS address without embedded credentials.", Self::Unavailable => "The embedded browser is unavailable on this device.", Self::Closed => "This browser tab is no longer available.", + Self::Stale => "The browser page is changing. Waiting for its current document.", Self::TimedOut => "The browser did not respond. Close the tab and try again.", + Self::ComponentMissing => "The verified Chromium component is not included in this build.", + Self::BootstrapRequired => "Embedded Chromium requires the supported sandboxed native bootstrap on this platform.", + Self::NativeAcceptanceRequired => "Embedded Chromium has not passed native acceptance on this platform.", }) } } diff --git a/apps/desktop/native-browser/src/view.rs b/apps/desktop/native-browser/src/view.rs new file mode 100644 index 000000000..cd2966246 --- /dev/null +++ b/apps/desktop/native-browser/src/view.rs @@ -0,0 +1,245 @@ +//! Safe presentation handle shared by the existing guest and the Chromium preview. + +use tauri::{Rect, Webview}; +use url::Url; + +use crate::{BrowserError, NavigationAction, PageState}; + +/// One actual native guest. Chromium never navigates a second `WebKit` page. +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] // Unlinked preview surface is an inert zero-sized stub. +pub enum BrowserView { + /// Existing human browsing adapter retained until native Chromium acceptance. + System(Webview), + /// Explicitly enabled embedded Chromium preview. + #[cfg(feature = "cef-preview")] + Chromium(crate::chromium::Surface), + /// Trusted attachment to a separately contained Chromium host. + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Remote(crate::remote::RemoteSurface), +} + +impl BrowserView { + /// Exact canonical conversation of an independently admitted browser context. + #[must_use] + pub fn conversation_id(&self) -> Option<&str> { + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + if let Self::Remote(view) = self { + return Some(view.conversation_id()); + } + None + } + + /// Exact independently contained runtime session, when admitted. + #[must_use] + pub fn contained_session_id(&self) -> Option<&str> { + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + if let Self::Remote(view) = self { + return Some(view.session_id()); + } + None + } + + /// Transfer an independently admitted human page to a registered run. + /// + /// # Errors + /// UI preview pages and unverified runtime contexts cannot grant automation. + #[cfg_attr( + not(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + )), + allow(clippy::unused_async) + )] + pub async fn handoff(&self, run_id: &str) -> Result<(), BrowserError> { + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + if let Self::Remote(view) = self { + return view.handoff(run_id).await; + } + let _ = run_id; + Err(BrowserError::Unavailable) + } + + /// Whether the native attachment admits human navigation and keyboard input. + #[must_use] + pub fn human_control_available(&self) -> bool { + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + if let Self::Remote(view) = self { + return view.human_control_available(); + } + true + } + /// Cancel native identity review before controller ownership changes. + pub fn revoke_client_identity(&self) { + #[cfg(feature = "cef-preview")] + if let Self::Chromium(view) = self { + view.revoke_client_identity(); + } + } + + /// Navigate through the native guest's closed human interface. + /// + /// # Errors + /// Fails when the guest has closed or navigation is invalid. + pub fn navigate(&self, url: Url) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.navigate(url).map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.navigate(&url), + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.navigate(&url), + } + } + + /// Hide the real native page and prevent guest input. + /// + /// # Errors + /// Returns an error when the guest no longer exists. + pub fn hide(&self) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.hide().map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.hide(), + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.hide(), + } + } + + /// Display the native page after the controller has validated its viewport. + /// + /// # Errors + /// Returns an error when the guest no longer exists. + pub fn show(&self) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.show().map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.show(), + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.show(), + } + } + + /// Apply validated logical viewport bounds on the native UI thread. + /// + /// # Errors + /// Returns an error when the guest no longer exists or its bounds are invalid. + pub fn set_bounds(&self, bounds: Rect) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.set_bounds(bounds).map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.set_bounds(bounds), + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.set_bounds(bounds), + } + } + + /// Stop and destroy the native guest before releasing its profile. + /// + /// # Errors + /// Returns an error when native teardown cannot complete. + pub async fn close(&self) -> Result<(), BrowserError> { + match self { + Self::System(view) => { + let _ = crate::release(view).await; + view.close().map_err(|_| BrowserError::Closed) + } + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.close().await, + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.close().await, + } + } + + /// Read native navigation metadata, never renderer-authored authority. + /// + /// # Errors + /// Fails if the guest is unavailable or its observation deadline expires. + pub async fn inspect(&self) -> Result { + match self { + Self::System(view) => crate::inspect(view).await, + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.inspect().await, + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.inspect().await, + } + } + + /// Apply a human navigation control on this same guest. + /// + /// # Errors + /// Fails if the guest is unavailable or its operation deadline expires. + pub async fn control(&self, action: NavigationAction) -> Result<(), BrowserError> { + match self { + Self::System(view) => crate::control(view, action).await, + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.control(action).await, + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(view) => view.control(action).await, + } + } + + /// Existing guest handle for sharing only the legacy engine's native context. + #[must_use] + pub fn system_view(&self) -> Option<&Webview> { + match self { + Self::System(view) => Some(view), + #[cfg(feature = "cef-preview")] + Self::Chromium(_) => None, + #[cfg(all( + feature = "cef-preview", + colossus_cef_linked, + any(windows, target_os = "macos") + ))] + Self::Remote(_) => None, + } + } +} diff --git a/apps/desktop/native-credential-ui/src/lib.rs b/apps/desktop/native-credential-ui/src/lib.rs index a0d5f4795..730ec3f22 100644 --- a/apps/desktop/native-credential-ui/src/lib.rs +++ b/apps/desktop/native-credential-ui/src/lib.rs @@ -7,8 +7,9 @@ mod appearance; #[cfg(any(windows, target_os = "macos", target_os = "linux"))] mod lifecycle; +mod password; mod prompt; -#[cfg(any(windows, target_os = "macos", target_os = "linux"))] +mod purpose; mod validation; #[cfg(target_os = "linux")] @@ -20,7 +21,8 @@ mod macos; mod windows; pub use appearance::{ColorScheme, DialogAppearance, TextSize}; -pub use prompt::{PromptError, prompt}; +pub use password::NativePassword; +pub use prompt::{PromptError, prompt, prompt_password}; /// Run isolated, synthetic `AppKit` control acceptance on the process main thread. /// This entry point exists only in explicitly enabled native test builds. diff --git a/apps/desktop/native-credential-ui/src/lifecycle.rs b/apps/desktop/native-credential-ui/src/lifecycle.rs index 6e2c1ea5b..6a9b3f9aa 100644 --- a/apps/desktop/native-credential-ui/src/lifecycle.rs +++ b/apps/desktop/native-credential-ui/src/lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, }; -use colossus_contracts::HostSecret; +use crate::NativePassword; use tokio::sync::oneshot; use crate::PromptError; @@ -16,12 +16,12 @@ pub(crate) static TEST_OWNERSHIP: std::sync::Mutex<()> = std::sync::Mutex::new(( /// Held by the native window, not the waiting future, so cancelling a caller /// cannot admit another dialog until the first window has cleared and closed. pub(crate) struct Completion { - sender: Option>>, + sender: Option>>, } impl Completion { pub(crate) fn acquire() - -> Result<(Self, oneshot::Receiver>), PromptError> { + -> Result<(Self, oneshot::Receiver>), PromptError> { ACTIVE .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .map_err(|_| PromptError::Busy)?; @@ -34,7 +34,7 @@ impl Completion { )) } - pub(crate) fn finish(mut self, result: Result) { + pub(crate) fn finish(mut self, result: Result) { // Release ownership before waking a caller that might immediately retry. ACTIVE.store(false, Ordering::Release); if let Some(sender) = self.sender.take() { diff --git a/apps/desktop/native-credential-ui/src/linux.rs b/apps/desktop/native-credential-ui/src/linux.rs index 174776583..cbf8a167a 100644 --- a/apps/desktop/native-credential-ui/src/linux.rs +++ b/apps/desktop/native-credential-ui/src/linux.rs @@ -1,6 +1,7 @@ //! Owned GTK password entry on Tauri's existing UI thread; no nested event loop. -use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; +use crate::{ + DialogAppearance, NativePassword, PromptError, lifecycle::Completion, purpose::Purpose, +}; use gtk::{glib, prelude::*}; use std::{ cell::RefCell, @@ -20,22 +21,40 @@ pub(crate) fn open( cancelled: Arc, completion: Completion, appearance: DialogAppearance, + purpose: Purpose, ) { let Ok(parent) = parent.gtk_window() else { completion.finish(Err(PromptError::Unavailable)); return; }; - let _ = create(parent.upcast_ref(), cancelled, completion, appearance); + let _ = create_for( + parent.upcast_ref(), + cancelled, + completion, + appearance, + purpose, + ); } +#[cfg(test)] fn create( parent: >k::Window, cancelled: Arc, completion: Completion, appearance: DialogAppearance, +) -> Result<(gtk::Dialog, gtk::Entry), PromptError> { + create_for(parent, cancelled, completion, appearance, Purpose::Token) +} + +fn create_for( + parent: >k::Window, + cancelled: Arc, + completion: Completion, + appearance: DialogAppearance, + purpose: Purpose, ) -> Result<(gtk::Dialog, gtk::Entry), PromptError> { let dialog = gtk::Dialog::builder() - .title("Save a credential") + .title(purpose.title()) .transient_for(parent) .modal(true) .destroy_with_parent(true) @@ -43,24 +62,23 @@ fn create( .resizable(false) .build(); dialog.add_button("Cancel", gtk::ResponseType::Cancel); - dialog.add_button("Save credential", gtk::ResponseType::Accept); + dialog.add_button(purpose.confirm(), gtk::ResponseType::Accept); dialog.set_default_response(gtk::ResponseType::Accept); let content = dialog.content_area(); styling::content(&content, appearance); - let description = gtk::Label::new(Some( - "This token stays in native credential storage and is never sent to the app's web view.", - )); + let description = gtk::Label::new(Some(purpose.description())); description.set_line_wrap(true); description.set_xalign(0.0); description .style_context() .add_class("credential-description"); - let label = gtk::Label::new(Some("_Token")); + let label = gtk::Label::new(Some(&format!("_{}", purpose.label()))); label.set_use_underline(true); label.set_xalign(0.0); label.style_context().add_class("credential-label"); let entry = gtk::Entry::new(); entry.set_visibility(false); + entry.set_placeholder_text(Some(purpose.placeholder())); entry.set_input_purpose(gtk::InputPurpose::Password); entry.set_activates_default(true); label.set_mnemonic_widget(Some(&entry)); @@ -94,13 +112,12 @@ fn create( } if response == gtk::ResponseType::Accept { let text = Zeroizing::new(entry.text().to_string()); - if let Err(invalid) = validation::validate(&text) { + if let Err(invalid) = purpose.validate(&text) { error.set_text(invalid.message()); entry.grab_focus(); return; } - *result.borrow_mut() = - Some(HostSecret::new(text.to_string()).map_err(|_| PromptError::Unavailable)); + *result.borrow_mut() = Some(NativePassword::new(text.to_string())); } else { *result.borrow_mut() = Some(Err(PromptError::Cancelled)); } @@ -143,8 +160,8 @@ fn create( mod tests { use super::*; fn wait_for_completion( - received: &mut tokio::sync::oneshot::Receiver>, - ) -> Result { + received: &mut tokio::sync::oneshot::Receiver>, + ) -> Result { let deadline = std::time::Instant::now() + Duration::from_secs(2); loop { match received.try_recv() { diff --git a/apps/desktop/native-credential-ui/src/macos.rs b/apps/desktop/native-credential-ui/src/macos.rs index 2e2a49594..ce2588dd5 100644 --- a/apps/desktop/native-credential-ui/src/macos.rs +++ b/apps/desktop/native-credential-ui/src/macos.rs @@ -10,8 +10,9 @@ mod styling; #[path = "macos/tests.rs"] pub(crate) mod tests; -use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; +use crate::{ + DialogAppearance, NativePassword, PromptError, lifecycle::Completion, purpose::Purpose, +}; use formatter::TokenFormatter; use objc2::{ DefinedClass, MainThreadOnly, Message, define_class, msg_send, @@ -49,6 +50,7 @@ struct Session { timer: Retained, completion: Completion, cancelled: Arc, + purpose: Purpose, } #[derive(Default)] @@ -75,9 +77,11 @@ define_class!( fn changed(&self, _: &NSNotification) { if let Some(session) = self.ivars().session.borrow().as_ref() { let text = current_text(&session.input); - session.count.setStringValue(&NSString::from_str(&format!("{} / 65,536 bytes", text.length()))); + let bytes = formatter::native_bytes(&text, session.purpose); + let count = bytes.as_ref().map_or_else(|_| "Invalid passphrase character".into(), |bytes| format!("{bytes} / 65,536 bytes")); + session.count.setStringValue(&NSString::from_str(&count)); session.status.setStringValue(ns_string!("")); - session.save.setEnabled(text.length() > 0); + session.save.setEnabled(bytes.is_ok() && (text.length() > 0 || session.purpose == Purpose::Pkcs12Password)); } } } @@ -90,16 +94,16 @@ define_class!( let borrowed = self.ivars().session.borrow(); let Some(session) = borrowed.as_ref() else { return; }; let text = current_text(&session.input); - if let Err(error) = formatter::validate_native(&text) { + if let Err(error) = formatter::native_bytes(&text, session.purpose) { session.status.setStringValue(&NSString::from_str(error.message())); return; } let mut secret = Zeroizing::new(text.to_string()); - if let Err(error) = validation::validate(&secret) { + if let Err(error) = session.purpose.validate(&secret) { session.status.setStringValue(&NSString::from_str(error.message())); return; } - HostSecret::new(std::mem::take(&mut *secret)).map_err(|_| PromptError::Unavailable) + NativePassword::new(std::mem::take(&mut *secret)) }; self.finish(result); } @@ -126,7 +130,7 @@ impl Controller { unsafe { msg_send![super(this), init] } } - fn finish(&self, result: Result) { + fn finish(&self, result: Result) { // Keep self alive through timer invalidation and thread-local release. let _keep_alive = self.retain(); let Some(session) = self.ivars().session.borrow_mut().take() else { @@ -158,6 +162,7 @@ pub(crate) fn open( cancelled: Arc, completion: Completion, appearance: DialogAppearance, + purpose: Purpose, ) { let Some(mtm) = MainThreadMarker::new() else { completion.finish(Err(PromptError::Unavailable)); @@ -177,15 +182,34 @@ pub(crate) fn open( completion.finish(Err(PromptError::Cancelled)); return; } - open_sheet(&parent, mtm, cancelled, completion, appearance); + open_sheet_for(&parent, mtm, cancelled, completion, appearance, purpose); } +#[cfg(feature = "native-test-driver")] fn open_sheet( parent: &NSWindow, mtm: MainThreadMarker, cancelled: Arc, completion: Completion, appearance: DialogAppearance, +) { + open_sheet_for( + parent, + mtm, + cancelled, + completion, + appearance, + Purpose::Token, + ); +} + +fn open_sheet_for( + parent: &NSWindow, + mtm: MainThreadMarker, + cancelled: Arc, + completion: Completion, + appearance: DialogAppearance, + purpose: Purpose, ) { let controller = Controller::new(mtm); let style = styling::Style::new(parent, appearance); @@ -201,24 +225,24 @@ fn open_sheet( unsafe { panel.setReleasedWhenClosed(false); } - panel.setTitle(ns_string!("Save credential")); + panel.setTitle(&NSString::from_str(purpose.title())); style.panel(&panel); let Some(content) = panel.contentView() else { completion.finish(Err(PromptError::Unavailable)); return; }; - let [heading, description, label] = style.labels(mtm); + let [heading, description, label] = style.labels(mtm, purpose); let input = NSSecureTextField::initWithFrame( NSSecureTextField::alloc(mtm), style.rect(28.0, 145.0, 504.0, 44.0), ); - input.setPlaceholderString(Some(ns_string!("Paste your credential"))); + input.setPlaceholderString(Some(&NSString::from_str(purpose.placeholder()))); style.input(&input); - accessibility::label(&input, ns_string!("Token")); + accessibility::label(&input, &NSString::from_str(purpose.label())); let [count, status] = style.feedback(mtm); let save = unsafe { NSButton::buttonWithTitle_target_action( - ns_string!("Save"), + &NSString::from_str(purpose.confirm()), Some(&controller), Some(sel!(save:)), mtm, @@ -227,8 +251,8 @@ fn open_sheet( save.setFrame(style.rect(432.0, 24.0, 100.0, 40.0)); style.button(&save, true); save.setKeyEquivalent(ns_string!("\r")); - accessibility::label(&save, ns_string!("Save")); - save.setEnabled(false); + accessibility::label(&save, &NSString::from_str(purpose.confirm())); + save.setEnabled(purpose == Purpose::Pkcs12Password); let cancel = unsafe { NSButton::buttonWithTitle_target_action( ns_string!("Cancel"), @@ -241,7 +265,7 @@ fn open_sheet( style.button(&cancel, false); cancel.setKeyEquivalent(ns_string!("\u{1b}")); accessibility::label(&cancel, ns_string!("Cancel")); - let formatter = TokenFormatter::new(mtm, status.clone()); + let formatter = TokenFormatter::new(mtm, status.clone(), purpose); input.setFormatter(Some(&formatter)); unsafe { input.setDelegate(Some(ProtocolObject::from_ref(&*controller))); @@ -281,6 +305,7 @@ fn open_sheet( timer, completion, cancelled, + purpose, })); DIALOG.with(|dialog| dialog.replace(Some(controller))); parent.beginSheet_completionHandler(&panel, None); diff --git a/apps/desktop/native-credential-ui/src/macos/formatter.rs b/apps/desktop/native-credential-ui/src/macos/formatter.rs index fe919e746..c6da0b95a 100644 --- a/apps/desktop/native-credential-ui/src/macos/formatter.rs +++ b/apps/desktop/native-credential-ui/src/macos/formatter.rs @@ -1,3 +1,4 @@ +use crate::purpose::Purpose; use crate::validation::{InputError, validate_units}; use objc2::{ DefinedClass, MainThreadOnly, Message, define_class, msg_send, rc::Retained, runtime::AnyObject, @@ -7,6 +8,7 @@ use objc2_foundation::{MainThreadMarker, NSFormatter, NSObjectProtocol, NSString pub(super) struct FormatterIvars { status: Retained, + purpose: Purpose, } define_class!( @@ -28,7 +30,7 @@ define_class!( #[unsafe(method(getObjectValue:forString:errorDescription:))] fn parsed(&self, output: *mut *mut AnyObject, string: &NSString, _: *mut *mut NSString) -> bool { - if validate_native(string).is_err() { false } else { + if native_bytes(string, self.ivars().purpose).is_err() { false } else { if !output.is_null() { // SAFETY: Cocoa supplies a writable autoreleasing object out // parameter. Preserve the NSString with normal +0 semantics. @@ -40,8 +42,8 @@ define_class!( #[unsafe(method(isPartialStringValid:newEditingString:errorDescription:))] fn partial(&self, value: &NSString, _: *mut *mut NSString, _: *mut *mut NSString) -> bool { - match validate_native(value) { - Ok(()) => true, + match native_bytes(value, self.ivars().purpose) { + Ok(_) => true, Err(error) => { self.ivars().status.setStringValue(&NSString::from_str(error.message())); false @@ -52,17 +54,24 @@ define_class!( ); impl TokenFormatter { - pub(super) fn new(mtm: MainThreadMarker, status: Retained) -> Retained { - let this = Self::alloc(mtm).set_ivars(FormatterIvars { status }); + pub(super) fn new( + mtm: MainThreadMarker, + status: Retained, + purpose: Purpose, + ) -> Retained { + let this = Self::alloc(mtm).set_ivars(FormatterIvars { status, purpose }); // SAFETY: NSFormatter's designated NSObject initializer is valid here. unsafe { msg_send![super(this), init] } } } /// Validate the Cocoa-owned candidate without allocating an unbounded Rust copy. -pub(super) fn validate_native(value: &NSString) -> Result<(), InputError> { - validate_units( - (0..value.length()).map(|index| value.characterAtIndex(index)), - value.length(), - ) +pub(super) fn native_bytes(value: &NSString, purpose: Purpose) -> Result { + let units = (0..value.length()).map(|index| value.characterAtIndex(index)); + match purpose { + Purpose::Token => validate_units(units, value.length()).map(|()| value.length()), + Purpose::Pkcs12Password => { + crate::validation::password_units_bytes(units, value.length(), false) + } + } } diff --git a/apps/desktop/native-credential-ui/src/macos/styling.rs b/apps/desktop/native-credential-ui/src/macos/styling.rs index 8f70ebf8f..8f1bf1184 100644 --- a/apps/desktop/native-credential-ui/src/macos/styling.rs +++ b/apps/desktop/native-credential-ui/src/macos/styling.rs @@ -1,5 +1,6 @@ //! `AppKit` presentation only; credential input and completion stay in the controller. +use crate::purpose::Purpose; use crate::{ColorScheme, DialogAppearance, appearance::Palette}; use objc2::rc::Retained; use objc2_app_kit::{ @@ -8,7 +9,7 @@ use objc2_app_kit::{ NSAppearanceNameDarkAqua, NSButton, NSColor, NSControlSize, NSFont, NSSecureTextField, NSTextField, NSWindow, NSWorkspace, }; -use objc2_foundation::{MainThreadMarker, NSRect, ns_string}; +use objc2_foundation::{MainThreadMarker, NSRect, NSString, ns_string}; #[derive(Clone, Copy)] enum LabelTone { @@ -71,18 +72,24 @@ impl Style { )); } - pub(super) fn labels(&self, mtm: MainThreadMarker) -> [Retained; 3] { - let heading = NSTextField::labelWithString(ns_string!("Save credential"), mtm); + pub(super) fn labels( + &self, + mtm: MainThreadMarker, + purpose: Purpose, + ) -> [Retained; 3] { + let heading = NSTextField::labelWithString(&NSString::from_str(purpose.heading()), mtm); heading.setFrame(self.rect(28.0, 263.0, 504.0, 33.0)); heading.setFont(Some(&NSFont::boldSystemFontOfSize(20.0 * self.scale))); heading.setTextColor(Some(&self.color(self.palette.strong, NSColor::labelColor))); - let description = NSTextField::wrappingLabelWithString( - ns_string!("Your token is saved in the encrypted credential vault."), - mtm, - ); - description.setFrame(self.rect(28.0, 227.0, 504.0, 30.0)); + let description = + NSTextField::wrappingLabelWithString(&NSString::from_str(purpose.description()), mtm); + description.setFrame(if purpose == Purpose::Token { + self.rect(28.0, 227.0, 504.0, 30.0) + } else { + self.rect(28.0, 223.0, 504.0, 40.0) + }); self.label(&description, 14.0, LabelTone::Muted); - let label = NSTextField::labelWithString(ns_string!("Token"), mtm); + let label = NSTextField::labelWithString(&NSString::from_str(purpose.label()), mtm); label.setFrame(self.rect(28.0, 197.0, 504.0, 22.0)); self.label(&label, 14.0, LabelTone::Normal); [heading, description, label] diff --git a/apps/desktop/native-credential-ui/src/password.rs b/apps/desktop/native-credential-ui/src/password.rs new file mode 100644 index 000000000..1a12af041 --- /dev/null +++ b/apps/desktop/native-credential-ui/src/password.rs @@ -0,0 +1,37 @@ +//! Owned native passphrase values, including an empty PKCS#12 passphrase. + +use zeroize::Zeroizing; + +/// A bounded PKCS#12 passphrase entered through a native secure control. +/// +/// The allocation clears on drop. This type has no serialization or cloning +/// surface; native controls may retain allocations that Rust cannot clear. +pub struct NativePassword(Zeroizing); + +impl NativePassword { + pub(crate) fn new(value: String) -> Result { + let value = Zeroizing::new(value); + crate::validation::validate_password(&value) + .map_err(|_| crate::PromptError::Unavailable)?; + Ok(Self(value)) + } + + /// Borrow the passphrase only in trusted native importing code. + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } + + pub(crate) fn into_token( + mut self, + ) -> Result { + colossus_contracts::HostSecret::new(std::mem::take(&mut *self.0)) + .map_err(|_| crate::PromptError::Unavailable) + } +} + +impl std::fmt::Debug for NativePassword { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("NativePassword([REDACTED])") + } +} diff --git a/apps/desktop/native-credential-ui/src/prompt.rs b/apps/desktop/native-credential-ui/src/prompt.rs index 2c5a52403..24e7687e5 100644 --- a/apps/desktop/native-credential-ui/src/prompt.rs +++ b/apps/desktop/native-credential-ui/src/prompt.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, atomic::AtomicBool}; #[cfg(any(windows, target_os = "macos", target_os = "linux"))] use crate::ColorScheme; -use crate::DialogAppearance; +use crate::{DialogAppearance, NativePassword, purpose::Purpose}; use colossus_contracts::HostSecret; #[cfg(any(windows, target_os = "macos", target_os = "linux"))] @@ -44,9 +44,34 @@ pub async fn prompt( parent: tauri::Window, appearance: DialogAppearance, ) -> Result { + prompt_for(parent, appearance, Purpose::Token) + .await? + .into_token() +} + +/// Enter a PKCS#12 passphrase in a native secure control without saving it. +/// +/// Spaces, Unicode and an empty passphrase are preserved. The UTF-8 value is +/// bounded to 65,536 bytes; NUL, line breaks and control characters are rejected. +/// Native custody, cancellation and the exclusive dialog lease match [`prompt`]. +/// +/// # Errors +/// Returns only categorical native entry failures, never the entered value. +pub async fn prompt_password( + parent: tauri::Window, + appearance: DialogAppearance, +) -> Result { + prompt_for(parent, appearance, Purpose::Pkcs12Password).await +} + +async fn prompt_for( + parent: tauri::Window, + appearance: DialogAppearance, + purpose: Purpose, +) -> Result { #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { - let _ = (parent, appearance); + let _ = (parent, appearance, purpose); return Err(PromptError::Unsupported); } #[cfg(any(windows, target_os = "macos", target_os = "linux"))] @@ -66,11 +91,11 @@ pub async fn prompt( parent .run_on_main_thread(move || { #[cfg(windows)] - crate::windows::open(&native_parent, cancelled, completion, appearance); + crate::windows::open(&native_parent, cancelled, completion, appearance, purpose); #[cfg(target_os = "macos")] - crate::macos::open(&native_parent, cancelled, completion, appearance); + crate::macos::open(&native_parent, cancelled, completion, appearance, purpose); #[cfg(target_os = "linux")] - crate::linux::open(&native_parent, cancelled, completion, appearance); + crate::linux::open(&native_parent, cancelled, completion, appearance, purpose); }) .map_err(|_| PromptError::Unavailable)?; receiver.await.map_err(|_| PromptError::Cancelled)? diff --git a/apps/desktop/native-credential-ui/src/purpose.rs b/apps/desktop/native-credential-ui/src/purpose.rs new file mode 100644 index 000000000..7f0a021b2 --- /dev/null +++ b/apps/desktop/native-credential-ui/src/purpose.rs @@ -0,0 +1,62 @@ +//! Native dialog purpose and fixed, non-secret copy. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Purpose { + Token, + Pkcs12Password, +} + +impl Purpose { + pub(crate) fn title(self) -> &'static str { + match self { + Self::Token => "Save a Colossus credential", + Self::Pkcs12Password => "Unlock PKCS#12 identity", + } + } + + #[cfg(any(windows, target_os = "macos"))] + pub(crate) fn heading(self) -> &'static str { + match self { + Self::Token => "Save credential", + Self::Pkcs12Password => "Unlock PKCS#12 identity", + } + } + + pub(crate) fn description(self) -> &'static str { + match self { + Self::Token => "Your token is saved in the encrypted credential vault.", + Self::Pkcs12Password => { + "Enter the selected certificate package's passphrase. It is used only for native import and is not saved." + } + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Token => "Token", + Self::Pkcs12Password => "Passphrase", + } + } + + pub(crate) fn placeholder(self) -> &'static str { + match self { + Self::Token => "Paste your token", + Self::Pkcs12Password => "Passphrase (leave empty if the package has none)", + } + } + + #[cfg(not(windows))] + pub(crate) fn confirm(self) -> &'static str { + match self { + Self::Token => "Save", + Self::Pkcs12Password => "Continue", + } + } + + pub(crate) fn validate(self, value: &str) -> Result<(), crate::validation::InputError> { + match self { + Self::Token => crate::validation::validate(value), + Self::Pkcs12Password => crate::validation::validate_password(value), + } + } +} diff --git a/apps/desktop/native-credential-ui/src/validation.rs b/apps/desktop/native-credential-ui/src/validation.rs index 8a3c84726..cbbd9892d 100644 --- a/apps/desktop/native-credential-ui/src/validation.rs +++ b/apps/desktop/native-credential-ui/src/validation.rs @@ -5,6 +5,8 @@ pub(crate) enum InputError { Empty, TooLong, InvalidCharacter, + InvalidPasswordCharacter, + PasswordTooLong, } impl InputError { @@ -15,8 +17,55 @@ impl InputError { Self::InvalidCharacter => { "Use a token without spaces, line breaks, or non-ASCII characters." } + Self::InvalidPasswordCharacter => { + "Use a passphrase without NUL, line breaks, or control characters." + } + Self::PasswordTooLong => { + "Passphrase exceeds 65,536 UTF-8 bytes. The change was not accepted." + } + } + } +} + +pub(crate) fn validate_password(value: &str) -> Result<(), InputError> { + if value.len() > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + if value.chars().any(password_control) { + return Err(InputError::InvalidPasswordCharacter); + } + Ok(()) +} + +fn password_control(character: char) -> bool { + character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') +} + +/// Inspect native UTF-16 without a plaintext Rust String allocation. A Windows +/// single-character edit may temporarily contain a high surrogate until the +/// paired low surrogate arrives; final acceptance always requires valid UTF-16. +#[cfg(any(windows, target_os = "macos", test))] +pub(crate) fn password_units_bytes( + units: impl IntoIterator, + unit_count: usize, + partial: bool, +) -> Result { + if unit_count > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + let mut bytes = 0_usize; + for character in char::decode_utf16(units) { + let length = match character { + Ok(character) if !password_control(character) => character.len_utf8(), + Err(error) if partial && (0xd800..=0xdbff).contains(&error.unpaired_surrogate()) => 3, + _ => return Err(InputError::InvalidPasswordCharacter), + }; + bytes = bytes.saturating_add(length); + if bytes > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); } } + Ok(bytes) } pub(crate) fn validate(value: &str) -> Result<(), InputError> { @@ -75,4 +124,72 @@ mod tests { Err(InputError::InvalidCharacter) ); } + + #[test] + fn native_passwords_preserve_empty_spaces_unicode_and_exact_utf8_limit() { + for password in ["", "two words", " 密碼 🔐 café "] { + assert_eq!(validate_password(password), Ok(())); + assert_eq!( + password_units_bytes( + password.encode_utf16(), + password.encode_utf16().count(), + false + ), + Ok(password.len()) + ); + let owned = crate::NativePassword::new(password.to_owned()).unwrap(); + assert_eq!(owned.expose(), password); + assert_eq!(format!("{owned:?}"), "NativePassword([REDACTED])"); + } + let exact = "🔐".repeat(MAX_HOST_SECRET_BYTES / 4); + assert_eq!(validate_password(&exact), Ok(())); + assert_eq!( + password_units_bytes(exact.encode_utf16(), exact.encode_utf16().count(), false), + Ok(MAX_HOST_SECRET_BYTES) + ); + let oversized = format!("{exact}x"); + assert_eq!( + validate_password(&oversized), + Err(InputError::PasswordTooLong) + ); + assert_eq!( + password_units_bytes( + oversized.encode_utf16(), + oversized.encode_utf16().count(), + false + ), + Err(InputError::PasswordTooLong) + ); + // Password support does not change the existing token contract. + assert_eq!(validate("two words"), Err(InputError::InvalidCharacter)); + assert_eq!(validate("密碼"), Err(InputError::InvalidCharacter)); + assert_eq!(validate(""), Err(InputError::Empty)); + } + + #[test] + fn passwords_reject_controls_and_malformed_final_utf16_without_normalizing() { + for password in ["a\0b", "a\nb", "a\rb", "a\tb", "a\u{2028}b", "a\u{2029}b"] { + assert_eq!( + validate_password(password), + Err(InputError::InvalidPasswordCharacter) + ); + } + assert_eq!( + password_units_bytes([0xd800], 1, false), + Err(InputError::InvalidPasswordCharacter) + ); + assert_eq!( + password_units_bytes([0xdc00], 1, true), + Err(InputError::InvalidPasswordCharacter) + ); + assert_eq!(password_units_bytes([0xd800], 1, true), Ok(3)); + assert_eq!(password_units_bytes([0xd83d, 0xdd10], 2, false), Ok(4)); + let decomposed = "e\u{301}"; + assert_eq!( + crate::NativePassword::new(decomposed.into()) + .unwrap() + .expose(), + decomposed + ); + } } diff --git a/apps/desktop/native-credential-ui/src/windows.rs b/apps/desktop/native-credential-ui/src/windows.rs index 33ebdaba1..30fba2e61 100644 --- a/apps/desktop/native-credential-ui/src/windows.rs +++ b/apps/desktop/native-credential-ui/src/windows.rs @@ -9,8 +9,10 @@ mod painting; mod tests; mod visuals; -use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; +use crate::{ + DialogAppearance, NativePassword, PromptError, lifecycle::Completion, purpose::Purpose, + validation, +}; use std::{ cell::{Cell, RefCell}, ptr::{null, null_mut}, @@ -56,12 +58,13 @@ struct Session { save: HWND, cancel: HWND, visuals: visuals::Visuals, + purpose: Purpose, has_error: Cell, hovered: Cell, cancelled: Arc, created: Arc, completion: RefCell>, - result: RefCell>>, + result: RefCell>>, } pub(crate) fn open( @@ -69,6 +72,7 @@ pub(crate) fn open( cancelled: Arc, completion: Completion, appearance: DialogAppearance, + purpose: Purpose, ) { let Ok(handle) = parent.hwnd() else { completion.finish(Err(PromptError::Unavailable)); @@ -78,16 +82,37 @@ pub(crate) fn open( // SAFETY: Called on Tauri's UI thread with a live native parent. The owned // window retains the boxed session until WM_NCDESTROY. No callback blocks. unsafe { - create(parent, cancelled, completion, true, appearance); + create_for(parent, cancelled, completion, true, appearance, purpose); } } +#[cfg(any(test, feature = "native-test-driver"))] unsafe fn create( parent: HWND, cancelled: Arc, completion: Completion, visible: bool, appearance: DialogAppearance, +) -> Option { + unsafe { + create_for( + parent, + cancelled, + completion, + visible, + appearance, + Purpose::Token, + ) + } +} + +unsafe fn create_for( + parent: HWND, + cancelled: Arc, + completion: Completion, + visible: bool, + appearance: DialogAppearance, + purpose: Purpose, ) -> Option { if cancelled.load(Ordering::Acquire) || unsafe { IsWindow(parent) } == 0 { completion.finish(Err(PromptError::Cancelled)); @@ -125,6 +150,7 @@ unsafe fn create( save: null_mut(), cancel: null_mut(), visuals, + purpose, has_error: Cell::new(false), hovered: Cell::new(null_mut()), cancelled, @@ -138,7 +164,7 @@ unsafe fn create( CreateWindowExW( WS_EX_DLGMODALFRAME, class.as_ptr(), - wide("Save a Colossus credential").as_ptr(), + wide(purpose.title()).as_ptr(), WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN, left, top, @@ -331,12 +357,24 @@ unsafe fn release_session(window: HWND, pointer: *mut Session) { unsafe fn update_count(pointer: *const Session) { let length = unsafe { GetWindowTextLengthW((*pointer).input) }.max(0); - let message = format!("{length} / 65,536 bytes"); + let purpose = unsafe { (*pointer).purpose }; + let byte_count = if purpose == Purpose::Pkcs12Password { + unsafe { input::current_password_bytes((*pointer).input) }.ok() + } else { + usize::try_from(length).ok() + }; + let message = byte_count.map_or_else( + || "Incomplete passphrase character".into(), + |bytes| format!("{bytes} / 65,536 bytes"), + ); unsafe { (*pointer).has_error.set(false); SetWindowTextW((*pointer).status, wide(&message).as_ptr()); SetWindowTextW((*pointer).error, wide("").as_ptr()); - EnableWindow((*pointer).save, i32::from(length > 0)); + EnableWindow( + (*pointer).save, + i32::from(byte_count.is_some() && (length > 0 || purpose == Purpose::Pkcs12Password)), + ); painting::invalidate_input(pointer); } } @@ -344,12 +382,19 @@ unsafe fn update_count(pointer: *const Session) { unsafe fn save(window: HWND, pointer: *const Session) { let length = unsafe { GetWindowTextLengthW((*pointer).input) }; let count = usize::try_from(length).unwrap_or(0); - if count == 0 || count > colossus_contracts::MAX_HOST_SECRET_BYTES { + let purpose = unsafe { (*pointer).purpose }; + if count == 0 && purpose == Purpose::Token { unsafe { input::error(pointer, validation::InputError::Empty); } return; } + if count > colossus_contracts::MAX_HOST_SECRET_BYTES { + unsafe { + input::error(pointer, validation::InputError::PasswordTooLong); + } + return; + } let mut units = Zeroizing::new(vec![0_u16; count + 1]); let copied = unsafe { GetWindowTextW((*pointer).input, units.as_mut_ptr(), length + 1) }; if copied != length { @@ -359,13 +404,13 @@ unsafe fn save(window: HWND, pointer: *const Session) { return; }; let mut secret = Zeroizing::new(decoded); - if let Err(error) = validation::validate(&secret) { + if let Err(error) = purpose.validate(&secret) { unsafe { input::error(pointer, error); } return; } - if let Ok(secret) = HostSecret::new(std::mem::take(&mut *secret)) { + if let Ok(secret) = NativePassword::new(std::mem::take(&mut *secret)) { unsafe { (*pointer).result.replace(Some(Ok(secret))); DestroyWindow(window); diff --git a/apps/desktop/native-credential-ui/src/windows/acceptance.rs b/apps/desktop/native-credential-ui/src/windows/acceptance.rs index 478bea2a8..9a451dae3 100644 --- a/apps/desktop/native-credential-ui/src/windows/acceptance.rs +++ b/apps/desktop/native-credential-ui/src/windows/acceptance.rs @@ -6,8 +6,8 @@ mod appearance; mod isolation; use super::{CANCEL_ID, INPUT_ID, SAVE_ID, Session, create, wide}; +use crate::NativePassword; use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; use isolation::{InputIsolation, set_clipboard}; use std::{ ptr::{null, null_mut}, @@ -54,7 +54,7 @@ struct Dialog { error: HWND, save: HWND, cancel: HWND, - result: oneshot::Receiver>, + result: oneshot::Receiver>, } impl Dialog { diff --git a/apps/desktop/native-credential-ui/src/windows/controls.rs b/apps/desktop/native-credential-ui/src/windows/controls.rs index a31a82023..af0e2460a 100644 --- a/apps/desktop/native-credential-ui/src/windows/controls.rs +++ b/apps/desktop/native-credential-ui/src/windows/controls.rs @@ -21,15 +21,10 @@ const SS_RIGHT: u32 = 2; pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { // Populate handles before fonts/layout can cause synchronous paint messages. unsafe { - (*pointer).heading = control(window, "STATIC", "Save credential", 0, 0); - (*pointer).description = control( - window, - "STATIC", - "Your token is saved in the encrypted credential vault.", - 0, - 0, - ); - (*pointer).label = control(window, "STATIC", "&Token", 0, 0); + let purpose = (*pointer).purpose; + (*pointer).heading = control(window, "STATIC", purpose.heading(), 0, 0); + (*pointer).description = control(window, "STATIC", purpose.description(), 0, 0); + (*pointer).label = control(window, "STATIC", &format!("&{}", purpose.label()), 0, 0); (*pointer).input = control( window, "EDIT", @@ -42,7 +37,11 @@ pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { (*pointer).save = control( window, "BUTTON", - "&Save", + if purpose == crate::purpose::Purpose::Token { + "&Save" + } else { + "C&ontinue" + }, WS_TABSTOP | BS_OWNERDRAW as u32, SAVE_ID, ); @@ -78,7 +77,7 @@ pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { session.input, EM_SETCUEBANNER, 0, - wide("Paste your token").as_ptr() as isize, + wide(session.purpose.placeholder()).as_ptr() as isize, ); } for handle in [session.input, session.save, session.cancel] { @@ -89,7 +88,10 @@ pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { } unsafe { layout(window, pointer); - EnableWindow(session.save, 0); + EnableWindow( + session.save, + i32::from(session.purpose == crate::purpose::Purpose::Pkcs12Password), + ); } true } diff --git a/apps/desktop/native-credential-ui/src/windows/input.rs b/apps/desktop/native-credential-ui/src/windows/input.rs index e0e025cb3..fbdcf35c0 100644 --- a/apps/desktop/native-credential-ui/src/windows/input.rs +++ b/apps/desktop/native-credential-ui/src/windows/input.rs @@ -1,4 +1,5 @@ use super::{CANCEL_ID, SAVE_ID, Session, painting, wide}; +use crate::purpose::Purpose; use crate::validation::{InputError, validate_units}; use colossus_contracts::MAX_HOST_SECRET_BYTES; use std::slice; @@ -13,8 +14,9 @@ use windows_sys::Win32::{ Input::KeyboardAndMouse::{GetKeyState, SetFocus, VK_CONTROL, VK_SHIFT}, Shell::{DefSubclassProc, RemoveWindowSubclass}, WindowsAndMessaging::{ - GetParent, GetWindowTextLengthW, PostMessageW, SendMessageW, SetWindowTextW, WM_CHAR, - WM_CLOSE, WM_COMMAND, WM_KEYDOWN, WM_NCDESTROY, WM_PASTE, WM_SETTEXT, WM_SYSCHAR, + GetParent, GetWindowTextLengthW, GetWindowTextW, PostMessageW, SendMessageW, + SetWindowTextW, WM_CHAR, WM_CLOSE, WM_COMMAND, WM_KEYDOWN, WM_NCDESTROY, WM_PASTE, + WM_SETTEXT, WM_SYSCHAR, }, }, }; @@ -45,6 +47,72 @@ unsafe fn replacement_length(window: HWND, inserted: usize) -> usize { .saturating_add(inserted) } +unsafe fn current_units(window: HWND) -> Result>, InputError> { + let length = usize::try_from(unsafe { GetWindowTextLengthW(window) }) + .map_err(|_| InputError::InvalidPasswordCharacter)?; + if length > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + let mut units = Zeroizing::new(vec![0; length + 1]); + let capacity = i32::try_from(units.len()).map_err(|_| InputError::PasswordTooLong)?; + let copied = unsafe { GetWindowTextW(window, units.as_mut_ptr(), capacity) }; + if usize::try_from(copied).ok() != Some(length) { + return Err(InputError::InvalidPasswordCharacter); + } + units.truncate(length); + Ok(units) +} + +pub(super) unsafe fn current_password_bytes(window: HWND) -> Result { + let units = unsafe { current_units(window) }?; + crate::validation::password_units_bytes(units.iter().copied(), units.len(), false) +} + +unsafe fn validate_edit( + window: HWND, + pointer: *const Session, + inserted: &[u16], + replace_all: bool, + partial: bool, +) -> Result<(), InputError> { + if unsafe { (*pointer).purpose } == Purpose::Token { + let length = if replace_all { + inserted.len() + } else { + unsafe { replacement_length(window, inserted.len()) } + }; + return validate_units(inserted.iter().copied(), length); + } + let mut existing = unsafe { current_units(window) }?; + let (start, end) = if replace_all { + (0, existing.len()) + } else { + let (mut start, mut end) = (0_u32, 0_u32); + unsafe { + SendMessageW( + window, + EM_GETSEL, + &raw mut start as usize, + &raw mut end as isize, + ); + } + ( + (start as usize).min(existing.len()), + (end as usize).min(existing.len()), + ) + }; + let length = existing + .len() + .saturating_sub(end.saturating_sub(start)) + .saturating_add(inserted.len()); + if length > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + existing.splice(start..end, inserted.iter().copied()); + crate::validation::password_units_bytes(existing.iter().copied(), existing.len(), partial) + .map(|_| ()) +} + pub(super) unsafe extern "system" fn control_proc( window: HWND, message: u32, @@ -87,10 +155,9 @@ pub(super) unsafe extern "system" fn control_proc( if wparam == 8 { return unsafe { DefSubclassProc(window, message, wparam, lparam) }; } - let length = unsafe { replacement_length(window, 1) }; match u16::try_from(wparam) .map_err(|_| InputError::InvalidCharacter) - .and_then(|unit| validate_units([unit], length)) + .and_then(|unit| unsafe { validate_edit(window, pointer, &[unit], false, true) }) { Ok(()) => unsafe { DefSubclassProc(window, message, wparam, lparam) }, Err(reason) => { @@ -112,13 +179,8 @@ pub(super) unsafe extern "system" fn control_proc( (0..=MAX_HOST_SECRET_BYTES).find(|index| unsafe { *raw.add(*index) } == 0); end.map(|length| unsafe { slice::from_raw_parts(raw, length) }) }; - let result = units.ok_or(InputError::TooLong).and_then(|units| { - let length = if message == WM_SETTEXT { - units.len() - } else { - unsafe { replacement_length(window, units.len()) } - }; - validate_units(units.iter().copied(), length) + let result = units.ok_or(InputError::TooLong).and_then(|units| unsafe { + validate_edit(window, pointer, units, message == WM_SETTEXT, false) }); match result { Ok(()) => unsafe { DefSubclassProc(window, message, wparam, lparam) }, @@ -160,9 +222,7 @@ unsafe fn paste(window: HWND, pointer: *const Session) { .position(|unit| *unit == 0) .ok_or(InputError::TooLong) .and_then(|end| { - validate_units(units[..end].iter().copied(), unsafe { - replacement_length(window, end) - })?; + unsafe { validate_edit(window, pointer, &units[..end], false, false) }?; Ok(Zeroizing::new(units[..=end].to_vec())) }); unsafe { @@ -245,15 +305,19 @@ pub(super) unsafe fn mnemonic(character: WPARAM, pointer: *const Session) -> boo return false; }; let session = unsafe { &*pointer }; + let (input_key, confirm_key) = match session.purpose { + Purpose::Token => (b't', b's'), + Purpose::Pkcs12Password => (b'p', b'o'), + }; match character.to_ascii_lowercase() { - b't' => unsafe { + character if character == input_key => unsafe { SetFocus(session.input); }, - b's' | b'c' => { - let command = if character.eq_ignore_ascii_case(&b's') { - SAVE_ID - } else { + character if character == b'c' || character == confirm_key => { + let command = if character.eq_ignore_ascii_case(&b'c') { CANCEL_ID + } else { + SAVE_ID }; unsafe { PostMessageW(GetParent(session.input), WM_COMMAND, command, 0); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 236e07343..1322dbdc8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,7 +10,7 @@ "dev": "vite", "build": "npm run typecheck && vite build && npm run check:bundle-size", "check": "npm run typecheck && vitest run && npm run check:security && npm run format:check", - "check:security": "node --test ../../scripts/ci/desktop-contract.test.mjs ../../scripts/ci/desktop-manifest-binding.test.mjs ../../scripts/ci/desktop-unsigned-archive.test.mjs ../../scripts/ci/desktop-update-manifest.test.mjs ../../scripts/ci/tauri-updater-signature.test.mjs ../../scripts/ci/desktop-acceptance-targets.test.mjs ../../scripts/ci/dictation-model.test.mjs ../ui/test/*.test.mjs", + "check:security": "node --test ../../scripts/ci/desktop-contract.test.mjs ../../scripts/ci/desktop-manifest-binding.test.mjs ../../scripts/ci/desktop-unsigned-archive.test.mjs ../../scripts/ci/desktop-update-manifest.test.mjs ../../scripts/ci/tauri-updater-signature.test.mjs ../../scripts/ci/desktop-acceptance-targets.test.mjs ../../scripts/ci/dictation-model.test.mjs ../../scripts/ci/browser-pki-evidence.test.mjs ../ui/test/*.test.mjs", "check:bundle-size": "node ../../scripts/check-desktop-renderer-bundle.mjs .", "typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json", "format": "prettier --write .", @@ -20,6 +20,8 @@ "test:plugin-runtime": "node scripts/test-plugin-runtime.mjs", "test:approval-runtime": "node scripts/test-approval-runtime.mjs", "test:browser-native": "node scripts/test-browser-native.mjs", + "test:browser-chromium-native": "node scripts/test-browser-native.mjs --embedded-chromium", + "test:browser-pki-native": "node scripts/test-browser-pki-native.mjs", "test:browser:install": "playwright install chromium", "test:watch": "vitest", "tauri": "tauri", diff --git a/apps/desktop/scripts/browser-pki-evidence.mjs b/apps/desktop/scripts/browser-pki-evidence.mjs new file mode 100644 index 000000000..84eef9bb3 --- /dev/null +++ b/apps/desktop/scripts/browser-pki-evidence.mjs @@ -0,0 +1,66 @@ +// This validates actual TLS receipts from our unique server; mock/fixture TLS +// clients do not become Chromium evidence merely by producing the same receipt. +export function verifyPkiReceipts(metadata, report) { + if ( + report.schema_version !== 1 || + report.fixture_id !== metadata.fixture_id || + report.overflow !== false || + !Array.isArray(report.events) || + report.events.length > 512 + ) + throw new Error( + "PKI fixture evidence was absent, mismatched, or exceeded bounds", + ); + const event = (endpoint, path, status, fingerprint = null) => + report.events.some( + (value) => + value.endpoint === endpoint && + value.path === path && + value.status === status && + value.client_fingerprint_sha256 === fingerprint, + ); + if (!event("public", "/public", 200)) + throw new Error("Chromium did not request the trusted private CA fixture"); + for (const endpoint of ["untrusted", "wrong_hostname", "expired"]) { + if ( + !event(endpoint, "tls_denied", 0) || + report.events.some( + (value) => value.endpoint === endpoint && value.status > 0, + ) + ) + throw new Error(`Chromium did not enforce the ${endpoint} TLS fixture`); + } + if ( + !event("mtls", "/client-check", 200, metadata.fingerprints_sha256.client) || + !event( + "mtls", + "/client-check", + 403, + metadata.fingerprints_sha256.alternate_client, + ) || + !event("mtls", "tls_denied", 0) + ) + throw new Error( + "The server did not observe exact accepted/rejected native client identities", + ); + if ( + !event("public", "/redirect-denied", 302) || + !event("redirect_mtls", "tls_denied", 0) || + report.events.some( + (value) => + value.endpoint === "redirect_mtls" && + (value.status > 0 || value.client_fingerprint_sha256 !== null), + ) + ) + throw new Error("A client identity followed an unreviewed redirect origin"); + if ( + report.events.some( + (value) => + value.endpoint === "mtls" && + value.status === 200 && + value.client_fingerprint_sha256 !== metadata.fingerprints_sha256.client, + ) + ) + throw new Error("The mTLS fixture accepted a different client identity"); + return true; +} diff --git a/apps/desktop/scripts/native-presenter-probe.mjs b/apps/desktop/scripts/native-presenter-probe.mjs new file mode 100644 index 000000000..7a783e56f --- /dev/null +++ b/apps/desktop/scripts/native-presenter-probe.mjs @@ -0,0 +1,48 @@ +import { spawnSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const required = [ + "native_presenter_parent_close=passed", + "native_presenter_reentrant_close=passed", + "native_presenter_identity_reuse=passed", +]; + +// This probe uses the actual platform window/focus callbacks. It does not load +// CEF, attest sandbox containment or replace the browser rendering/input tier. +export async function runNativePresenterProbe(evidenceDirectory, nativeBuild) { + const executable = join( + resolve(nativeBuild), + `colossus-browser-presenter-probe${process.platform === "win32" ? ".exe" : ""}`, + ); + const result = spawnSync(executable, [], { + encoding: "utf8", + timeout: 15_000, + maxBuffer: 64 * 1024, + windowsHide: false, + }); + const passed = + result.status === 0 && + !result.error && + required.every((marker) => result.stdout?.includes(marker)); + await writeFile( + join(evidenceDirectory, "native-presenter-lifecycle.json"), + JSON.stringify( + { + passed, + platform: process.platform, + status: result.status, + checks: Object.fromEntries( + required.map((marker) => [marker.split("=")[0], passed]), + ), + }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, + ); + if (!passed) throw new Error("Native presenter lifecycle acceptance failed"); + console.log( + "PASS native presenter parent close, reentrant close and stale identity", + ); +} diff --git a/apps/desktop/scripts/test-browser-chromium-windows.mjs b/apps/desktop/scripts/test-browser-chromium-windows.mjs new file mode 100644 index 000000000..891b3ff81 --- /dev/null +++ b/apps/desktop/scripts/test-browser-chromium-windows.mjs @@ -0,0 +1,321 @@ +import { createServer } from "node:http"; +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { acceptanceTargets } from "./acceptance-targets.mjs"; +import { runNativePresenterProbe } from "./native-presenter-probe.mjs"; +import { developmentEnvironment } from "../../../scripts/development-launch.mjs"; + +const desktop = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repository = resolve(desktop, "../.."); +if (process.platform !== "win32" || process.arch !== "x64") + throw new Error("Windows Chromium acceptance requires native Windows x64"); +if (!process.env.COLOSSUS_CEF_ROOT || !process.env.COLOSSUS_CEF_NATIVE_LIB_DIR) + throw new Error( + "Run scripts/desktop-chromium-preview-windows.ps1 -BuildOnly first", + ); +const targets = acceptanceTargets( + repository, + desktop, + process.env.CARGO_TARGET_DIR, +); +const run = (command, arguments_, options = {}) => { + const result = spawnSync(command, arguments_, { + cwd: repository, + stdio: "inherit", + windowsHide: true, + ...options, + }); + if (result.status !== 0) + throw new Error(`Windows native acceptance command failed: ${command}`); +}; +// Only fixed npm arguments use the Windows command-script shell. Paths and all +// native staging/evidence arguments use structured, direct process invocation. +run("npm.cmd", ["run", "build"], { cwd: desktop, shell: true }); +run("cargo", [ + "build", + "--locked", + "--manifest-path", + "apps/desktop/src-tauri/Cargo.toml", + "--target-dir", + targets.native, + "--lib", + "--features", + "embedded-chromium-preview,browser-test-bridge,tauri/custom-protocol", +]); +const evidenceDirectory = resolve( + process.env.COLOSSUS_BROWSER_ACCEPTANCE_EVIDENCE_DIR ?? + join(repository, ".local", `chromium-acceptance-${randomUUID()}`), +); +await mkdir(evidenceDirectory, { recursive: true }); +await runNativePresenterProbe( + evidenceDirectory, + process.env.COLOSSUS_CEF_NATIVE_LIB_DIR, +); +const stagedApp = join(evidenceDirectory, "windows-component"); +run("python", [ + "-B", + "native/browser/scripts/stage_windows.py", + "--cef-root", + resolve(process.env.COLOSSUS_CEF_ROOT), + "--native-build", + resolve(process.env.COLOSSUS_CEF_NATIVE_LIB_DIR), + "--client-dll", + join(targets.native, "debug", "colossus_desktop_lib.dll"), + "--destination", + stagedApp, +]); +const executable = join(stagedApp, "colossus-chromium-preview.exe"); +const root = join(homedir(), `.colossus-browser-acceptance-${randomUUID()}`); +const fixtureRequests = { "/first": 0, "/second": 0 }; +const server = createServer((request, response) => { + if (Object.hasOwn(fixtureRequests, request.url)) + fixtureRequests[request.url] += 1; + response.writeHead(200, { + "Content-Type": "text/html", + "Cache-Control": "no-store", + }); + response.end( + `${request.url === "/second" ? "Second page" : "First page"}

Colossus native Chromium fixture

Next page
`, + ); +}); +await new Promise((done) => server.listen(0, "127.0.0.1", done)); +const address = `http://127.0.0.1:${server.address().port}`; +const evidenceScript = join( + repository, + "native/browser/scripts/windows_process_evidence.ps1", +); +const query = (probe = false, terminate = false) => { + const result = spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + evidenceScript, + "-Executable", + executable, + "-Home", + root, + ...(probe ? ["-Probe"] : []), + ...(terminate ? ["-Terminate"] : []), + ], + { + cwd: repository, + encoding: "utf8", + windowsHide: true, + timeout: 15_000, + maxBuffer: 256 * 1024, + }, + ); + if (result.status !== 0) + throw new Error("Windows owned browser process evidence failed"); + return JSON.parse(result.stdout); +}; +let output = "", + outputFailure = false, + nativeHostPid, + sandboxEvidence, + cleanupEvidence; +let child, timeout; +const stop = () => { + // Terminate only the exact acceptance host whose process handle Node retains; + // Helpers are reaped later through separately retained, identity-checked + // native handles. No image-name or PID-only tree kill is used. + try { + child?.kill(); + } catch { + outputFailure = true; + } +}; +try { + console.log(`Native Chromium acceptance evidence: ${evidenceDirectory}`); + child = spawn(executable, [address], { + cwd: repository, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: false, + env: { ...developmentEnvironment(process.env), COLOSSUS_HOME: root }, + }); + for (const [stream, destination] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + stream.on("data", (chunk) => { + destination.write(chunk); + output += chunk.toString(); + if (Buffer.byteLength(output) > 256 * 1024) { + outputFailure = true; + stop(); + return; + } + const host = /^NATIVE_BROWSER_HOST_PID ([1-9][0-9]*)\r?\n/mu.exec(output); + if (host) nativeHostPid = Number(host[1]); + if ( + !sandboxEvidence && + output.includes( + "PASS CEF page rendering inside Desktop Tauri/Win32 with sandbox preserved", + ) + ) { + try { + sandboxEvidence = query(true); + if (!sandboxEvidence.passed) + throw new Error( + "Windows renderer token/resource acceptance failed", + ); + console.log( + "PASS Windows renderer restricted token and OS file access denial", + ); + output += + "PASS Windows renderer restricted token and OS file access denial\n"; + writeFileSync( + join(root, "windows-sandbox-evidence.ready"), + "accepted\n", + { flag: "wx" }, + ); + } catch (error) { + outputFailure = true; + console.error(error); + stop(); + } + } + }); + } + timeout = setTimeout(() => { + outputFailure = true; + console.error("Windows Chromium acceptance timed out"); + stop(); + }, 240_000); + process.exitCode = await new Promise((done) => { + child.once("error", () => done(1)); + // close follows stdio drain; the final shutdown markers must be retained. + child.once("close", (code) => done(code === 0 ? 0 : 1)); + }); + clearTimeout(timeout); + const required = [ + "PASS actual Desktop controller document authorizes browser controls", + "PASS CEF page rendering inside Desktop Tauri/Win32 with sandbox preserved", + "PASS Windows native OS mouse and keyboard reach Chromium guest", + "PASS Windows renderer restricted token and OS file access denial", + "PASS CEF navigation, back, forward, and reload", + "PASS CEF tab creation, selection, hidden sibling, and close", + "PASS CEF native bounds and rendered content after Desktop resize", + "PASS Desktop overlay revokes native Chromium visibility", + "PASS stale viewport heartbeat hides CEF and fresh lease restores it", + "PASS CEF workspace generation, foreign tab, app-origin denial, and disabled production automation", + "PASS CEF tab close acknowledgements and temporary profile teardown", + "PASS live CEF tab retained for native application quit", + "PASS native application terminate requested with a live CEF tab", + "PASS native application quit acknowledged live CEF tab close", + "PASS native CefShutdown acknowledged after all browser close callbacks", + "PASS CEF private application cache removed after shutdown", + ...(process.env.COLOSSUS_BROWSER_PKI_FIXTURE + ? ["PKI_NATIVE_TLS_CONFORMANCE_PASSED", "PKI_OS_STORE_CUSTODY_PENDING"] + : []), + "native browser acceptance passed", + ]; + if ( + nativeHostPid !== child.pid || + outputFailure || + !sandboxEvidence?.passed || + required.some((marker) => !output.includes(marker)) || + fixtureRequests["/first"] < 4 || + fixtureRequests["/second"] < 2 + ) + process.exitCode = 1; + const deadline = Date.now() + 5000; + let survivors; + do { + survivors = query(); + if (!survivors.processQuerySucceeded || survivors.owned.length === 0) break; + await new Promise((done) => setTimeout(done, 100)); + } while (Date.now() < deadline); + if (!survivors.processQuerySucceeded || survivors.owned.length > 0) { + process.exitCode = 1; + console.error( + "Windows Chromium helpers survived acknowledged shutdown", + survivors.owned, + ); + } else { + output += "PASS no Chromium helper processes survive Desktop shutdown\n"; + console.log("PASS no Chromium helper processes survive Desktop shutdown"); + } + if (process.exitCode !== 0) { + try { + cleanupEvidence = query(false, true); + if (!cleanupEvidence.terminatedOwned) + throw new Error("Windows browser cleanup was not acknowledged"); + } catch (error) { + cleanupEvidence = { terminatedOwned: false }; + console.error(error.message); + } + } +} catch (error) { + process.exitCode = 1; + console.error(error.message); + stop(); +} finally { + clearTimeout(timeout); + if (process.exitCode !== 0 && cleanupEvidence === undefined) { + try { + cleanupEvidence = query(false, true); + if (!cleanupEvidence.terminatedOwned) + throw new Error("Windows browser cleanup was not acknowledged"); + } catch { + cleanupEvidence = { terminatedOwned: false }; + console.error( + "Windows browser cleanup could not be proven; private home retained", + ); + } + } + server.closeAllConnections(); + await new Promise((done) => server.close(done)); + // A failed process cleanup keeps profile evidence for operator inspection. + // Successful acceptance alone authorizes deleting this exact generated home. + if (process.exitCode === 0) { + try { + await rm(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 500, + }); + } catch { + process.exitCode = 1; + console.error("Windows acceptance home cleanup failed"); + } + } + // Keep categorical evidence even for startup errors or failed process queries. + await writeFile(join(evidenceDirectory, "native-acceptance.log"), output); + await writeFile( + join(evidenceDirectory, "native-acceptance.json"), + JSON.stringify( + { + passed: process.exitCode === 0, + platform: process.platform, + architecture: process.arch, + stagedApp, + executable, + nativeHostPid: nativeHostPid ?? null, + launchServices: false, + chromiumSandboxRequired: true, + sandboxEvidence: sandboxEvidence ?? null, + cleanupEvidence: cleanupEvidence ?? null, + productionAutomationEnabled: false, + pkiTlsConformanceRequested: Boolean( + process.env.COLOSSUS_BROWSER_PKI_FIXTURE, + ), + fixtureOrigin: address, + fixtureRequests, + checkedAt: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + ); +} diff --git a/apps/desktop/scripts/test-browser-native.mjs b/apps/desktop/scripts/test-browser-native.mjs index a778bd919..2c0eb58b4 100644 --- a/apps/desktop/scripts/test-browser-native.mjs +++ b/apps/desktop/scripts/test-browser-native.mjs @@ -1,19 +1,47 @@ import { createServer } from "node:http"; import { spawn, spawnSync } from "node:child_process"; -import { rm } from "node:fs/promises"; +import { mkdir, open as openFile, rm, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { acceptanceTargets } from "./acceptance-targets.mjs"; +import { runNativePresenterProbe } from "./native-presenter-probe.mjs"; const desktop = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repository = resolve(desktop, "../.."); +const chromium = process.argv.slice(2).includes("--embedded-chromium"); +if ( + process.argv.slice(2).some((argument) => argument !== "--embedded-chromium") +) + throw new Error("Usage: test-browser-native.mjs [--embedded-chromium]"); +if (chromium && process.platform === "win32") { + await import("./test-browser-chromium-windows.mjs"); + process.exit(process.exitCode ?? 0); +} +if (chromium && process.platform !== "darwin") + throw new Error( + "Native embedded Chromium acceptance currently requires macOS", + ); +if ( + chromium && + (!process.env.COLOSSUS_CEF_ROOT || !process.env.COLOSSUS_CEF_NATIVE_LIB_DIR) +) + throw new Error( + "Set COLOSSUS_CEF_ROOT and COLOSSUS_CEF_NATIVE_LIB_DIR to the pinned CEF source and native build", + ); const targets = acceptanceTargets( repository, desktop, process.env.CARGO_TARGET_DIR, ); +if (chromium) { + const renderer = spawnSync("npm", ["run", "build"], { + cwd: desktop, + stdio: "inherit", + }); + if (renderer.status !== 0) process.exit(renderer.status ?? 1); +} const build = spawnSync( "cargo", [ @@ -26,11 +54,53 @@ const build = spawnSync( "--example", "browser-acceptance", "--features", - "browser-test-bridge", + chromium + ? "embedded-chromium-preview,browser-test-bridge,tauri/custom-protocol" + : "browser-test-bridge", ], { cwd: repository, stdio: "inherit", windowsHide: true }, ); if (build.status !== 0) process.exit(build.status ?? 1); +let executable = join( + targets.native, + "debug/examples", + `browser-acceptance${process.platform === "win32" ? ".exe" : ""}`, +); +let evidenceDirectory; +let stagedApp; +if (chromium) { + evidenceDirectory = resolve( + process.env.COLOSSUS_BROWSER_ACCEPTANCE_EVIDENCE_DIR ?? + join(repository, ".local", `chromium-acceptance-${randomUUID()}`), + ); + await mkdir(evidenceDirectory, { recursive: true, mode: 0o700 }); + await runNativePresenterProbe( + evidenceDirectory, + process.env.COLOSSUS_CEF_NATIVE_LIB_DIR, + ); + stagedApp = join(evidenceDirectory, "Colossus Chromium Acceptance.app"); + const stage = spawnSync( + "python3", + [ + "-B", + join(repository, "native/browser/scripts/stage_macos.py"), + "--cef-root", + resolve(process.env.COLOSSUS_CEF_ROOT), + "--native-build", + resolve(process.env.COLOSSUS_CEF_NATIVE_LIB_DIR), + "--executable", + executable, + "--app", + stagedApp, + "--platform", + process.arch === "arm64" ? "macosarm64" : "macosx64", + ], + { cwd: repository, stdio: "inherit" }, + ); + if (stage.status !== 0) process.exit(stage.status ?? 1); + executable = join(stagedApp, "Contents/MacOS/browser-acceptance"); + console.log(`Native Chromium acceptance evidence: ${evidenceDirectory}`); +} // Native code creates this new directory with an owner-only DACL. Windows' shared // temp ancestors do not satisfy Colossus home namespace-authority requirements. const root = join(homedir(), `.colossus-browser-acceptance-${randomUUID()}`); @@ -46,7 +116,10 @@ const downloads = { "%PDF-1.4\n% Synthetic attachment\n%%EOF", ], }; +const fixtureRequests = { "/first": 0, "/second": 0 }; const server = createServer((request, response) => { + if (Object.hasOwn(fixtureRequests, request.url)) + fixtureRequests[request.url] += 1; const download = downloads[request.url]; if (download) { response.writeHead(200, { @@ -61,30 +134,244 @@ const server = createServer((request, response) => { "Cache-Control": "no-store", }); response.end( - `${request.url === "/second" ? "Second page" : "First page"}

Native browser fixture

Next page`, + `${request.url === "/second" ? "Second page" : "First page"}

Colossus native Chromium fixture

Next page
`, ); }); await new Promise((done) => server.listen(0, "127.0.0.1", done)); const address = `http://127.0.0.1:${server.address().port}`; try { - const child = spawn( - join( - targets.native, - "debug/examples", - `browser-acceptance${process.platform === "win32" ? ".exe" : ""}`, - ), - [address], - { - cwd: repository, - stdio: "inherit", - windowsHide: true, - env: { ...process.env, COLOSSUS_HOME: root }, + let launchExecutable = executable; + let launchArguments = [address]; + const logReaders = []; + if (chromium) { + const stdoutPath = join(evidenceDirectory, "native.stdout.log"); + const stderrPath = join(evidenceDirectory, "native.stderr.log"); + for (const [path, destination] of [ + [stdoutPath, process.stdout], + [stderrPath, process.stderr], + ]) { + const file = await openFile(path, "wx", 0o600); + await file.close(); + logReaders.push({ + file: await openFile(path, "r"), + offset: 0, + destination, + }); + } + const pause = Math.min( + 30_000, + Math.max( + 0, + Number.parseInt( + process.env.COLOSSUS_BROWSER_ACCEPTANCE_PAUSE_MS ?? "0", + 10, + ) || 0, + ), + ); + const foregroundWait = Math.min( + 60_000, + Math.max( + 0, + Number.parseInt( + process.env.COLOSSUS_BROWSER_ACCEPTANCE_FOREGROUND_WAIT_MS ?? "15000", + 10, + ) || 0, + ), + ); + launchExecutable = "/usr/bin/open"; + launchArguments = [ + "-n", + "-W", + "--stdout", + stdoutPath, + "--stderr", + stderrPath, + "--env", + `COLOSSUS_HOME=${root}`, + "--env", + "MallocNanoZone=0", + "--env", + `COLOSSUS_BROWSER_ACCEPTANCE_PAUSE_MS=${pause}`, + "--env", + `COLOSSUS_BROWSER_ACCEPTANCE_FOREGROUND_WAIT_MS=${foregroundWait}`, + ...(process.env.COLOSSUS_BROWSER_PKI_FIXTURE + ? [ + "--env", + `COLOSSUS_BROWSER_PKI_FIXTURE=${resolve(process.env.COLOSSUS_BROWSER_PKI_FIXTURE)}`, + ] + : []), + stagedApp, + "--args", + address, + ]; + } + const child = spawn(launchExecutable, launchArguments, { + cwd: repository, + stdio: chromium ? ["ignore", "pipe", "pipe"] : "inherit", + windowsHide: true, + env: { + ...process.env, + COLOSSUS_HOME: root, + ...(chromium ? { MallocNanoZone: "0" } : {}), + }, + }); + let output = ""; + let nativeHostPid; + let outputFailure = false; + const ownedProcesses = () => { + const result = spawnSync("/bin/ps", ["-axo", "pid=,command="], { + encoding: "utf8", + timeout: 3_000, + maxBuffer: 1024 * 1024, + }); + return { + ...result, + owned: (result.stdout ?? "") + .split("\n") + .filter((line) => line.includes(stagedApp) || line.includes(root)), + }; + }; + let sandboxEvidence; + const sampleSandbox = () => { + const processes = ownedProcesses(); + const owned = processes.owned; + const renderers = owned.filter((line) => + /(?:^|\s)--type=renderer(?:\s|$)/u.test(line), + ); + const unsafeSandboxFlags = + /(?:^|\s)--(?:no-sandbox|disable-(?:sandbox|gpu-sandbox|setuid-sandbox|seccomp-filter-sandbox|namespace-sandbox)|allow-sandbox-debugging|single-process|in-process-gpu)(?:=|\s|$)/u; + const remoteDebugging = + /(?:^|\s)--remote-debugging-(?:port|address|pipe)(?:=|\s|$)/u; + sandboxEvidence = { + ownedProcessCount: owned.length, + rendererCount: renderers.length, + seatbeltClientPresent: + renderers.length > 0 && + renderers.every((line) => + /(?:^|\s)--seatbelt-client(?:=|\s|$)/u.test(line), + ), + unsafeSandboxFlagsAbsent: owned.every( + (line) => !unsafeSandboxFlags.test(line), + ), + remoteDebuggingPortAbsent: owned.every( + (line) => !remoteDebugging.test(line), + ), + processQuerySucceeded: processes.status === 0, + checkedAt: new Date().toISOString(), + }; + sandboxEvidence.passed = + sandboxEvidence.processQuerySucceeded && + sandboxEvidence.rendererCount > 0 && + sandboxEvidence.seatbeltClientPresent && + sandboxEvidence.unsafeSandboxFlagsAbsent && + sandboxEvidence.remoteDebuggingPortAbsent; + if (sandboxEvidence.passed) { + const marker = `PASS live Chromium sandbox helpers renderer_count=${sandboxEvidence.rendererCount} seatbelt_client=true unsafe_sandbox_flags=false remote_debugging=false`; + console.log(marker); + output += `${marker}\n`; + } else { + console.error( + "Live Chromium sandbox helper evidence failed", + sandboxEvidence, + ); + } + }; + const stop = () => { + if (chromium) { + // LaunchServices owns the native host, while child.pid identifies only + // open's wait proxy. Match our exact fresh app/home before signaling. + for (const line of ownedProcesses().owned) { + const match = /^\s*(\d+)\s+(.+)$/.exec(line); + if (!match) continue; + const current = spawnSync( + "/bin/ps", + ["-p", match[1], "-o", "command="], + { encoding: "utf8", timeout: 3_000, maxBuffer: 1024 * 1024 }, + ); + if (current.status !== 0 || current.stdout.trim() !== match[2].trim()) + continue; + try { + process.kill(Number(match[1]), "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") console.error(error); + } + } + } + try { + child.kill(); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + }; + const forwardOutput = (chunk, destination) => { + destination.write(chunk); + output += chunk.toString(); + const host = /^NATIVE_BROWSER_HOST_PID ([1-9][0-9]*)\r?\n/mu.exec(output); + if (!nativeHostPid && host) { + nativeHostPid = Number(host[1]); + console.log(`Native Chromium acceptance host PID: ${nativeHostPid}`); + } + if ( + !sandboxEvidence && + output.includes( + "PASS CEF page rendering inside Desktop Tauri/AppKit with sandbox preserved", + ) + ) + sampleSandbox(); + if (Buffer.byteLength(output) > 256 * 1024) { + outputFailure = true; + console.error("Native Chromium acceptance exceeded 256 KiB output"); + stop(); + } + }; + const readLogs = async () => { + for (const reader of logReaders) { + const size = (await reader.file.stat()).size; + if (size < reader.offset || size > 256 * 1024) + throw new Error( + "Native Chromium output log exceeded its bound or was truncated", + ); + const available = size - reader.offset; + if (available === 0) continue; + const buffer = Buffer.alloc(available); + const read = await reader.file.read(buffer, 0, available, reader.offset); + reader.offset += read.bytesRead; + forwardOutput(buffer.subarray(0, read.bytesRead), reader.destination); + } + }; + let polling = false; + let pollTimer; + if (chromium) { + console.log(`Native Chromium LaunchServices proxy PID: ${child.pid}`); + for (const [stream, destination] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + stream.on("data", (chunk) => { + forwardOutput(chunk, destination); + }); + } + pollTimer = setInterval(() => { + if (polling) return; + polling = true; + readLogs() + .catch((error) => { + outputFailure = true; + console.error(error); + stop(); + }) + .finally(() => { + polling = false; + }); + }, 100); + } + const timeout = setTimeout( + () => { + console.error("Native browser acceptance timed out"); + stop(); }, + chromium ? 240_000 : 120_000, ); - const timeout = setTimeout(() => { - console.error("Native browser acceptance timed out"); - child.kill(); - }, 120_000); process.exitCode = await new Promise((done) => { child.once("error", (error) => { console.error(error); @@ -99,6 +386,130 @@ try { }); }); clearTimeout(timeout); + if (chromium) { + clearInterval(pollTimer); + while (polling) await new Promise((done) => setTimeout(done, 10)); + try { + await readLogs(); + } catch (error) { + console.error(error); + outputFailure = true; + } finally { + await Promise.all(logReaders.map((reader) => reader.file.close())); + } + const required = [ + "PASS actual Desktop controller document authorizes browser controls", + "PASS CEF page rendering inside Desktop Tauri/AppKit with sandbox preserved", + "PASS live Chromium sandbox helpers", + "PASS CEF navigation, back, forward, and reload", + "PASS CEF tab creation, selection, hidden sibling, and close", + "PASS CEF native bounds and rendered content after Desktop resize", + "PASS Desktop overlay revokes native Chromium visibility", + "PASS stale viewport heartbeat hides CEF and fresh lease restores it", + "PASS CEF workspace generation, foreign tab, app-origin denial, and disabled production automation", + "PASS CEF tab close acknowledgements and temporary profile teardown", + "PASS live CEF tab retained for native application quit", + "PASS native application terminate requested with a live CEF tab", + "PASS native application quit acknowledged live CEF tab close", + "PASS native CefShutdown acknowledged after all browser close callbacks", + "PASS native clean shutdown after close acknowledgements", + "PASS CEF private application cache removed after shutdown", + "native browser acceptance passed", + ...(process.env.COLOSSUS_BROWSER_PKI_FIXTURE + ? ["PKI_NATIVE_TLS_CONFORMANCE_PASSED", "PKI_OS_STORE_CUSTODY_PENDING"] + : []), + ]; + if ( + !nativeHostPid || + outputFailure || + required.some((marker) => !output.includes(marker)) + ) { + console.error( + "Native Chromium exited without all required acceptance evidence", + ); + process.exitCode = 1; + } + if (fixtureRequests["/first"] < 3 || fixtureRequests["/second"] < 2) { + console.error( + "CEF did not perform the expected native page requests", + fixtureRequests, + ); + process.exitCode = 1; + } else { + const marker = `PASS native loopback page requests first=${fixtureRequests["/first"]} second=${fixtureRequests["/second"]}`; + console.log(marker); + output += `${marker}\n`; + } + // A zero parent exit does not establish helper cleanup. Match only the + // freshly generated app/home owned by this exact acceptance run. + let processes; + let survivors; + const reapDeadline = Date.now() + 5_000; + do { + processes = ownedProcesses(); + survivors = processes.owned; + if (processes.status !== 0 || survivors.length === 0) break; + await new Promise((done) => setTimeout(done, 100)); + } while (Date.now() < reapDeadline); + if (processes.status !== 0 || survivors.length > 0) { + console.error("Native Chromium helpers survived shutdown", survivors); + process.exitCode = 1; + stop(); + for (const line of survivors) { + const match = /^\s*(\d+)\s+(.+)$/.exec(line); + if (!match) continue; + const current = spawnSync( + "/bin/ps", + ["-p", match[1], "-o", "command="], + { encoding: "utf8", timeout: 3_000, maxBuffer: 1024 * 1024 }, + ); + // Failed CEF children may have moved into their own process group. + // Recheck the exact command from our fresh app/home before cleanup. + if (current.status === 0 && current.stdout.trim() === match[2].trim()) { + try { + process.kill(Number(match[1]), "SIGKILL"); + output += `CLEANUP terminated failed-run Chromium helper PID ${match[1]}\n`; + } catch (error) { + if (error.code !== "ESRCH") console.error(error); + } + } + } + } else { + const marker = + "PASS no Chromium helper processes survive Desktop shutdown"; + console.log(marker); + output += `${marker}\n`; + } + await writeFile(join(evidenceDirectory, "native-acceptance.log"), output); + await writeFile( + join(evidenceDirectory, "native-acceptance.json"), + JSON.stringify( + { + passed: process.exitCode === 0, + platform: process.platform, + architecture: process.arch, + stagedApp, + executable, + nativeHostPid: nativeHostPid ?? null, + launchServices: true, + cefRoot: resolve(process.env.COLOSSUS_CEF_ROOT), + nativeBuild: resolve(process.env.COLOSSUS_CEF_NATIVE_LIB_DIR), + chromiumSandboxRequired: true, + sandboxEvidence: sandboxEvidence ?? null, + productionAutomationEnabled: false, + pkiTlsConformanceRequested: Boolean( + process.env.COLOSSUS_BROWSER_PKI_FIXTURE, + ), + pkiOsStoreCustodyVerified: false, + fixtureOrigin: address, + fixtureRequests, + checkedAt: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + ); + } } finally { server.closeAllConnections(); await new Promise((done) => server.close(done)); diff --git a/apps/desktop/scripts/test-browser-pki-native.mjs b/apps/desktop/scripts/test-browser-pki-native.mjs new file mode 100644 index 000000000..99671010a --- /dev/null +++ b/apps/desktop/scripts/test-browser-pki-native.mjs @@ -0,0 +1,249 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +import { verifyPkiReceipts } from "./browser-pki-evidence.mjs"; + +const desktop = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repository = resolve(desktop, "../.."); +if (!["darwin", "win32"].includes(process.platform)) + throw new Error("Native Chromium PKI conformance requires macOS or Windows"); +if (!process.stdin.isTTY || process.argv.length !== 2) + throw new Error( + "Native PKI requires an interactive operator and no arguments", + ); +if (!process.env.COLOSSUS_CEF_ROOT || !process.env.COLOSSUS_CEF_NATIVE_LIB_DIR) + throw new Error( + "Prepare the pinned native Chromium component before PKI conformance", + ); + +// Python atomically creates an owner-private new directory, including a protected +// Windows DACL. This runner never installs, overwrites, or deletes OS-store items. +const directory = join( + homedir(), + `.colossus-browser-pki-fixture-${randomUUID()}`, +); +const fixture = spawn( + process.platform === "win32" ? "python" : "python3", + [ + "-B", + join(repository, "native/browser/scripts/pki_fixture.py"), + "--directory", + directory, + ], + { cwd: repository, stdio: ["pipe", "pipe", "inherit"], windowsHide: true }, +); +let pending = ""; +let outputBytes = 0; +let fixtureFailure; +fixture.stdin.on("error", () => { + fixtureFailure = new Error("PKI fixture control channel closed"); +}); +const messages = []; +const waiters = []; +const deliver = (value) => { + const waiting = waiters.shift(); + if (waiting) waiting.resolve(value); + else messages.push(value); +}; +fixture.stdout.setEncoding("utf8"); +fixture.stdout.on("data", (chunk) => { + pending += chunk; + outputBytes += Buffer.byteLength(chunk); + if (outputBytes > 256 * 1024 || Buffer.byteLength(pending) > 128 * 1024) { + fixtureFailure = new Error("PKI fixture exceeded its evidence bound"); + fixture.kill(); + return; + } + while (pending.includes("\n")) { + const offset = pending.indexOf("\n"); + const line = pending.slice(0, offset); + pending = pending.slice(offset + 1); + try { + deliver(JSON.parse(line)); + } catch { + fixtureFailure = new Error("PKI fixture returned invalid evidence"); + fixture.kill(); + } + } +}); +const fixtureExited = new Promise((resolveExit) => { + fixture.once("error", (error) => { + fixtureFailure = error; + for (const waiter of waiters.splice(0)) waiter.reject(error); + resolveExit(1); + }); + fixture.once("exit", (code) => { + for (const waiter of waiters.splice(0)) + waiter.reject(new Error("PKI fixture exited before returning evidence")); + resolveExit(code ?? 1); + }); +}); +const nextMessage = async () => { + if (fixtureFailure) throw fixtureFailure; + if (fixture.exitCode !== null || fixture.killed) + throw new Error("PKI fixture exited before returning evidence"); + if (messages.length) return messages.shift(); + return new Promise((resolveMessage, reject) => { + const waiter = { resolve: resolveMessage, reject }; + waiters.push(waiter); + setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) { + waiters.splice(index, 1); + reject(new Error("PKI fixture evidence timed out")); + } + }, 30_000).unref(); + }); +}; +const consoleInput = createInterface({ + input: process.stdin, + output: process.stdout, +}); +const interruption = new AbortController(); +const interrupt = () => interruption.abort(); +process.on("SIGINT", interrupt); +process.on("SIGTERM", interrupt); +const acknowledge = (message, required) => + new Promise((resolveAnswer, reject) => { + const abort = () => { + clearTimeout(timeout); + reject( + new Error( + "Operator PKI conformance interrupted; remove only generated OS-store items", + ), + ); + }; + const timeout = setTimeout(() => { + interruption.signal.removeEventListener("abort", abort); + reject(new Error("Operator PKI step timed out")); + }, 600_000); + interruption.signal.addEventListener("abort", abort, { once: true }); + if (interruption.signal.aborted) return abort(); + consoleInput.question(message, (answer) => { + clearTimeout(timeout); + interruption.signal.removeEventListener("abort", abort); + if (answer === required) resolveAnswer(); + else reject(new Error("Operator PKI prerequisite was not acknowledged")); + }); + }); +let native; +let metadata; +let serverReceipts; +let tlsPassed = false; +let operatorCleanupReported = false; +try { + const ready = await nextMessage(); + if ( + ready.ready !== true || + ready.directory !== directory || + ready.fixture?.schema_version !== 1 + ) + throw new Error("PKI fixture could not establish its unique allocation"); + metadata = ready.fixture; + console.log(`Unique operator PKI fixture: ${metadata.fixture_id}`); + console.log(`CA DER: ${join(directory, "ca.der")}`); + console.log(`Client PFX: ${join(directory, "client.pfx")}`); + console.log( + `Alternate client PFX: ${join(directory, "alternate_client.pfx")}`, + ); + console.log( + `Native import passphrase file (never sent to renderer/model): ${join(directory, "passphrase.txt")}`, + ); + console.log(`CA fingerprint: ${metadata.fingerprints_sha256.ca}`); + console.log(`Client fingerprint: ${metadata.fingerprints_sha256.client}`); + console.log( + `Alternate fingerprint: ${metadata.fingerprints_sha256.alternate_client}`, + ); + console.log( + "Use the native Browser Certificates workflow to import only these generated inputs. CA trust affects your OS user. Import both PFX identities using the private passphrase file, then quit the preview before starting the harness.", + ); + console.log( + "This tier proves live Chromium TLS and private-key use. Signed installed key custody/removal and full network containment remain separate acceptance requirements.", + ); + await acknowledge( + "Type RUN after provisioned fixture import and quitting the preview: ", + "RUN", + ); + native = spawn( + process.execPath, + [join(desktop, "scripts/test-browser-native.mjs"), "--embedded-chromium"], + { + cwd: repository, + stdio: "inherit", + env: { + ...process.env, + COLOSSUS_BROWSER_PKI_FIXTURE: join(directory, "fixture.json"), + }, + windowsHide: true, + // A terminal interrupt must not kill the owning native acceptance runner + // before its bounded lifecycle can close and reap the actual CEF app. + detached: process.platform !== "win32", + }, + ); + const code = await new Promise((resolveExit, reject) => { + native.once("error", reject); + native.once("exit", (status) => resolveExit(status ?? 1)); + }); + fixture.stdin.write("report\n"); + const message = await nextMessage(); + serverReceipts = message.report; + if (interruption.signal.aborted) + throw new Error( + "Native PKI conformance interrupted; retained fixture material requires operator cleanup", + ); + if (code !== 0) + throw new Error( + "Native Chromium PKI harness failed; retained fixture files identify only test-owned items", + ); + tlsPassed = verifyPkiReceipts(metadata, message.report); + console.log( + "PASS actual Chromium TLS receipts: trusted CA, denied server certificates, exact accepted/rejected client identities, and unreviewed identity redirect.", + ); + console.log( + "Remove only the three generated certificate fingerprints and their two generated private keys through the native OS certificate manager. Keep the private fixture material until cleanup is confirmed. Do not delete any existing user identity.", + ); + await acknowledge( + "Type REMOVED after removing those exact generated OS-store items: ", + "REMOVED", + ); + operatorCleanupReported = true; +} finally { + consoleInput.close(); + if (native && native.exitCode === null) native.kill(); + fixture.stdin.end("stop\n"); + const timer = setTimeout(() => fixture.kill(), 5000); + const code = await fixtureExited; + clearTimeout(timer); + if (metadata) { + await writeFile( + join(directory, "native-pki-conformance.json"), + JSON.stringify( + { + schema_version: 1, + fixture_id: metadata.fixture_id, + platform: process.platform, + architecture: process.arch, + tls_conformance_passed: tlsPassed && code === 0, + operator_cleanup_reported: operatorCleanupReported, + os_store_cleanup_verified: false, + signed_installed_artifact_verified: false, + production_acceptance: false, + fingerprints_sha256: metadata.fingerprints_sha256, + server_receipts: serverReceipts ?? null, + checked_at: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, + ); + console.log(`Retained private PKI material and evidence: ${directory}`); + } + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", interrupt); +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d9249ff47..fd0e2b8dd 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -959,6 +959,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "colossus-contracts", + "colossus-media", "colossus-ports", "futures", "hex", @@ -968,6 +969,7 @@ dependencies = [ "thiserror 2.0.19", "time", "url", + "zeroize", ] [[package]] @@ -982,6 +984,21 @@ dependencies = [ "tonic-prost-build", ] +[[package]] +name = "colossus-browser-presentation" +version = "0.11.8" +dependencies = [ + "async-trait", + "colossus-contracts", + "getrandom 0.4.3", + "hmac", + "serde", + "serde_json", + "sha2", + "tokio", + "zeroize", +] + [[package]] name = "colossus-cloud-protocol" version = "0.11.8" @@ -1053,6 +1070,7 @@ dependencies = [ "colossus-domain", "serde", "serde_json", + "url", "zeroize", ] @@ -1084,7 +1102,10 @@ dependencies = [ name = "colossus-darwin-process" version = "0.11.8" dependencies = [ + "core-foundation", "libc", + "security-framework", + "sha2", ] [[package]] @@ -1254,17 +1275,36 @@ dependencies = [ name = "colossus-native-browser" version = "0.1.0" dependencies = [ + "async-trait", "block2", + "colossus-browser-presentation", + "colossus-native-browser-pki", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-web-kit", + "rcgen", + "rustls-pki-types", "serde", + "serde_json", + "sha2", "tauri", "tokio", "url", "webview2-com", "windows 0.61.3", + "windows-sys 0.61.2", + "x509-parser", + "zeroize", +] + +[[package]] +name = "colossus-native-browser-pki" +version = "0.1.0" +dependencies = [ + "rustls-pki-types", + "sha2", + "x509-parser", ] [[package]] @@ -1372,6 +1412,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.19", + "zeroize", ] [[package]] @@ -1382,6 +1423,7 @@ dependencies = [ "base64 0.22.1", "colossus-api", "colossus-api-proto", + "colossus-browser-presentation", "colossus-contracts", "colossus-darwin-process", "colossus-grpc", @@ -5060,9 +5102,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e4766635f..3ad4d5e1b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,7 +16,14 @@ default = ["dictation"] plugin-test-bridge = [] approval-test-bridge = [] browser-preview = [] +embedded-chromium-preview = ["browser-preview", "colossus-native-browser/cef-preview"] browser-test-bridge = ["browser-preview", "colossus-native-browser/native-test-driver"] +# Separate-host Debug lane: link only the native pixel adapter; never bootstrap +# an in-process CEF guest or fall back to a system webview for this lane. +# This standalone example has no Vite server. Embed the built renderer at compile +# time; clearing devUrl at runtime cannot restore assets omitted by Tauri codegen. +macos-contained-browser-diagnostic = ["browser-test-bridge", "colossus-native-browser/cef-preview", "tauri/custom-protocol"] +windows-browser-development = ["browser-test-bridge", "colossus-native-browser/cef-preview", "tauri/custom-protocol"] dictation = ["dep:colossus-native-dictation", "colossus-native-dictation/download"] dictation-preview = ["dictation"] dictation-metal = ["dictation", "colossus-native-dictation/metal"] @@ -49,6 +56,7 @@ sha2 = "0.10.9" tauri-build = { version = "=2.6.3", features = [] } [dependencies] +async-trait = "0.1.89" colossus-connector = { version = "=0.11.8", path = "../../../crates/colossus-connector" } anyhow = "1.0.104" base64 = "0.22.1" @@ -107,7 +115,6 @@ tauri = { version = "=2.11.5", features = ["tray-icon", "image-png"] } tauri-plugin-notification = "=2.4.0" [dev-dependencies] -async-trait = "0.1.89" keyring = "4.1.4" rcgen = { version = "0.14.8", default-features = false, features = ["crypto", "pem", "ring", "zeroize"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"] } @@ -124,3 +131,7 @@ unsafe_code = "forbid" [lints.clippy] all = "warn" pedantic = "warn" + +# Match Core's authenticated Retina-frame hashing within the existing lease. +[profile.dev.package.sha2] +opt-level = 3 diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 0f875aa52..2c23498f7 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -23,6 +23,7 @@ const COMMANDS: &[&str] = &[ "browser_context", "browser_command", "browser_viewport", + "browser_certificates", "command_review_context", "finish_command_review", "remembered_command_count", @@ -174,7 +175,20 @@ fn main() { // including library tests and examples that use TaskDialogIndirect. Disable // Tauri's binary-only copy below to avoid two MANIFEST resources in the app. if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + if env::var_os("CARGO_FEATURE_EMBEDDED_CHROMIUM_PREVIEW").is_some() + && env::var_os("COLOSSUS_CEF_NATIVE_LIB_DIR").is_some() + { + // The bootstrap establishes sandbox services before libcef loads. + // Eager imports would load it before the client DLL's RunWinMain. + println!("cargo:rustc-link-arg=/DELAYLOAD:libcef.dll"); + } println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + // Windows child compositor surfaces require an OS compatibility manifest. + // Explicit asInvoker preserves ordinary-user installation and app use. + let manifest = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("windows.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); println!( "cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'" ); diff --git a/apps/desktop/src-tauri/capabilities/main-chat.json b/apps/desktop/src-tauri/capabilities/main-chat.json index fa0bc0072..b6fe8a10f 100644 --- a/apps/desktop/src-tauri/capabilities/main-chat.json +++ b/apps/desktop/src-tauri/capabilities/main-chat.json @@ -37,6 +37,7 @@ "allow-browser-context", "allow-browser-command", "allow-browser-viewport", + "allow-browser-certificates", "allow-get-plugin-inventory", "allow-resolve-plugin-selection", "allow-read-plugin-preview", diff --git a/apps/desktop/src-tauri/examples/browser-acceptance.rs b/apps/desktop/src-tauri/examples/browser-acceptance.rs index 6d61387d6..2f86fcfda 100644 --- a/apps/desktop/src-tauri/examples/browser-acceptance.rs +++ b/apps/desktop/src-tauri/examples/browser-acceptance.rs @@ -1,4 +1,4 @@ -//! Native `WebView` acceptance entry point, excluded from shipped builds. +//! Native browser acceptance entry point, excluded from shipped builds. fn main() { std::process::exit(colossus_desktop_lib::run_browser_acceptance()); } diff --git a/apps/desktop/src-tauri/src/application_lifecycle.rs b/apps/desktop/src-tauri/src/application_lifecycle.rs new file mode 100644 index 000000000..9dde15ef4 --- /dev/null +++ b/apps/desktop/src-tauri/src/application_lifecycle.rs @@ -0,0 +1,81 @@ +//! Controlled application shutdown while native close callbacks keep pumping. + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, +}; + +use tauri::Manager as _; + +pub(crate) fn callback() -> ( + impl FnMut(&tauri::AppHandle, tauri::RunEvent), + Arc, +) { + let shutdown = Arc::new(AtomicU8::new(0)); + let acknowledged = Arc::new(AtomicBool::new(false)); + let shutdown_result = acknowledged.clone(); + let callback = move |app: &tauri::AppHandle, event| { + #[cfg(target_os = "macos")] + if matches!( + event, + tauri::RunEvent::Reopen { + has_visible_windows: false, + .. + } + ) { + crate::status_bar::show_main_window(app); + } + if let tauri::RunEvent::ExitRequested { api, .. } = event { + #[cfg(feature = "embedded-chromium-preview")] + eprintln!( + "Desktop ExitRequested received (shutdown phase {})", + shutdown.load(Ordering::Acquire) + ); + if shutdown.load(Ordering::Acquire) < 2 { + api.prevent_exit(); + if shutdown + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + app.state::().cancel(); + let app = app.clone(); + let shutdown = shutdown.clone(); + tauri::async_runtime::spawn(async move { + // Keep Tauri's UI thread alive for actual native close + // acknowledgements; blocking it would deadlock CEF. + match app + .state::() + .close_all_settled() + .await + { + Ok(()) => { + #[cfg(feature = "embedded-chromium-preview")] + eprintln!("Desktop native browser close callbacks settled"); + shutdown.store(2, Ordering::Release); + app.exit(0); + } + Err(error) => { + eprintln!("Desktop shutdown did not settle: {}", error.message); + // The app and event pump remain live. A later + // explicit Quit can reconcile delayed closes. + shutdown.store(0, Ordering::Release); + } + } + }); + } + } + } else if matches!(event, tauri::RunEvent::Exit) { + #[cfg(feature = "embedded-chromium-preview")] + eprintln!("Desktop Exit received; settling native browser lifecycle"); + match crate::browser::bootstrap::shutdown() { + Ok(()) => { + shutdown_result.store(true, Ordering::Release); + #[cfg(feature = "embedded-chromium-preview")] + println!("Desktop native browser lifecycle settled"); + } + Err(error) => eprintln!("native browser shutdown failed: {error}"), + } + } + }; + (callback, acknowledged) +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance.rs b/apps/desktop/src-tauri/src/browser/acceptance.rs index 28db7c584..7195f6491 100644 --- a/apps/desktop/src-tauri/src/browser/acceptance.rs +++ b/apps/desktop/src-tauri/src/browser/acceptance.rs @@ -2,21 +2,52 @@ use super::dto::BrowserAction; use crate::state::AppState; -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, }; -use tauri::{Manager as _, Webview}; +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] +use std::time::Duration; +use tauri::Manager as _; +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] +use tauri::Webview; + +#[cfg(feature = "embedded-chromium-preview")] +mod chromium; +#[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" +))] +mod contained_diagnostic; +#[cfg(feature = "embedded-chromium-preview")] +mod pki; +// Keep platform-exclusive native lifecycle branches in one source-only harness. +#[allow(clippy::too_many_lines)] pub(crate) fn run() -> i32 { + println!("NATIVE_BROWSER_HOST_PID {}", std::process::id()); println!("Starting native browser acceptance"); let address = std::env::args().nth(1).expect("native browser fixture URL"); let url = tauri::Url::parse(&address).expect("fixture URL"); assert_eq!(url.scheme(), "http"); assert_eq!(url.host_str(), Some("127.0.0.1")); + let mut context = crate::app_context::create(); + #[cfg(feature = "macos-contained-browser-diagnostic")] + if !embedded_controller_available(&context) { + return 1; + } let home = std::path::PathBuf::from(std::env::var_os("COLOSSUS_HOME").expect("isolated test home")); assert!(!home.exists(), "test home must be new"); @@ -30,7 +61,12 @@ pub(crate) fn run() -> i32 { .create(&home) .expect("private acceptance home"); } - let mut context = crate::app_context::create(); + let browser_cache = super::bootstrap::initialize(); + #[cfg(feature = "embedded-chromium-preview")] + if let Err(error) = colossus_native_browser::chromium::readiness() { + eprintln!("native Chromium acceptance bootstrap failed: {error}"); + return 1; + } context.config_mut().build.dev_url = None; context.config_mut().app.windows[0].data_directory = Some(home.join("controller")); context.config_mut().app.windows[0].incognito = true; @@ -47,14 +83,56 @@ pub(crate) fn run() -> i32 { ]) .setup(move |app| { println!("Native app initialized"); + super::bootstrap::start_pump(app.handle())?; + super::start_watchdog(app.handle().clone()); + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + super::contained::install(app.handle())?; let app = app.handle().clone(); tauri::async_runtime::spawn(async move { - let result = exercise(&app, &address).await; + let result = async { + controller_authorization(&app).await?; + #[cfg(feature = "embedded-chromium-preview")] + { + chromium::exercise(&app, &address).await + } + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + { + contained_diagnostic::exercise(&app, &address, &home).await + } + #[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) + )))] + { + exercise(&app, &address).await + } + } + .await; match result { Ok(()) => { - println!("native browser acceptance passed"); completion.store(true, Ordering::SeqCst); - app.exit(0); + #[cfg(feature = "embedded-chromium-preview")] + if let Err(error) = chromium::terminate(&app).await { + completion.store(false, Ordering::SeqCst); + eprintln!("native application quit regression failed: {error:#}"); + app.exit(1); + } else { + println!("native browser acceptance passed"); + } + #[cfg(not(feature = "embedded-chromium-preview"))] + { + println!("native browser acceptance passed"); + app.exit(0); + } } Err(error) => { eprintln!("native browser acceptance failed: {error:#}"); @@ -66,10 +144,141 @@ pub(crate) fn run() -> i32 { }) .build(context) .expect("native browser acceptance app"); - application.run_return(|_, _| {}); + let result = run_lifecycle(application, &completed); + settle_cache(result, browser_cache) +} + +#[cfg(feature = "macos-contained-browser-diagnostic")] +fn embedded_controller_available(context: &tauri::Context) -> bool { + let available = context.assets().get(&"index.html".into()).is_some(); + if !available { + eprintln!("native browser acceptance requires the embedded Desktop renderer"); + } + available +} + +fn settle_cache(result: i32, cache: Option) -> i32 { + #[cfg(feature = "embedded-chromium-preview")] + let cache_path = cache.as_ref().map(|cache| cache.path().to_owned()); + if result != 0 { + if let Some(cache) = cache { + // Keep native files alive if shutdown was not acknowledged. The + // driver can remove its exact private home after process teardown. + let _preserved = cache.keep(); + eprintln!("CEF private cache retained after unsuccessful native acceptance"); + } + return result; + } + drop(cache); + #[cfg(feature = "embedded-chromium-preview")] + if cache_path.is_some_and(|path| matches!(path.try_exists(), Ok(false))) { + println!("PASS CEF private application cache removed after shutdown"); + } else { + eprintln!("CEF private application cache survived shutdown"); + return 1; + } + result +} + +async fn controller_authorization(app: &tauri::AppHandle) -> anyhow::Result<()> { + let controller = app + .get_webview("main") + .ok_or_else(|| anyhow::anyhow!("Desktop controller missing"))?; + // The engine harness must exercise the same native document check as real + // browser IPC. Manager calls alone cannot prove that the UI can control it. + for _ in 0..100 { + let url = controller.url()?; + if url.as_str() != "about:blank" { + super::commands::require_controller(&controller).map_err(|error| { + anyhow::anyhow!( + "actual Desktop controller {url} was denied: {}", + error.message + ) + })?; + println!("PASS actual Desktop controller document authorizes browser controls"); + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + anyhow::bail!("actual Desktop controller navigation did not resolve") +} + +fn run_lifecycle(application: tauri::App, completed: &AtomicBool) -> i32 { + let shutdown = Arc::new(AtomicU8::new(0)); + let closed = Arc::new(AtomicBool::new(false)); + let close_result = closed.clone(); + let event_loop_exit = Arc::new(AtomicBool::new(false)); + let shutdown_result = event_loop_exit.clone(); + application.run_return(move |app, event| { + if let tauri::RunEvent::ExitRequested { api, .. } = event { + if shutdown.load(Ordering::Acquire) < 2 { + api.prevent_exit(); + if shutdown + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let app = app.clone(); + let shutdown = shutdown.clone(); + let closed = close_result.clone(); + tauri::async_runtime::spawn(async move { + #[cfg(feature = "embedded-chromium-preview")] + let live = app + .state::() + .browser + .lock() + .map_or(0, |registry| registry.tabs.len()); + // Exercise Desktop's browser admission/teardown path while + // keeping Tauri/AppKit pumping for native acknowledgements. + // This harness does not exercise the full production callback's + // retry behavior or runtime/client teardown. + match app.state::().browser.drain_for_shutdown().await { + Ok(_) => { + closed.store(true, Ordering::Release); + #[cfg(feature = "embedded-chromium-preview")] + if live > 0 { + println!("PASS native application quit acknowledged live CEF tab close"); + } + } + Err(error) => { + eprintln!("native browser teardown failed: {}", error.message); + } + } + shutdown.store(2, Ordering::Release); + app.exit(0); + }); + } + } + } else if matches!(event, tauri::RunEvent::Exit) { + #[cfg(any(feature = "macos-contained-browser-diagnostic", feature = "windows-browser-development"))] + { + // The contained host was already closed by the diagnostic. + // No in-process CEF engine was initialized in Desktop. + shutdown_result.store(true, Ordering::Release); + println!("PASS Desktop {} event loop exited after supervised host shutdown", + if cfg!(windows) { "Windows" } else { "AppKit" }); + } + #[cfg(not(any(feature = "macos-contained-browser-diagnostic", feature = "windows-browser-development")))] + match super::bootstrap::shutdown() { + Ok(()) => { + shutdown_result.store(true, Ordering::Release); + #[cfg(feature = "embedded-chromium-preview")] + println!( + "PASS native CefShutdown acknowledged after all browser close callbacks" + ); + } + Err(error) => eprintln!("native browser engine shutdown failed: {error}"), + } + } + }); // Native window teardown can return zero even after app.exit(1). Only a // completed exercise authorizes success, including when the window closes early. - i32::from(!completed.load(Ordering::SeqCst)) + let passed = completed.load(Ordering::SeqCst) + && closed.load(Ordering::Acquire) + && event_loop_exit.load(Ordering::Acquire); + if passed { + println!("PASS native clean shutdown after close acknowledgements"); + } + i32::from(!passed) } async fn action( @@ -77,6 +286,7 @@ async fn action( action: BrowserAction, ) -> anyhow::Result { let state = app.state::(); + let _operation = state.browser.operation.lock().await; let generation = state .browser .snapshot() @@ -95,13 +305,27 @@ async fn action( .map_err(|e| anyhow::anyhow!(e.message)) } -#[cfg(target_os = "macos")] +#[cfg(all( + target_os = "macos", + not(feature = "embedded-chromium-preview"), + not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )) +))] async fn evaluate(view: &Webview, script: &'static str) -> anyhow::Result { let value = colossus_native_browser::acceptance::evaluate(view, script).await?; Ok(serde_json::from_str(&value)?) } -#[cfg(not(target_os = "macos"))] +#[cfg(all( + not(target_os = "macos"), + not(feature = "embedded-chromium-preview"), + not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )) +))] async fn evaluate(view: &Webview, script: &'static str) -> anyhow::Result { let (send, receive) = tokio::sync::oneshot::channel(); let send = std::sync::Mutex::new(Some(send)); @@ -114,6 +338,13 @@ async fn evaluate(view: &Webview, script: &'static str) -> anyhow::Result anyhow::Result<()> { for _ in 0..100 { if colossus_native_browser::inspect(view).await?.title == title { @@ -124,6 +355,13 @@ async fn wait_title(view: &Webview, title: &str) -> anyhow::Result<()> { anyhow::bail!("expected native title {title:?}") } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn exercise(app: &tauri::AppHandle, address: &str) -> anyhow::Result<()> { println!("Creating first native browser tab"); let state = app.state::(); @@ -131,6 +369,7 @@ async fn exercise(app: &tauri::AppHandle, address: &str) -> anyhow::Result<()> { let first = action( app, BrowserAction::New { + conversation_id: None, url: format!("{address}/first"), }, ) @@ -161,6 +400,13 @@ async fn exercise(app: &tauri::AppHandle, address: &str) -> anyhow::Result<()> { Ok(()) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn history( app: &tauri::AppHandle, address: &str, @@ -215,11 +461,19 @@ async fn history( Ok(()) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn sessions(app: &tauri::AppHandle, address: &str, generation: u64) -> anyhow::Result<()> { let state = app.state::(); let second = action( app, BrowserAction::New { + conversation_id: None, url: format!("{address}/first"), }, ) @@ -245,6 +499,7 @@ async fn sessions(app: &tauri::AppHandle, address: &str, generation: u64) -> any let second_scope = action( app, BrowserAction::New { + conversation_id: None, url: format!("{address}/first"), }, ) @@ -258,7 +513,10 @@ async fn sessions(app: &tauri::AppHandle, address: &str, generation: u64) -> any ); println!("PASS workspace isolation and temporary cookie sharing"); anyhow::ensure!( - state.browser.tab(sibling.label(), "acceptance-b").is_err(), + state + .browser + .legacy_tab(sibling.label(), "acceptance-b") + .is_err(), "foreign tab was accessible" ); evaluate( @@ -284,6 +542,13 @@ async fn sessions(app: &tauri::AppHandle, address: &str, generation: u64) -> any Ok(()) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] fn acceptance_bounds(view: &Webview) -> anyhow::Result { let window = view.window(); let size = window @@ -301,6 +566,13 @@ fn acceptance_bounds(view: &Webview) -> anyhow::Result }) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn permissions(view: &Webview) -> anyhow::Result<()> { let rect = acceptance_bounds(view)?; view.set_bounds(tauri::Rect { @@ -335,6 +607,13 @@ async fn permissions(view: &Webview) -> anyhow::Result<()> { anyhow::bail!("native permission callback did not resolve") } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn viewport(app: &tauri::AppHandle, view: &Webview) -> anyhow::Result<()> { use super::dto::BrowserViewportRequest; let state = app.state::(); @@ -386,6 +665,13 @@ async fn viewport(app: &tauri::AppHandle, view: &Webview) -> anyhow::Result<()> Ok(()) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn guest_denial( app: &tauri::AppHandle, address: &str, @@ -424,6 +710,13 @@ async fn guest_denial( Ok(()) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn handoffs( app: &tauri::AppHandle, b: &str, @@ -473,12 +766,20 @@ async fn handoffs( Ok(()) } +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] async fn cleanup(app: &tauri::AppHandle, address: &str) -> anyhow::Result<()> { let state = app.state::(); action(app, BrowserAction::Clear).await?; let reset = action( app, BrowserAction::New { + conversation_id: None, url: format!("{address}/first"), }, ) diff --git a/apps/desktop/src-tauri/src/browser/acceptance/chromium.rs b/apps/desktop/src-tauri/src/browser/acceptance/chromium.rs new file mode 100644 index 000000000..ee74e6e21 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/chromium.rs @@ -0,0 +1,614 @@ +//! Real CEF child views inside the Desktop Tauri native application. + +use std::time::Duration; + +use colossus_native_browser::{BrowserView, chromium::Surface}; +use tauri::Manager as _; + +use crate::{ + browser::dto::{BrowserAction, BrowserRect, BrowserViewportRequest}, + state::AppState, +}; + +use super::action; + +pub(super) async fn exercise(app: &tauri::AppHandle, address: &str) -> anyhow::Result<()> { + let state = app.state::(); + state + .select_target(Some("chromium-acceptance-a".into())) + .await; + let first = action( + app, + BrowserAction::New { + conversation_id: None, + url: format!("{address}/first"), + }, + ) + .await?; + anyhow::ensure!( + first.engine.kind == "embedded_chromium" && first.engine.ready, + "CEF preview is unavailable" + ); + anyhow::ensure!( + !first.engine.agent_control_available, + "production browser automation became available" + ); + let a = first + .selected_tab_id + .ok_or_else(|| anyhow::anyhow!("first tab missing"))?; + let first_view = surface(app, &a, "chromium-acceptance-a")?; + wait_title(&first_view, "First page").await?; + + let controller = app + .get_webview("main") + .ok_or_else(|| anyhow::anyhow!("Desktop controller missing"))?; + match first_view.acceptance_probe().await { + Ok(probe) => println!("EVIDENCE startup CEF probe {probe:?}"), + Err(error) => eprintln!("EVIDENCE startup CEF probe unavailable: {error}"), + } + println!("CHECKPOINT waiting-for-native-foreground"); + focus_controller(&controller, &first_view).await?; + let rect = bounds(app)?; + viewport(app, Some(&a), Some(rect)).await?; + checkpoint(app, "presented-native-child", Some(&a), Some(rect)).await?; + pixels(app, &first_view, true, Some(rect)).await?; + #[cfg(target_os = "macos")] + println!("PASS CEF page rendering inside Desktop Tauri/AppKit with sandbox preserved"); + #[cfg(windows)] + { + println!("PASS CEF page rendering inside Desktop Tauri/Win32 with sandbox preserved"); + // Hold this exact rendered guest while the operator-owned runner proves + // its actual renderer token denies opening a private fixture file. The + // isolated acceptance home is created by native owner-only DACL code. + let proof = std::path::PathBuf::from( + std::env::var_os("COLOSSUS_HOME") + .ok_or_else(|| anyhow::anyhow!("acceptance home missing"))?, + ) + .join("windows-sandbox-evidence.ready"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + while !proof.try_exists()? { + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "Windows sandbox evidence runner did not acknowledge actual renderer resource denial" + ); + viewport(app, Some(&a), Some(rect)).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + } + first_view.acceptance_input().await?; + wait_title(&first_view, "Native input accepted").await?; + println!("PASS Windows native OS mouse and keyboard reach Chromium guest"); + action(app, BrowserAction::Reload { tab_id: a.clone() }).await?; + wait_title(&first_view, "First page").await?; + } + checkpoint(app, "rendered-first-page", Some(&a), Some(rect)).await?; + + history(app, address, &a, &first_view).await?; + tabs(app, address, &a, &first_view).await?; + resizing(app, &a, &first_view).await?; + overlays(app, &a, &first_view).await?; + workspace_boundary(app, address, &a, first.generation).await?; + + state + .browser + .close_all_settled() + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + anyhow::ensure!( + state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .tabs + .is_empty(), + "native tabs survived close acknowledgements" + ); + anyhow::ensure!( + app.webviews().len() == 1, + "CEF created an unmanaged Tauri WebView" + ); + println!("PASS CEF tab close acknowledgements and temporary profile teardown"); + super::pki::exercise_if_configured(app).await?; + retain_live_for_quit(app, address).await +} + +async fn retain_live_for_quit(app: &tauri::AppHandle, address: &str) -> anyhow::Result<()> { + let state = app.state::(); + let final_snapshot = action( + app, + BrowserAction::New { + conversation_id: None, + url: format!("{address}/first"), + }, + ) + .await?; + let final_id = final_snapshot + .selected_tab_id + .ok_or_else(|| anyhow::anyhow!("application-quit regression tab missing"))?; + let final_view = surface(app, &final_id, "chromium-acceptance-b")?; + wait_title(&final_view, "First page").await?; + let rect = bounds(app)?; + viewport(app, Some(&final_id), Some(rect)).await?; + pixels(app, &final_view, true, Some(rect)).await?; + anyhow::ensure!( + state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .tabs + .len() + == 1, + "application quit must begin with one live native browser tab" + ); + println!("PASS live CEF tab retained for native application quit"); + Ok(()) +} + +pub(super) async fn terminate(app: &tauri::AppHandle) -> anyhow::Result<()> { + let state = app.state::(); + let scope = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .scope + .clone() + .ok_or_else(|| anyhow::anyhow!("application quit scope missing"))?; + let view = surface(app, &selected(app)?, &scope)?; + view.acceptance_terminate_application().await?; + println!("PASS native application terminate requested with a live CEF tab"); + Ok(()) +} + +async fn focus_controller(controller: &tauri::Webview, view: &Surface) -> anyhow::Result<()> { + let window = controller.window(); + window.show()?; + let wait_ms = std::env::var("COLOSSUS_BROWSER_ACCEPTANCE_FOREGROUND_WAIT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(15_000) + .min(60_000); + let deadline = tokio::time::Instant::now() + Duration::from_millis(wait_ms); + let mut attempt = 0; + while tokio::time::Instant::now() < deadline { + if colossus_native_browser::is_active(controller).await? { + break; + } + if attempt % 20 == 0 { + window.set_focus()?; + view.acceptance_activate_parent().await?; + } + tokio::time::sleep(Duration::from_millis(50)).await; + attempt += 1; + } + if !colossus_native_browser::is_active(controller).await? { + if let Ok(probe) = view.acceptance_probe().await { + println!("EVIDENCE foreground timeout after {wait_ms}ms native={probe:?}"); + } + anyhow::bail!("native Chromium acceptance requires a foreground Desktop window"); + } + Ok(()) +} + +fn surface(app: &tauri::AppHandle, id: &str, scope: &str) -> anyhow::Result { + let (view, _) = app + .state::() + .browser + .tab(id, scope) + .map_err(|error| anyhow::anyhow!(error.message))?; + match view { + BrowserView::Chromium(surface) => Ok(surface), + _ => anyhow::bail!("CEF child-view acceptance requires the native fixture surface"), + } +} + +async fn wait_title(view: &Surface, title: &str) -> anyhow::Result<()> { + for _ in 0..100 { + let page = view.inspect().await?; + if page.title == title && !page.loading { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + anyhow::bail!("expected loaded CEF title {title:?}") +} + +fn bounds(app: &tauri::AppHandle) -> anyhow::Result { + let window = app + .get_window("main") + .ok_or_else(|| anyhow::anyhow!("Desktop window missing"))?; + let size = window + .inner_size()? + .to_logical::(window.scale_factor()?); + anyhow::ensure!( + size.width >= 300.0 && size.height >= 300.0, + "Desktop acceptance window too small" + ); + Ok(BrowserRect { + x: 24.0, + y: 96.0, + width: size.width - 48.0, + height: size.height - 120.0, + }) +} + +async fn viewport( + app: &tauri::AppHandle, + id: Option<&str>, + rect: Option, +) -> anyhow::Result<()> { + let state = app.state::(); + let generation = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .generation; + state + .browser + .viewport( + app, + &BrowserViewportRequest { + generation, + tab_id: id.map(str::to_owned), + rect, + }, + ) + .await + .map_err(|error| anyhow::anyhow!(error.message)) +} + +async fn pixels( + app: &tauri::AppHandle, + view: &Surface, + visible: bool, + rect: Option, +) -> anyhow::Result<()> { + let started = tokio::time::Instant::now(); + let deadline = started + Duration::from_secs(5); + let mut first_probe = true; + loop { + let probe = probe_with_heartbeat(app, view, visible, rect).await?; + if first_probe { + println!( + "EVIDENCE first CEF probe expected_visible={visible} expected_bounds={rect:?} elapsed_ms={} actual={probe:?}", + started.elapsed().as_millis() + ); + first_probe = false; + } + #[cfg(target_os = "macos")] + anyhow::ensure!( + probe.cef_application && probe.tauri_event_loop && probe.parent_attached, + "CEF and Tauri do not share the supported AppKit application/parent: {probe:?}" + ); + #[cfg(windows)] + anyhow::ensure!( + probe.windows_owning_thread + && (!visible || probe.os_compositor_capture) + && probe.parent_attached, + "CEF HWND is not attached on the owning thread with Windows compositor evidence: {probe:?}" + ); + let correct_bounds = rect.is_none_or(|rect| { + (probe.x - rect.x).abs() <= 1.0 + && (probe.y - rect.y).abs() <= 1.0 + && (probe.width - rect.width).abs() <= 1.0 + && (probe.height - rect.height).abs() <= 1.0 + }); + if probe.visible == visible + && correct_bounds + && (!visible || (probe.magenta_pixels > 32 && probe.green_pixels > 32)) + { + println!( + "EVIDENCE CEF visible={} bounds={},{},{},{} magenta_pixels={} green_pixels={} cef_application={} tauri_event_loop={} parent_attached={}", + probe.visible, + probe.x, + probe.y, + probe.width, + probe.height, + probe.magenta_pixels, + probe.green_pixels, + probe.cef_application, + probe.tauri_event_loop, + probe.parent_attached + ); + return Ok(()); + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "CEF rendered pixels, visibility, or native bounds did not match: expected_visible={visible}, expected_bounds={rect:?}, elapsed_ms={}, actual={probe:?}", + started.elapsed().as_millis() + ); + if let Some(rect) = rect { + viewport(app, Some(selected(app)?.as_str()), Some(rect)).await?; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn probe_with_heartbeat( + app: &tauri::AppHandle, + view: &Surface, + visible: bool, + rect: Option, +) -> anyhow::Result { + if visible && let Some(rect) = rect { + let controller = app + .get_webview("main") + .ok_or_else(|| anyhow::anyhow!("Desktop controller missing"))?; + if !colossus_native_browser::is_active(&controller).await? { + focus_controller(&controller, view).await?; + } + viewport(app, Some(selected(app)?.as_str()), Some(rect)).await?; + } + let probe = view.acceptance_probe(); + tokio::pin!(probe); + loop { + tokio::select! { + result = &mut probe => return Ok(result?), + () = tokio::time::sleep(Duration::from_millis(250)), if visible => { + // The real renderer keeps renewing its viewport lease while a + // page paints. A native screenshot must not suspend that owner. + if let Some(rect) = rect { + let controller = app.get_webview("main").ok_or_else(|| anyhow::anyhow!("Desktop controller missing"))?; + if !colossus_native_browser::is_active(&controller).await? { + focus_controller(&controller, view).await?; + } + viewport(app, Some(selected(app)?.as_str()), Some(rect)).await?; + } + } + } + } +} + +fn selected(app: &tauri::AppHandle) -> anyhow::Result { + app.state::() + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .snapshot() + .selected_tab_id + .ok_or_else(|| anyhow::anyhow!("selected CEF tab missing")) +} + +async fn history( + app: &tauri::AppHandle, + address: &str, + id: &str, + view: &Surface, +) -> anyhow::Result<()> { + action( + app, + BrowserAction::Navigate { + tab_id: id.to_owned(), + url: format!("{address}/second"), + }, + ) + .await?; + wait_title(view, "Second page").await?; + anyhow::ensure!( + view.inspect().await?.can_go_back, + "CEF history did not retain the first page" + ); + action( + app, + BrowserAction::Back { + tab_id: id.to_owned(), + }, + ) + .await?; + wait_title(view, "First page").await?; + anyhow::ensure!( + view.inspect().await?.can_go_forward, + "CEF forward history missing" + ); + action( + app, + BrowserAction::Forward { + tab_id: id.to_owned(), + }, + ) + .await?; + wait_title(view, "Second page").await?; + action( + app, + BrowserAction::Reload { + tab_id: id.to_owned(), + }, + ) + .await?; + wait_title(view, "Second page").await?; + viewport(app, Some(id), Some(bounds(app)?)).await?; + pixels(app, view, true, Some(bounds(app)?)).await?; + println!("PASS CEF navigation, back, forward, and reload"); + Ok(()) +} + +async fn tabs( + app: &tauri::AppHandle, + address: &str, + a: &str, + first: &Surface, +) -> anyhow::Result<()> { + let second = action( + app, + BrowserAction::New { + conversation_id: None, + url: format!("{address}/first"), + }, + ) + .await?; + let b = second + .selected_tab_id + .ok_or_else(|| anyhow::anyhow!("second tab missing"))?; + let sibling = surface(app, &b, "chromium-acceptance-a")?; + wait_title(&sibling, "First page").await?; + anyhow::ensure!( + second.tabs.len() == 2 && second.tabs[0].session_id == second.tabs[1].session_id, + "CEF tabs lack shared workspace session ownership" + ); + let rect = bounds(app)?; + viewport(app, Some(&b), Some(rect)).await?; + pixels(app, first, false, None).await?; + pixels(app, &sibling, true, Some(rect)).await?; + action( + app, + BrowserAction::Select { + tab_id: a.to_owned(), + }, + ) + .await?; + viewport(app, Some(a), Some(rect)).await?; + pixels(app, &sibling, false, None).await?; + pixels(app, first, true, Some(rect)).await?; + action(app, BrowserAction::Close { tab_id: b }).await?; + anyhow::ensure!( + sibling.inspect().await.is_err(), + "closed CEF tab retained engine authority" + ); + println!("PASS CEF tab creation, selection, hidden sibling, and close"); + Ok(()) +} + +async fn resizing(app: &tauri::AppHandle, a: &str, view: &Surface) -> anyhow::Result<()> { + let window = app + .get_window("main") + .ok_or_else(|| anyhow::anyhow!("Desktop window missing"))?; + window.set_size(tauri::LogicalSize::new(1050.0, 760.0))?; + tokio::time::sleep(Duration::from_millis(150)).await; + let rect = bounds(app)?; + viewport(app, Some(a), Some(rect)).await?; + pixels(app, view, true, Some(rect)).await?; + println!("PASS CEF native bounds and rendered content after Desktop resize"); + checkpoint(app, "resized-page", Some(a), Some(rect)).await?; + Ok(()) +} + +async fn overlays(app: &tauri::AppHandle, a: &str, view: &Surface) -> anyhow::Result<()> { + let controller = app + .get_webview("main") + .ok_or_else(|| anyhow::anyhow!("Desktop controller missing"))?; + let rect = bounds(app)?; + focus_controller(&controller, view).await?; + viewport(app, Some(a), Some(rect)).await?; + pixels(app, view, true, Some(rect)).await?; + require_active(&controller, "overlay visibility baseline").await?; + controller.eval("const overlay = document.createElement('div'); overlay.id = 'chromium-acceptance-overlay'; overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:grid;place-items:center;background:var(--surface-primary,#fff);color:var(--text-primary,#111);font:24px sans-serif'; overlay.textContent = 'Colossus Desktop overlay: Chromium must be occluded'; document.body.appendChild(overlay);")?; + viewport(app, None, None).await?; + require_active(&controller, "overlay visibility revocation").await?; + pixels(app, view, false, None).await?; + require_active(&controller, "overlay hidden capture").await?; + println!("PASS Desktop overlay revokes native Chromium visibility"); + checkpoint(app, "desktop-overlay-occlusion", None, None).await?; + controller.eval("document.getElementById('chromium-acceptance-overlay')?.remove();")?; + viewport(app, Some(a), Some(rect)).await?; + pixels(app, view, true, Some(rect)).await?; + require_active(&controller, "viewport expiry visibility baseline").await?; + anyhow::ensure!(leased(app, a)?, "visible CEF tab lacks a viewport lease"); + // Do not renew presentation or reactivate the window during this interval. + // Otherwise inactive-window hiding could masquerade as watchdog expiry. + let deadline = tokio::time::Instant::now() + Duration::from_millis(2600); + while tokio::time::Instant::now() < deadline { + require_active(&controller, "viewport heartbeat expiry interval").await?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + require_active(&controller, "viewport heartbeat expiry capture").await?; + pixels(app, view, false, None).await?; + require_active(&controller, "expired viewport hidden capture").await?; + anyhow::ensure!( + !leased(app, a)?, + "watchdog retained an expired viewport lease" + ); + viewport(app, Some(a), Some(rect)).await?; + pixels(app, view, true, Some(rect)).await?; + println!("PASS stale viewport heartbeat hides CEF and fresh lease restores it"); + Ok(()) +} + +async fn require_active(controller: &tauri::Webview, scenario: &str) -> anyhow::Result<()> { + anyhow::ensure!( + colossus_native_browser::is_active(controller).await?, + "Desktop lost foreground during {scenario}; leave the test window in front and rerun" + ); + Ok(()) +} + +fn leased(app: &tauri::AppHandle, id: &str) -> anyhow::Result { + Ok(app + .state::() + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .tabs + .iter() + .find(|tab| tab.dto.id == id) + .is_some_and(|tab| tab.heartbeat.is_some())) +} + +async fn workspace_boundary( + app: &tauri::AppHandle, + address: &str, + a: &str, + old_generation: u64, +) -> anyhow::Result<()> { + let state = app.state::(); + state + .select_target(Some("chromium-acceptance-b".into())) + .await; + anyhow::ensure!( + state.browser.validate(old_generation).is_err(), + "stale workspace command retained browser authority" + ); + anyhow::ensure!( + state.browser.tab(a, "chromium-acceptance-b").is_err(), + "foreign workspace could access CEF tab" + ); + let snapshot = action( + app, + BrowserAction::New { + conversation_id: None, + url: format!("{address}/first"), + }, + ) + .await?; + anyhow::ensure!( + snapshot.tabs.len() == 1 && !snapshot.engine.agent_control_available, + "workspace selection or disabled production automation changed" + ); + let b = snapshot + .selected_tab_id + .ok_or_else(|| anyhow::anyhow!("other workspace tab missing"))?; + wait_title(&surface(app, &b, "chromium-acceptance-b")?, "First page").await?; + anyhow::ensure!( + action( + app, + BrowserAction::Navigate { + tab_id: b, + url: "http://tauri.localhost/".into() + } + ) + .await + .is_err(), + "CEF accepted privileged app origin" + ); + println!( + "PASS CEF workspace generation, foreign tab, app-origin denial, and disabled production automation" + ); + Ok(()) +} + +async fn checkpoint( + app: &tauri::AppHandle, + label: &str, + id: Option<&str>, + rect: Option, +) -> anyhow::Result<()> { + println!("CHECKPOINT {label}"); + let pause = std::env::var("COLOSSUS_BROWSER_ACCEPTANCE_PAUSE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .min(30_000); + let deadline = tokio::time::Instant::now() + Duration::from_millis(pause); + while tokio::time::Instant::now() < deadline { + viewport(app, id, rect).await?; + tokio::time::sleep(Duration::from_millis(250)).await; + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs new file mode 100644 index 000000000..e136c71c8 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs @@ -0,0 +1,1973 @@ +//! Debug-only, real Core -> managed sidecar -> native Desktop acceptance. +//! The fixture is bound to one compiler-sealed loopback origin and grants only +//! the browser actions exercised here. A failed assertion never reports a pass. + +use std::{ + io::Write as _, + net::{Ipv4Addr, SocketAddrV4}, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use crate::{ + bundle::VerifiedBundle, desktop_settings::validate_workspace, native_browser_authentication, + state::AppState, +}; +use anyhow::{Context as _, ensure}; +use colossus_contracts::{BrowserDocumentId, BrowserTabId, BrowserTarget}; +#[cfg(target_os = "macos")] +use colossus_native_browser::presentation::AcceptanceFocusState; +use colossus_native_browser::{ + BrowserError, BrowserView, + remote::{RemoteAcceptanceProbe, RemoteAcceptedFrame, RemoteSurface}, +}; +use colossus_sdk::{ + ApiMajor, AppPrivateInstanceDir, CancelRunRequest, Colossus, CreateRunRequest, GetRunRequest, + IdempotencyKey, InputContentPart, InstanceId, ManagedAccessProfile, ManagedExecutionBoundary, + ManagedFieldOverride, ManagedProviderKind, ManagedRuntimeConfig, NativeSidecarLifecycle, + RunMode, RunStatus, SidecarApplicationGrant, SidecarBootstrapConfig, SidecarOptions, +}; +use serde_json::{Value, json}; +use tauri::Manager as _; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::{TcpListener, TcpStream}, + sync::Notify, + task::JoinHandle, +}; + +use super::{ + super::dto::{BrowserAction, BrowserRect, BrowserViewportRequest}, + action, +}; +#[cfg(windows)] +#[path = "windows_remote.rs"] +mod windows; +#[cfg(windows)] +#[path = "windows_frames.rs"] +mod windows_frames; +#[cfg(windows)] +use windows_frames::{accepted_frame, focus}; + +mod native_input; +mod owned_tabs; +mod viewport_heartbeat; +use viewport_heartbeat::ViewportHeartbeat; + +const SPACE: &str = if cfg!(windows) { + "windows-owned-acceptance" +} else { + "mac-contained-acceptance" +}; +const TOOLS: [&str; 11] = [ + "browser.open", + "browser.status", + "browser.snapshot", + "browser.click", + "browser.navigate", + "browser.screenshot", + "browser.close", + "browser.tabs", + "browser.tab.open", + "browser.tab.select", + "browser.tab.close", +]; +const MODEL_TIMEOUT: Duration = Duration::from_secs(20); +const VISUAL_MODEL_TIMEOUT: Duration = Duration::from_secs(90); +const PAGE_TIMEOUT: Duration = Duration::from_secs(15); +// The existing agent writer lease is sixty seconds. Reserve twenty seconds +// for tool latency, viewer detachment, Core close and acknowledged shutdown. +const MAX_VISUAL_HOLD_MS: u64 = 40_000; + +struct Fixture { + origin: String, + task: JoinHandle<()>, + state: Arc, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Default)] +struct FixtureState { + native_input: bool, + native_tabs: bool, + tabs_begin: AtomicBool, + tabs_ack: AtomicUsize, + tab_event: Mutex>, + second_tab: Mutex>, + model_timeout: Duration, + phase: AtomicUsize, + model_seen: AtomicBool, + handoff_done: AtomicBool, + click_observed: AtomicBool, + navigation_observed: AtomicBool, + screenshot_seen: AtomicBool, + viewer_detached: AtomicBool, + changed: Notify, + target: Mutex>, + navigated_target: Mutex>, + run_id: Mutex>, + failure: Mutex>, +} + +fn close_after_completion() -> bool { + cfg!(windows) + && std::env::var("COLOSSUS_BROWSER_ACCEPTANCE_CLOSE_AFTER_COMPLETION").as_deref() == Ok("1") +} + +impl FixtureState { + async fn wait(&self, flag: &AtomicBool, what: &str) -> anyhow::Result<()> { + tokio::time::timeout(self.model_timeout, async { + loop { + let notified = self.changed.notified(); + self.check()?; + if flag.load(Ordering::Acquire) { + return Ok::<(), anyhow::Error>(()); + } + notified.await; + } + }) + .await + .with_context(|| format!("timed out waiting for {what}"))??; + Ok(()) + } + + fn signal(&self, flag: &AtomicBool) { + flag.store(true, Ordering::Release); + self.changed.notify_waiters(); + } + + fn fail(&self, message: String) { + if let Ok(mut failure) = self.failure.lock() { + failure.get_or_insert(message); + } + self.changed.notify_waiters(); + } + + fn check(&self) -> anyhow::Result<()> { + if let Some(failure) = self.failure.lock().unwrap().as_deref() { + anyhow::bail!("deterministic provider fixture failed: {failure}"); + } + Ok(()) + } + + async fn navigated_target(&self) -> anyhow::Result { + tokio::time::timeout(self.model_timeout, async { + loop { + let notified = self.changed.notified(); + self.check()?; + if let Some(target) = self.navigated_target.lock().unwrap().clone() { + return Ok::(target); + } + notified.await; + } + }) + .await + .context("timed out waiting for Core navigation target")? + } +} + +impl Fixture { + async fn bind(address: &str, visual_hold: Duration) -> anyhow::Result { + let url = tauri::Url::parse(address)?; + ensure!( + url.scheme() == "http" + && url.host_str() == Some("127.0.0.1") + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none(), + "diagnostic fixture requires an exact loopback origin" + ); + let port = url + .port() + .context("diagnostic fixture requires a fixed port")?; + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)).await?; + let origin = url.origin().ascii_serialization(); + let native_tabs = owned_tabs::enabled()?; + ensure!( + !native_tabs || visual_hold <= Duration::from_secs(20), + "owned-tab acceptance reserves the existing writer lease by limiting the visual hold to 20000ms" + ); + let state = Arc::new(FixtureState { + native_input: native_input::enabled()?, + native_tabs, + model_timeout: if visual_hold.is_zero() { + MODEL_TIMEOUT + } else { + VISUAL_MODEL_TIMEOUT + }, + ..FixtureState::default() + }); + let server_state = Arc::clone(&state); + let server_origin = origin.clone(); + let task = tokio::spawn(async move { + loop { + let Ok((stream, peer)) = listener.accept().await else { + break; + }; + if !peer.ip().is_loopback() { + continue; + } + let state = Arc::clone(&server_state); + let origin = server_origin.clone(); + tokio::spawn(async move { + if let Err(error) = serve(stream, &origin, &state).await { + state.fail(format!("{error:#}")); + } + }); + } + }); + Ok(Self { + origin, + task, + state, + }) + } +} + +struct Request { + method: String, + path: String, + body: Vec, +} + +async fn read_request(stream: &mut TcpStream) -> anyhow::Result> { + let mut bytes = Vec::new(); + let mut block = [0_u8; 4096]; + let header_end = loop { + if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + break index + 4; + } + ensure!(bytes.len() < 16 * 1024, "fixture headers exceeded limit"); + let count = + match tokio::time::timeout(Duration::from_secs(5), stream.read(&mut block)).await { + Ok(result) => result?, + Err(_) if bytes.is_empty() => return Ok(None), + Err(error) => return Err(error.into()), + }; + // Chromium may open an idle speculative connection and close it without + // sending a request. That is not a fixture or browser failure. + if count == 0 && bytes.is_empty() { + return Ok(None); + } + ensure!(count > 0, "fixture connection ended inside headers"); + bytes.extend_from_slice(&block[..count]); + }; + let header = std::str::from_utf8(&bytes[..header_end])?; + let mut lines = header.split("\r\n"); + let line = lines.next().context("missing fixture request line")?; + let mut first = line.split_ascii_whitespace(); + let method = first.next().context("missing fixture method")?.to_owned(); + let path = first.next().context("missing fixture path")?.to_owned(); + ensure!( + first.next() == Some("HTTP/1.1"), + "unexpected fixture HTTP version" + ); + let mut content_length = 0_usize; + for line in lines { + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = value.trim().parse()?; + } + } + ensure!(content_length <= 1024 * 1024, "fixture body exceeded limit"); + let total = header_end + content_length; + while bytes.len() < total { + let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut block)).await??; + ensure!(count > 0, "fixture connection ended inside body"); + bytes.extend_from_slice(&block[..count]); + ensure!( + bytes.len() <= total + 4096, + "fixture request exceeded length" + ); + } + Ok(Some(Request { + method, + path, + body: bytes[header_end..total].to_vec(), + })) +} + +async fn respond(stream: &mut TcpStream, code: u16, kind: &str, body: &[u8]) -> anyhow::Result<()> { + let header = format!( + "HTTP/1.1 {code} {}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n", + if code == 200 { "OK" } else { "Error" }, + body.len() + ); + stream.write_all(header.as_bytes()).await?; + stream.write_all(body).await?; + stream.shutdown().await?; + Ok(()) +} + +async fn serve( + mut stream: TcpStream, + origin: &str, + state: &Arc, +) -> anyhow::Result<()> { + let Some(request) = read_request(&mut stream).await? else { + return Ok(()); + }; + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/first") => { + let page: &[u8] = if cfg!(windows) { + br#"Human fixture

Human fixture

Waiting for Core"# + } else if state.native_input { + include_bytes!("native_input.html") + } else { + br#"Human fixture

Human fixture

Waiting for Core"# + }; + respond(&mut stream, 200, "text/html; charset=utf-8", page).await?; + } + ("GET", "/agent") => { + let page = br#"Agent controlled page

Agent controlled page

Core navigated this page after native handoff.

"#; + respond(&mut stream, 200, "text/html; charset=utf-8", page).await?; + } + ("GET", "/tab-second") => { + respond( + &mut stream, + 200, + "text/html; charset=utf-8", + owned_tabs::PAGE, + ) + .await?; + } + ("GET", "/favicon.ico") => { + respond(&mut stream, 200, "image/x-icon", &[]).await?; + } + ("POST", "/v1/chat/completions") => { + let body: Value = serde_json::from_slice(&request.body)?; + ensure!( + body["stream"] == false, + "fixture requires non-streaming model" + ); + let phase = state.phase.fetch_add(1, Ordering::AcqRel); + ensure!( + phase <= if state.native_tabs { 11 } else { 7 }, + "unexpected extra model turn {phase}" + ); + state.signal(&state.model_seen); + if phase == 0 { + state + .wait(&state.handoff_done, "native human-to-agent handoff") + .await?; + } + if phase == 3 { + state + .wait(&state.click_observed, "visible Core click") + .await?; + } + if phase == 5 { + let result = tool_result(&body, "fixture-4")?; + let target = browser_target(&result["tab"])?; + *state.navigated_target.lock().unwrap() = Some(target); + state.changed.notify_waiters(); + state + .wait(&state.navigation_observed, "visible Core navigation") + .await?; + } + let response = model_response(phase, &body, origin, state).await?; + let encoded = serde_json::to_vec(&response)?; + respond(&mut stream, 200, "application/json", &encoded).await?; + } + _ => { + respond(&mut stream, 404, "text/plain", b"fixture route unavailable").await?; + } + } + Ok(()) +} + +fn tool_result(body: &Value, call_id: &str) -> anyhow::Result { + let messages = body["messages"] + .as_array() + .context("model messages missing")?; + let message = messages + .iter() + .rev() + .find(|message| message["role"] == "tool" && message["tool_call_id"] == call_id) + .with_context(|| format!("Core did not return {call_id} tool evidence"))?; + let content = message["content"] + .as_str() + .context("tool evidence is not scalar text")?; + let result: Value = serde_json::from_str(content).context("tool evidence is not JSON")?; + if result.get("error").is_some() { + let code = categorical_tool_failure(&result); + eprintln!( + "EVIDENCE core-tool-failure code={code} browser_error={}", + categorical_browser_failure(&result) + ); + } + Ok(result) +} + +fn categorical_tool_failure(result: &Value) -> &'static str { + match result["error"]["code"] + .as_str() + .or_else(|| result["error"]["type"].as_str()) + { + Some("tool.denied" | "denied") => "tool.denied", + Some("tool.outcome_unknown" | "outcome_unknown") => "tool.outcome_unknown", + Some("unknown_tool") => "tool.unknown", + Some("invalid_arguments") => "tool.invalid_arguments", + Some("execution_error") => "tool.execution_error", + Some("validation_error") => "tool.validation_error", + _ => "tool.unclassified", + } +} + +fn categorical_browser_failure(result: &Value) -> &'static str { + match result["error"]["message"].as_str() { + Some("browser requires a fresh snapshot") => "snapshot_required", + Some("browser document or element is stale" | "browser target is stale") => { + "stale_document" + } + Some("browser control lease is stale") => "stale_control", + Some("browser destination denied" | "browser native boundary denied operation") => "denied", + Some("browser native host failed") => "native_failed", + Some( + "browser outcome unknown" | "browser outcome unknown; automatic retry is prohibited", + ) => "outcome_unknown", + _ => "unclassified", + } +} + +fn browser_target(tab: &Value) -> anyhow::Result { + Ok(BrowserTarget { + tab_id: BrowserTabId::parse( + tab["tab_id"] + .as_str() + .context("Core tab identity missing")?, + )?, + document_id: BrowserDocumentId::parse( + tab["document_id"] + .as_str() + .context("Core document identity missing")?, + )?, + }) +} + +fn alias(body: &Value, canonical: &str) -> anyhow::Result { + let tools = body["tools"].as_array().context("model tools missing")?; + let expected = canonical.replace('.', "_"); + tools + .iter() + .filter_map(|tool| tool["function"]["name"].as_str()) + .find(|name| *name == canonical || *name == expected) + .map(str::to_owned) + .with_context(|| format!("Core did not offer {canonical}")) +} + +fn model_tool(phase: usize, name: &str, arguments: &Value) -> Value { + json!({ + "id": format!("fixture-call-{phase}"), + "object": "chat.completion", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": format!("fixture-{phase}"), + "type": "function", + "function": { + "name": name, + "arguments": arguments.to_string() + } + }] + }, + "finish_reason": "tool_calls" + }] + }) +} + +// Keep the deterministic Core tool transcript in one ordered source fixture. +#[allow(clippy::too_many_lines)] +async fn model_response( + phase: usize, + body: &Value, + origin: &str, + state: &FixtureState, +) -> anyhow::Result { + let session = state.target.lock().unwrap().clone(); + match phase { + 0 => { + ensure!( + alias(body, "browser.open").is_ok(), + "registered Core run did not offer browser.open for native handoff" + ); + let id = session + .as_ref() + .and_then(|target| target["session_id"].as_str()) + .context("native session was not installed before model release")?; + Ok(model_tool( + phase, + &alias(body, "browser.status")?, + &json!({"session_id": id}), + )) + } + 1 => { + let status = tool_result(body, "fixture-0")?; + let tab = status["tabs"] + .as_array() + .and_then(|tabs| tabs.first()) + .context("Core browser.status lacked the owned tab")?; + let target = json!({ + "session_id": status["session_id"], + "control_generation": status["control_generation"], + "tab_id": tab["tab_id"], + "document_id": tab["document_id"] + }); + let expected_session = session + .as_ref() + .and_then(|target| target["session_id"].as_str()) + .context("missing owned session at Core status")?; + ensure!( + target["session_id"] == expected_session + && target["control_generation"] + .as_u64() + .is_some_and(|value| value > 0), + "Core status did not describe the handed-off agent page" + ); + *state.target.lock().unwrap() = Some(target.clone()); + let mut args = target; + args["max_nodes"] = json!(128); + Ok(model_tool(phase, &alias(body, "browser.snapshot")?, &args)) + } + 2 => { + let observation = tool_result(body, "fixture-1")?; + let node = observation["snapshot"]["nodes"] + .as_array() + .and_then(|nodes| nodes.iter().find(|node| node["name"] == "Agent click")) + .context("Core AX snapshot did not expose the click target")?; + let mut args = session.context("missing owned target")?; + args["tab_id"] = observation["tab"]["tab_id"].clone(); + args["document_id"] = observation["tab"]["document_id"].clone(); + args["snapshot_id"] = node["element"]["snapshot_id"].clone(); + args["element_id"] = node["element"]["element_id"].clone(); + Ok(model_tool(phase, &alias(body, "browser.click")?, &args)) + } + 3 => { + let clicked = tool_result(body, "fixture-2")?; + ensure!( + clicked["tab"]["tab_id"].is_string(), + "Core click did not return a browser observation" + ); + let mut args = session.context("missing owned target")?; + args["tab_id"] = clicked["tab"]["tab_id"].clone(); + args["document_id"] = clicked["tab"]["document_id"].clone(); + args["max_nodes"] = json!(128); + Ok(model_tool(phase, &alias(body, "browser.snapshot")?, &args)) + } + 4 => { + let observed = tool_result(body, "fixture-3")?; + ensure!( + observed["snapshot"]["snapshot_id"].is_string(), + "Core fresh snapshot after click is missing" + ); + let mut args = session.context("missing owned target")?; + args["tab_id"] = observed["tab"]["tab_id"].clone(); + args["document_id"] = observed["tab"]["document_id"].clone(); + args["url"] = json!(format!("{origin}/agent")); + Ok(model_tool(phase, &alias(body, "browser.navigate")?, &args)) + } + 5 => { + let navigated = tool_result(body, "fixture-4")?; + ensure!( + navigated["tab"]["origin"].is_object() || navigated["tab"]["origin"].is_string(), + "Core navigate did not return an owned browser observation" + ); + let mut args = session.context("missing owned target")?; + args["tab_id"] = navigated["tab"]["tab_id"].clone(); + args["document_id"] = navigated["tab"]["document_id"].clone(); + Ok(model_tool( + phase, + &alias(body, "browser.screenshot")?, + &args, + )) + } + 6 => { + let screenshot = tool_result(body, "fixture-5")?; + ensure!( + screenshot["artifact"]["media_type"] == "image/png" + && screenshot["artifact"]["width_pixels"] + .as_u64() + .is_some_and(|width| width > 0) + && screenshot["artifact"]["height_pixels"] + .as_u64() + .is_some_and(|height| height > 0) + && screenshot["artifact"]["sha256"] + .as_str() + .is_some_and(|hash| hash.len() == 64), + "Core screenshot did not publish verified PNG artifact metadata" + ); + state.signal(&state.screenshot_seen); + if state.native_tabs { + return owned_tabs::response(phase, body, origin, state).await; + } + state + .wait(&state.viewer_detached, "native viewer detachment") + .await?; + if close_after_completion() { + return Ok( + json!({"id":"fixture-natural-complete","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"Owned browser natural completion acceptance."},"finish_reason":"stop"}]}), + ); + } + let target = session.context("missing owned target for Core close")?; + Ok(model_tool( + phase, + &alias(body, "browser.close")?, + &json!({ + "session_id": target["session_id"], + "control_generation": target["control_generation"] + }), + )) + } + 7..=11 if state.native_tabs => owned_tabs::response(phase, body, origin, state).await, + 7 => { + let closed = tool_result(body, "fixture-6")?; + let expected_session = session + .as_ref() + .and_then(|target| target["session_id"].as_str()) + .context("missing owned session at Core close confirmation")?; + ensure!( + closed["closed"] == true && closed["session_id"] == expected_session, + "Core browser.close did not confirm the owned session" + ); + Ok(json!({ + "id": "fixture-complete", + "object": "chat.completion", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Owned browser automation acceptance complete."}, + "finish_reason": "stop" + }] + })) + } + _ => anyhow::bail!("unexpected fixture phase"), + } +} + +fn private_directory(path: &Path) -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + std::fs::DirBuilder::new().mode(0o700).create(path)?; + } + #[cfg(windows)] + colossus_windows_native::create_private_directory(path)?; + Ok(()) +} + +fn managed_runtime(origin: &str, visual_hold: Duration) -> ManagedRuntimeConfig { + let mut runtime = ManagedRuntimeConfig::echo(ManagedAccessProfile::Pinned) + .with_execution_boundary(ManagedExecutionBoundary::OfflineIsolated); + runtime.providers[0].kind = ManagedProviderKind::OpenAiCompatible; + runtime.providers[0].base_url = Some(format!("{origin}/v1")); + runtime.providers[0].timeout_ms = if visual_hold.is_zero() { + 30_000 + } else { + 90_000 + }; + runtime.models[0].model = "mac-browser-fixture".into(); + runtime.models[0].capabilities.streaming = false.into(); + runtime.field_overrides = vec![ + ManagedFieldOverride { + field_id: "access.tools.include".into(), + value: json!(TOOLS), + }, + ManagedFieldOverride { + field_id: "access.actions.allow".into(), + value: json!([ + "provider.call", + "provider.openai.chat", + "browser.open", + "browser.status", + "browser.snapshot", + "browser.click", + "browser.navigate", + "browser.screenshot", + "browser.close", + "browser.tabs", + "browser.tab.open", + "browser.tab.select", + "browser.tab.close" + ]), + }, + ManagedFieldOverride { + field_id: "sandbox.networkDestinations".into(), + value: json!([origin]), + }, + ]; + runtime +} + +fn grant() -> anyhow::Result { + // Exercise the interactive app's authenticated tool ceiling rather than + // supplying a fixture-only grant that can hide Desktop composition gaps. + Ok(crate::managed_runtime::application_grant( + crate::desktop_settings::AccessProfileSetting::AllowAll, + )?) +} + +async fn start_sidecar( + home: &Path, + origin: &str, + visual_hold: Duration, +) -> anyhow::Result<(Colossus, colossus_sdk::NativeBrowserClient)> { + let workspace_path = home.join("workspace"); + let instance_path = home.join("instance"); + let runtime_home = home.join("colossus-home"); + private_directory(&workspace_path)?; + private_directory(&instance_path)?; + private_directory(&runtime_home)?; + let workspace = + validate_workspace(&workspace_path).map_err(|error| anyhow::anyhow!(error.message))?; + let bundle = VerifiedBundle::load().map_err(|error| anyhow::anyhow!(error.message))?; + let options = SidecarOptions::new( + InstanceId::from_uuid(uuid::Uuid::now_v7()), + AppPrivateInstanceDir::new(instance_path)?, + bundle.sidecar, + ApiMajor::new(1)?, + )?; + let authentication = native_browser_authentication::generate()?; + let bootstrap = SidecarBootstrapConfig::new( + workspace.path, + managed_runtime(origin, visual_hold), + grant()?, + )? + .with_expected_workspace_identity( + workspace + .identity + .context("private workspace identity missing")?, + )? + .with_colossus_home(runtime_home)? + .with_plaintext_journal_for_development() + .without_automatic_agent_instructions_for_diagnostics() + .with_native_browser_authentication(authentication)?; + let lifecycle = NativeSidecarLifecycle::new(bootstrap); + let native = lifecycle + .native_browser_client(&options)? + .context("diagnostic sidecar did not enroll native browser client")?; + let client = Colossus::start_sidecar(&lifecycle, options).await?; + let availability = native.available().await; + if !matches!(availability, Ok(true)) { + // Once the authenticated child has started, an admission failure must + // await its shutdown. A successful SDK close proves sidecar exit here; + // no browser session exists from which to infer a direct-child receipt. + let close = client.close().await; + eprintln!( + "EVIDENCE contained diagnostic bootstrap cleanup SDKClose={}", + if close.is_ok() { + "verifiedSidecarExit" + } else { + "unknown" + } + ); + ensure!(availability?, "Core did not admit Embedded browser"); + } + Ok((client, native)) +} + +fn bounds(app: &tauri::AppHandle) -> anyhow::Result { + let window = app.get_window("main").context("Desktop window missing")?; + let size = window + .inner_size()? + .to_logical::(window.scale_factor()?); + ensure!( + size.width >= 300.0 && size.height >= 300.0, + "Desktop window too small" + ); + Ok(BrowserRect { + x: 24.0, + y: 96.0, + width: size.width - 48.0, + height: size.height - 120.0, + }) +} + +async fn present(app: &tauri::AppHandle, id: &str, rect: BrowserRect) -> anyhow::Result<()> { + let state = app.state::(); + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + super::super::commands::require_controller(&controller) + .map_err(|error| anyhow::anyhow!(error.message))?; + let generation = { + let registry = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))?; + ensure!( + !registry.draining + && registry.scope.as_deref() == Some(SPACE) + && registry + .selected + .get(SPACE) + .is_some_and(|selected| selected == id) + && registry + .tabs + .iter() + .any(|tab| tab.scope == SPACE && tab.dto.id == id), + "fixture viewport lost its exact selected native tab" + ); + registry.generation + }; + state + .browser + .viewport( + app, + &BrowserViewportRequest { + generation, + tab_id: Some(id.to_owned()), + rect: Some(rect), + }, + ) + .await + .map_err(|error| anyhow::anyhow!(error.message)) +} + +fn remote(app: &tauri::AppHandle, id: &str) -> anyhow::Result { + let (view, _) = app + .state::() + .browser + .tab(id, SPACE) + .map_err(|error| anyhow::anyhow!(error.message))?; + match view { + BrowserView::Remote(surface) => Ok(surface), + _ => anyhow::bail!("Desktop diagnostic did not attach a RemoteSurface"), + } +} + +async fn title(view: &RemoteSurface, expected: &str) -> anyhow::Result<()> { + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + let mut last = String::from("no native title receipt"); + loop { + if let Ok(page) = view.inspect().await { + last.clone_from(&page.title); + if page.title == expected && !page.loading { + return Ok(()); + } + } + ensure!( + tokio::time::Instant::now() < deadline, + "native RemoteSurface did not show title {expected:?}; last title {last:?}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +struct ExpectedFrame<'a> { + title: &'a str, + url: &'a str, + center_bgra: [u8; 4], + core_target: Option<&'a BrowserTarget>, + previous: Option<&'a RemoteAcceptanceProbe>, + after: Option, +} + +fn same_document_frame_after(frame: RemoteAcceptedFrame, prior: RemoteAcceptedFrame) -> bool { + frame.lease.tab == prior.lease.tab + && frame.lease.session_generation == prior.lease.session_generation + && frame.lease.control_generation == prior.lease.control_generation + && frame.lease.document_generation == prior.lease.document_generation + && frame.lease.pixel_width == prior.lease.pixel_width + && frame.lease.pixel_height == prior.lease.pixel_height + && frame.sequence > 0 + && frame.count > 0 + && (frame.lease.viewport_generation > prior.lease.viewport_generation + || (frame.lease.viewport_generation == prior.lease.viewport_generation + && frame.sequence > prior.sequence + && frame.count > prior.count)) +} + +#[cfg(target_os = "macos")] +async fn frame_timeout_evidence( + app: &tauri::AppHandle, + id: &str, + rect: BrowserRect, + expected: &ExpectedFrame<'_>, + probe: Option<&RemoteAcceptanceProbe>, + last_probe_error: &str, + activation_requests: u8, +) { + let frame = probe.and_then(|observed| observed.frame); + let color_match = frame.is_some_and(|accepted| accepted.center_bgra == expected.center_bgra); + let target_match = probe.is_some_and(|observed| { + expected + .core_target + .is_none_or(|target| observed.page.target == *target) + && observed.page.title == expected.title + && observed.page.url == expected.url + }); + let appkit = if let Some(window) = app.get_window("main") { + colossus_native_browser::presentation::acceptance_focus_state(&window, false) + .await + .ok() + } else { + None + }; + let controller_active = if let Some(controller) = app.get_webview("main") { + colossus_native_browser::is_active(&controller).await.ok() + } else { + None + }; + let valid_bounds = app + .get_window("main") + .and_then(|window| { + let scale = window.scale_factor().ok()?; + let size = window.inner_size().ok()?.to_logical::(scale); + Some(super::super::viewport::valid_rect( + rect, + size.width, + size.height, + )) + }) + .unwrap_or(false); + let state = app.state::(); + let registry = state.browser.lock().ok(); + let registry_observed = registry.is_some(); + let registry_draining = registry.as_ref().is_some_and(|registry| registry.draining); + let registry_scope_match = registry + .as_ref() + .is_some_and(|registry| registry.scope.as_deref() == Some(SPACE)); + let registry_selected = registry.as_ref().is_some_and(|registry| { + registry + .selected + .get(SPACE) + .is_some_and(|selected| selected == id) + }); + let tab = registry + .as_ref() + .and_then(|registry| registry.tabs.iter().find(|tab| tab.dto.id == id)); + let registry_tab_present = tab.is_some(); + let registry_tab_scope_match = tab.is_some_and(|tab| tab.scope == SPACE); + let registry_tab_error = tab.is_some_and(|tab| tab.dto.error.is_some()); + eprintln!( + "EVIDENCE mac-remote-frame timeout probe_present={} last_probe_error={last_probe_error} frame_present={} count={} color_match={color_match} target_match={target_match} visible={} width={} height={} focus_observed={} app_active={} window_key={} app_key_matches={} controller_observed={} controller_active={} valid_bounds={valid_bounds} registry_observed={registry_observed} registry_draining={registry_draining} registry_scope_match={registry_scope_match} registry_selected={registry_selected} registry_tab_present={registry_tab_present} registry_tab_scope_match={registry_tab_scope_match} registry_tab_error={registry_tab_error} activation_requests={activation_requests}", + probe.is_some(), + frame.is_some(), + frame.map_or(0, |accepted| accepted.count.min(1_000_000)), + probe.is_some_and(|observed| observed.visible), + probe.map_or(0, |observed| observed.width.min(16_384)), + probe.map_or(0, |observed| observed.height.min(16_384)), + appkit.is_some(), + appkit.is_some_and(AcceptanceFocusState::app_active), + appkit.is_some_and(AcceptanceFocusState::window_key), + appkit.is_some_and(AcceptanceFocusState::app_key_matches), + controller_active.is_some(), + controller_active.unwrap_or(false), + ); +} + +#[cfg(target_os = "macos")] +async fn accepted_frame( + app: &tauri::AppHandle, + id: &str, + view: &RemoteSurface, + rect: BrowserRect, + expected: ExpectedFrame<'_>, +) -> anyhow::Result { + let window = app.get_window("main").context("Desktop window missing")?; + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + let mut next_focus_sample = tokio::time::Instant::now(); + let mut next_activation = tokio::time::Instant::now(); + let mut activation_requests = 0_u8; + loop { + if tokio::time::Instant::now() >= next_focus_sample { + let controller_active = colossus_native_browser::is_active(&controller) + .await + .unwrap_or(false); + let appkit = + colossus_native_browser::presentation::acceptance_focus_state(&window, false) + .await + .ok(); + if !(controller_active && appkit.is_some_and(AcceptanceFocusState::foreground)) + && tokio::time::Instant::now() >= next_activation + { + // Only the diagnostic fixture's own window is activated. The + // frame still needs independently observed foreground state. + let _ = window.set_focus(); + let _ = + colossus_native_browser::presentation::acceptance_focus_state(&window, true) + .await; + activation_requests = activation_requests.saturating_add(1); + next_activation = tokio::time::Instant::now() + Duration::from_secs(2); + } + next_focus_sample = tokio::time::Instant::now() + Duration::from_millis(500); + } + let (probe, last_probe_error) = match view.acceptance_probe().await { + Ok(probe) => (Some(probe), "none"), + Err(BrowserError::Stale) => (None, "stale"), + Err(BrowserError::Unavailable) => (None, "unavailable"), + Err(error) => return Err(error.into()), + }; + if let Some(probe) = probe.as_ref() { + let target_matches = expected + .core_target + .is_none_or(|target| probe.page.target == *target) + && probe.page.title == expected.title + && probe.page.url == expected.url; + if let Some(frame) = probe.frame { + let new_document = expected.previous.is_none_or(|previous| { + previous.frame.is_some_and(|prior| { + probe.page.target.document_id != previous.page.target.document_id + && frame.lease.document_generation > prior.lease.document_generation + && frame.lease.viewport_generation > prior.lease.viewport_generation + && frame.lease.control_generation > prior.lease.control_generation + }) + }); + if probe.visible + && probe.width > 0 + && probe.height > 0 + && !probe.page.loading + && probe.page.title == expected.title + && probe.page.url == expected.url + && target_matches + && new_document + && expected + .after + .is_none_or(|prior| same_document_frame_after(frame, prior)) + && frame.sequence > 0 + && frame.count > 0 + && frame.center_bgra == expected.center_bgra + { + let controller_active = colossus_native_browser::is_active(&controller) + .await + .unwrap_or(false); + let appkit = colossus_native_browser::presentation::acceptance_focus_state( + &window, false, + ) + .await + .ok(); + if controller_active && appkit.is_some_and(AcceptanceFocusState::foreground) { + return Ok(probe.clone()); + } + } + } + } + if tokio::time::Instant::now() >= deadline { + frame_timeout_evidence( + app, + id, + rect, + &expected, + probe.as_ref(), + last_probe_error, + activation_requests, + ) + .await; + anyhow::bail!( + "native NSView did not accept an authenticated frame for the expected current document and fixture color" + ); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[cfg(target_os = "macos")] +async fn next_viewport_frame( + app: &tauri::AppHandle, + view: &RemoteSurface, + previous: &RemoteAcceptanceProbe, +) -> anyhow::Result { + let window = app.get_window("main").context("Desktop window missing")?; + let scale = window.scale_factor()?; + ensure!( + scale.is_finite() && (2.0..=4.0).contains(&scale), + "Mac Retina acceptance requires an active 2x-or-greater display" + ); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let scale_milli = (scale * 1000.0).round() as u32; + let previous_frame = previous.frame.context("previous native frame missing")?; + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + loop { + let probe = match view.acceptance_probe().await { + Ok(probe) => Some(probe), + Err(BrowserError::Stale | BrowserError::Unavailable) => None, + Err(error) => return Err(error.into()), + }; + if let Some(probe) = probe + && let Some(frame) = probe.frame + { + let width = (u64::from(probe.width) * u64::from(scale_milli)).div_ceil(1000); + let height = (u64::from(probe.height) * u64::from(scale_milli)).div_ceil(1000); + let width = u32::try_from(width).context("Retina width exceeds frame bound")?; + let height = u32::try_from(height).context("Retina height exceeds frame bound")?; + if probe.visible + && !probe.page.loading + && probe.page.target == previous.page.target + && probe.page.url == previous.page.url + && probe.page.title == previous.page.title + && frame.lease.document_generation == previous_frame.lease.document_generation + && frame.lease.control_generation == previous_frame.lease.control_generation + && frame.lease.viewport_generation > previous_frame.lease.viewport_generation + && frame.lease.pixel_width == width + && frame.lease.pixel_height == height + && frame.sequence > 0 + && frame.count > 0 + && frame.center_bgra == previous_frame.center_bgra + { + return Ok(probe); + } + } + ensure!( + tokio::time::Instant::now() < deadline, + "Retina resize did not submit a new authenticated native viewport frame" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[cfg(target_os = "macos")] +async fn resize_and_occlude( + app: &tauri::AppHandle, + id: &str, + view: &RemoteSurface, + previous: &RemoteAcceptanceProbe, + heartbeat: &ViewportHeartbeat, +) -> anyhow::Result<(BrowserRect, RemoteAcceptanceProbe)> { + heartbeat.pause().await?; + let window = app.get_window("main").context("Desktop window missing")?; + window.set_size(tauri::LogicalSize::new(1050.0, 760.0))?; + tokio::time::sleep(Duration::from_millis(150)).await; + focus(app).await?; + let rect = bounds(app)?; + heartbeat.resume(id, rect).await?; + let resized = next_viewport_frame(app, view, previous).await?; + ensure!( + resized.width != previous.width || resized.height != previous.height, + "Desktop resize did not change the authenticated native viewport" + ); + let resized_frame = resized.frame.context("resized native frame missing")?; + println!( + "PASS authenticated Retina Desktop resize: logical={}x{} pixels={}x{} viewport_generation={}", + resized.width, + resized.height, + resized_frame.lease.pixel_width, + resized_frame.lease.pixel_height, + resized_frame.lease.viewport_generation + ); + + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + heartbeat.pause().await?; + controller.eval("const overlay = document.createElement('div'); overlay.id = 'contained-acceptance-overlay'; overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:var(--surface-primary,#fff)'; document.body.appendChild(overlay);")?; + let state = app.state::(); + let generation = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .generation; + state + .browser + .viewport( + app, + &BrowserViewportRequest { + generation, + tab_id: None, + rect: None, + }, + ) + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + loop { + ensure!( + colossus_native_browser::is_active(&controller).await? + && colossus_native_browser::presentation::acceptance_focus_state(&window, false) + .await? + .foreground(), + "overlay acceptance lost the foreground Desktop window" + ); + if view.acceptance_hidden_after(resized_frame).await? { + break; + } + ensure!( + tokio::time::Instant::now() < deadline, + "overlay did not hide the exact native view and clear its pixels" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + println!("PASS Desktop overlay revoked the authenticated native view and cleared pixels"); + controller.eval("document.getElementById('contained-acceptance-overlay')?.remove();")?; + heartbeat.resume(id, rect).await?; + let restored = next_viewport_frame(app, view, &resized).await?; + println!("PASS authenticated page returned after Desktop overlay removal"); + Ok((rect, restored)) +} + +fn visual_hold_duration() -> anyhow::Result { + const NAME: &str = "COLOSSUS_BROWSER_ACCEPTANCE_VISUAL_HOLD_MS"; + let Some(raw) = std::env::var_os(NAME) else { + return Ok(Duration::ZERO); + }; + let text = raw + .to_str() + .context("visual hold must be an ASCII decimal millisecond count")?; + ensure!( + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()), + "{NAME} must be decimal milliseconds from 0 through {MAX_VISUAL_HOLD_MS}" + ); + let milliseconds: u64 = text + .parse() + .context("visual hold milliseconds overflowed")?; + ensure!( + milliseconds <= MAX_VISUAL_HOLD_MS, + "{NAME} must be at most {MAX_VISUAL_HOLD_MS}" + ); + Ok(Duration::from_millis(milliseconds)) +} + +async fn hold_visual_checkpoint( + view: &RemoteSurface, + core_target: &BrowserTarget, + agent_url: &str, + hold: Duration, +) -> anyhow::Result<()> { + if hold.is_zero() { + return Ok(()); + } + println!( + "VISUAL_CHECKPOINT authenticated green agent page submitted to Desktop native presenter; hold_ms={}", + hold.as_millis() + ); + std::io::stdout().flush()?; + let deadline = tokio::time::Instant::now() + hold; + loop { + let probe = view.acceptance_probe().await?; + let frame = probe.frame.context("visual checkpoint lost native frame")?; + ensure!( + probe.visible + && probe.page.target == *core_target + && probe.page.url == agent_url + && probe.page.title == "Agent controlled page" + && !probe.page.loading + && frame.sequence > 0 + && frame.center_bgra == [216, 247, 217, 255], + "visual checkpoint lost the authenticated green agent page" + ); + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + tokio::time::sleep(remaining.min(Duration::from_millis(250))).await; + } + println!("PASS bounded visual checkpoint retained authenticated agent page"); + Ok(()) +} + +#[cfg(target_os = "macos")] +async fn focus(app: &tauri::AppHandle) -> anyhow::Result<()> { + let window = app.get_window("main").context("Desktop window missing")?; + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + window.show()?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let mut attempt = 0_u32; + let mut activation_requests = 0_u8; + loop { + let controller_key = colossus_native_browser::is_active(&controller).await?; + let appkit = + colossus_native_browser::presentation::acceptance_focus_state(&window, false).await?; + if controller_key && appkit.foreground() { + return Ok(()); + } + if attempt.is_multiple_of(20) { + window.set_focus()?; + // AppKit may register a LaunchServices window without activating it. + // This request targets only this fixture's own controller window; + // the loop still waits for independently observed foreground state. + let _ = colossus_native_browser::presentation::acceptance_focus_state(&window, true) + .await?; + activation_requests = activation_requests.saturating_add(1); + } + if tokio::time::Instant::now() >= deadline { + eprintln!( + "EVIDENCE mac-foreground-focus controller_key={controller_key} parent_attached={} own_window={} window_visible={} app_active={} window_key={} app_key_matches={} activation_requests={activation_requests}", + appkit.parent_attached(), + appkit.own_window(), + appkit.window_visible(), + appkit.app_active(), + appkit.window_key(), + appkit.app_key_matches(), + ); + anyhow::bail!("contained browser acceptance requires a foreground Desktop window"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + attempt = attempt.saturating_add(1); + } +} + +fn request(conversation_id: String) -> anyhow::Result { + Ok(CreateRunRequest { + model_profile: None, + reasoning_effort: None, + plugin_skill_ids: Vec::new(), + input: vec![InputContentPart::Text( + "Use the existing handed-off browser page. Observe its button, click it, then navigate to /agent.".into(), + )], + session_id: Some(conversation_id), + end_user_id: None, + role: "primary".into(), + mode: RunMode::Execute, + goal_max_iterations: 0, + research_depth: None, + research_sources: Vec::new(), + plan_action: None, + branch: None, + max_turns: 12, + idempotency_key: IdempotencyKey::new(format!( + "mac-contained-browser-{}", + uuid::Uuid::now_v7() + ))?, + }) +} + +async fn run_complete(client: &Colossus, run_id: &str) -> anyhow::Result<()> { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let run = client + .get_run(GetRunRequest { + run_id: run_id.to_owned(), + }) + .await? + .run; + match run.status { + RunStatus::Completed => return Ok(()), + RunStatus::Failed + | RunStatus::Cancelled + | RunStatus::Interrupted + | RunStatus::OutcomeUnknown => { + anyhow::bail!("Core browser run ended with {:?}", run.status) + } + _ => tokio::time::sleep(Duration::from_millis(100)).await, + } + } + }) + .await + .context("Core browser run did not complete")? +} + +// Each assertion must preserve its relationship to the live native lease. +#[allow(clippy::too_many_lines)] +async fn exercise_owned( + app: &tauri::AppHandle, + client: &Colossus, + fixture: &Fixture, + visual_hold: Duration, + home: &Path, + heartbeat: &mut ViewportHeartbeat, +) -> anyhow::Result<()> { + let state = app.state::(); + state.select_target(Some(SPACE.into())).await; + let snapshot = action( + app, + BrowserAction::New { + conversation_id: None, + url: format!("{}/first", fixture.origin), + }, + ) + .await + .context("stage=desktop-new-native-admission")?; + ensure!( + snapshot.engine.kind + == if cfg!(windows) { + "owned_chromium" + } else { + "contained_chromium" + } + && snapshot.engine.ready + && snapshot.engine.preview != cfg!(windows), + "Desktop did not report the Debug contained Chromium diagnostic" + ); + let tab = snapshot.selected_tab_id.context("contained tab missing")?; + let record = snapshot + .tabs + .iter() + .find(|entry| entry.id == tab) + .context("contained tab record missing")?; + let conversation = record + .conversation_id + .clone() + .context("Core did not bind a canonical conversation")?; + let session = record.session_id.clone(); + let view = remote(app, &tab).context("stage=desktop-remote-surface-lookup")?; + ensure!( + view.session_id() == session && view.conversation_id() == conversation, + "Desktop RemoteSurface did not retain Core's exact native admission" + ); + ensure!( + app.webviews().len() == 1, + "contained page created a Tauri WebView" + ); + focus(app).await.context("stage=desktop-foreground-focus")?; + let rect = bounds(app).context("stage=desktop-viewport-bounds")?; + heartbeat.resume(&tab, rect).await?; + title(&view, "Human fixture") + .await + .context("stage=desktop-human-page-title-and-viewport")?; + let human = accepted_frame( + app, + &tab, + &view, + rect, + ExpectedFrame { + title: "Human fixture", + url: &format!("{}/first", fixture.origin), + center_bgra: [255, 241, 231, 255], + core_target: None, + previous: None, + after: None, + }, + ) + .await + .context("stage=desktop-human-frame-acceptance")?; + ensure!( + human + .frame + .is_some_and(|frame| frame.lease.control_generation == 0), + "human page was not submitted under its native human lease" + ); + println!("PASS authenticated Core human page submitted to Desktop RemoteSurface"); + #[cfg(feature = "windows-browser-development")] + { + let loaded = state + .browser + .snapshot() + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + ensure!( + loaded + .tabs + .iter() + .find(|entry| entry.id == tab) + .is_some_and(|entry| !entry.page.loading), + "a loaded supervised page kept the legacy Windows loading spinner" + ); + println!( + "PASS supervised Windows loading metadata clears the spinner after real navigation" + ); + } + #[cfg(windows)] + windows::human(app, &tab, &view, rect, home, heartbeat).await?; + #[cfg(not(windows))] + let _ = home; + #[cfg(target_os = "macos")] + let (rect, human) = resize_and_occlude(app, &tab, &view, &human, heartbeat) + .await + .context("stage=desktop-retina-resize-overlay-occlusion")?; + + if !visual_hold.is_zero() { + println!( + "HUMAN_VISUAL_CHECKPOINT authenticated human page submitted to Desktop NSView; hold_ms=10000" + ); + std::io::stdout().flush()?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let probe = view.acceptance_probe().await?; + let frame = probe + .frame + .context("human visual checkpoint lost native frame")?; + ensure!( + probe.visible + && probe.page.title == "Human fixture" + && probe.page.url == format!("{}/first", fixture.origin) + && frame.lease.control_generation == 0 + && frame.center_bgra == [255, 241, 231, 255], + "human visual checkpoint lost its authenticated page" + ); + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + tokio::time::sleep(remaining.min(Duration::from_millis(100))).await; + } + } + + if fixture.state.native_input { + native_input::checkpoint(app, &tab, &view, rect, &fixture.origin).await?; + } + let run = client.create_run(request(conversation.clone())?).await?.run; + *fixture.state.run_id.lock().unwrap() = Some(run.run_id.clone()); + fixture + .state + .wait(&fixture.state.model_seen, "Core model request") + .await?; + fixture.state.check()?; + *fixture.state.target.lock().unwrap() = Some(json!({"session_id": session})); + action( + app, + BrowserAction::Handoff { + tab_id: tab.clone(), + run_id: run.run_id.clone(), + }, + ) + .await?; + ensure!( + !view.human_control_available(), + "handoff retained human input" + ); + #[cfg(windows)] + windows::agent_ready(app, &tab, &view, rect).await?; + fixture.state.signal(&fixture.state.handoff_done); + println!("PASS authenticated native human-to-agent handoff"); + + title(&view, "Agent click received").await?; + #[cfg(windows)] + windows::agent_click_indicator(app, &tab, &view, rect, home).await?; + fixture.state.signal(&fixture.state.click_observed); + println!("PASS Core browser.snapshot and browser.click changed the visible page"); + title(&view, "Agent controlled page").await?; + #[cfg(windows)] + println!("EVIDENCE Core navigation title acknowledged; awaiting matching native frame"); + let page = view.inspect().await?; + ensure!( + page.url == format!("{}/agent", fixture.origin), + "Core navigation reached the wrong page: {}", + page.url + ); + let core_target = fixture.state.navigated_target().await?; + let probe = accepted_frame( + app, + &tab, + &view, + rect, + ExpectedFrame { + title: "Agent controlled page", + url: &format!("{}/agent", fixture.origin), + center_bgra: [216, 247, 217, 255], + core_target: Some(&core_target), + previous: Some(&human), + after: None, + }, + ) + .await?; + let agent_frame = probe.frame.context("accepted agent frame missing")?; + println!( + "PASS Core browser.navigate submitted authenticated agent-page BGRA to Desktop {}: document_generation={} sequence={} center_bgra={:?}", + if cfg!(windows) { "HWND" } else { "NSView" }, + agent_frame.lease.document_generation, + agent_frame.sequence, + agent_frame.center_bgra, + ); + fixture.state.signal(&fixture.state.navigation_observed); + #[cfg(windows)] + windows::agent(app, &tab, &view, rect, home).await?; + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + loop { + heartbeat.check()?; + fixture.state.check()?; + if fixture.state.screenshot_seen.load(Ordering::Acquire) { + break; + } + ensure!( + tokio::time::Instant::now() < deadline, + "Core browser.screenshot did not publish its verified PNG" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + println!("PASS Core browser.screenshot published verified PNG artifact metadata"); + // Screenshot capture intentionally retires the presentation lease. Its + // published PNG is not evidence that a new frame has reached the NSView. + // Keep the independent trusted heartbeat active and require a subsequent + // exact-document native acknowledgement before exposing the visual marker. + let recovered = accepted_frame( + app, + &tab, + &view, + rect, + ExpectedFrame { + title: "Agent controlled page", + url: &format!("{}/agent", fixture.origin), + center_bgra: [216, 247, 217, 255], + core_target: Some(&core_target), + previous: None, + after: Some(agent_frame), + }, + ) + .await + .context("stage=agent-frame-recovery-after-screenshot")?; + let recovered_frame = recovered.frame.context("recovered agent frame missing")?; + println!( + "PASS authenticated agent frame recovered after Core screenshot: document_generation={} viewport_generation={} sequence={}", + recovered_frame.lease.document_generation, + recovered_frame.lease.viewport_generation, + recovered_frame.sequence + ); + hold_visual_checkpoint( + &view, + &core_target, + &format!("{}/agent", fixture.origin), + visual_hold, + ) + .await?; + if fixture.state.native_tabs { + owned_tabs::checkpoint(app, &tab, &view, rect, fixture).await?; + } + heartbeat.pause().await?; + if close_after_completion() { + ensure!( + !fixture.state.native_tabs, + "natural-completion fixture uses its original single tab" + ); + fixture.state.signal(&fixture.state.viewer_detached); + run_complete(client, &run.run_id).await?; + #[cfg(feature = "windows-browser-development")] + state + .browser + .watch_agent(app, SPACE, &run.run_id, &conversation) + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + #[cfg(feature = "windows-browser-development")] + ensure!( + state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .tabs + .is_empty(), + "a completed run left its viewer marked as a crashed tab" + ); + } + if !close_after_completion() { + #[cfg(windows)] + view.acceptance_disconnect_presentation(); + #[cfg(feature = "windows-browser-development")] + { + // The normal context reconciliation must replace a failed viewer + // without opening a browser or granting another agent writer. + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + loop { + let failed = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .tabs + .iter() + .any(|entry| { + entry.dto.id == tab + && entry.dto.error.as_deref() + == Some(super::super::registry::CRASHED_MESSAGE) + }); + if failed { + break; + } + ensure!( + tokio::time::Instant::now() < deadline, + "lost viewer channel did not signal its failure" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + state + .browser + .watch_agent(app, SPACE, &run.run_id, &conversation) + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + let replacement = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .selected + .get(SPACE) + .cloned() + .context("recovered viewer is missing")?; + ensure!( + replacement != tab, + "failed viewer was reused instead of freshly authenticated" + ); + let recovered = remote(app, &replacement)?; + ensure!( + recovered.session_id() == session && !recovered.human_control_available(), + "viewer recovery changed the browser or granted human control" + ); + heartbeat.resume(&replacement, rect).await?; + let color = if fixture.state.native_tabs { + [255, 255, 255, 255] + } else { + [216, 247, 217, 255] + }; + windows::selected_tab(app, &replacement, &recovered, rect, color).await?; + heartbeat.stop().await?; + println!( + "PASS normal context recovery freshly authenticated the same Core browser and displayed native pixels after viewer channel loss" + ); + action( + app, + BrowserAction::Close { + tab_id: replacement, + }, + ) + .await?; + } + #[cfg(not(feature = "windows-browser-development"))] + action(app, BrowserAction::Close { tab_id: tab }).await?; + } + #[cfg(not(feature = "windows-browser-development"))] + if close_after_completion() { + action(app, BrowserAction::Close { tab_id: tab }).await?; + } + heartbeat.stop().await?; + #[cfg(windows)] + println!("PASS lost viewer stream reconciled through separate authenticated native detach"); + ensure!( + state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .tabs + .is_empty(), + "viewer detach left a Desktop tab" + ); + ensure!( + app.webviews().len() == 1, + "viewer detach left an unmanaged WebView" + ); + fixture.state.signal(&fixture.state.viewer_detached); + run_complete(client, &run.run_id).await?; + if close_after_completion() { + #[cfg(feature = "windows-browser-development")] + println!( + "PASS completed Core run automatically retired its viewer through acknowledged detach without a crashed tab" + ); + #[cfg(not(feature = "windows-browser-development"))] + println!( + "PASS completed Core run retired its viewer; later native close reconciled the positive cleanup receipt" + ); + } + fixture.state.check()?; + println!("PASS viewer detachment, Core run completion, and native browser close"); + Ok(()) +} + +pub(super) async fn exercise( + app: &tauri::AppHandle, + address: &str, + home: &Path, +) -> anyhow::Result<()> { + let visual_hold = visual_hold_duration()?; + let fixture = Fixture::bind(address, visual_hold).await?; + println!( + "EVIDENCE owned {} diagnostic origin {}", + if cfg!(windows) { "Windows" } else { "macOS" }, + fixture.origin + ); + let (client, native) = start_sidecar(home, &fixture.origin, visual_hold).await?; + let state = app.state::(); + state + .configure_managed_browser_for(SPACE, Some(native)) + .await; + state + .replace_target( + SPACE, + client.clone(), + crate::state::TargetConsentContext::ManagedLocal, + ) + .await; + let mut heartbeat = ViewportHeartbeat::start(app.clone()); + let exercise = exercise_owned(app, &client, &fixture, visual_hold, home, &mut heartbeat).await; + // Settle every trusted pulse before cancellation, native hide or viewer drain, + // including when an assertion returns early. No background show can revive it. + let heartbeat_exit = heartbeat.stop().await; + let exercise = exercise.and(heartbeat_exit); + let failed_run_id = if exercise.is_err() { + fixture.state.run_id.lock().unwrap().take() + } else { + None + }; + if let Some(run_id) = failed_run_id { + let _ = client + .cancel_run(CancelRunRequest { + run_id, + idempotency_key: IdempotencyKey::new(format!( + "mac-contained-cancel-{}", + uuid::Uuid::now_v7() + ))?, + }) + .await; + } + // These operations run even after a failed assertion. The sidecar owns the + // direct-child shutdown receipt; no helper-tree cleanup is inferred here. + let drain = state.browser.close_all_settled().await; + let close = client.close().await; + state.configure_managed_browser_for(SPACE, None).await; + state.remove_target(SPACE).await; + state.select_target(None).await; + if exercise.is_err() { + #[cfg(windows)] + if let Err(error) = &exercise { + eprintln!( + "EVIDENCE original Windows acceptance assertion before fixture cleanup: {error:#}" + ); + } + eprintln!( + "EVIDENCE failed contained diagnostic cleanup ViewerDrain={} SDKClose={}", + if drain.is_ok() { "ok" } else { "unknown" }, + if close.is_ok() { + "verifiedGracefulDirectChild" + } else { + "unknown" + } + ); + } + fixture.state.check()?; + exercise?; + drain.map_err(|error| anyhow::anyhow!(error.message))?; + close?; + println!( + "PASS verified sidecar exit required GracefulDirectChildExit; Desktop native viewer shut down" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + FixtureState, categorical_browser_failure, categorical_tool_failure, grant, + managed_runtime, model_response, same_document_frame_after, + }; + use colossus_sdk::SidecarBootstrapConfig; + use std::time::Duration; + + #[test] + fn tool_failure_trace_releases_only_a_fixed_category() { + assert_eq!( + categorical_tool_failure( + &serde_json::json!({"error":{"code":"tool.outcome_unknown","message":"private-page-and-secret"}}) + ), + "tool.outcome_unknown" + ); + assert_eq!( + categorical_tool_failure( + &serde_json::json!({"error":{"type":"execution_error","message":"private-page-and-secret"}}) + ), + "tool.execution_error" + ); + assert_eq!( + categorical_tool_failure( + &serde_json::json!({"error":{"code":"private-page-and-secret","type":"private-page-and-secret"}}) + ), + "tool.unclassified" + ); + assert_eq!( + categorical_browser_failure( + &serde_json::json!({"error":{"message":"browser requires a fresh snapshot"}}) + ), + "snapshot_required" + ); + assert_eq!( + categorical_browser_failure( + &serde_json::json!({"error":{"message":"private-page-and-secret"}}) + ), + "unclassified" + ); + } + + #[test] + fn screenshot_recovery_requires_new_ack_for_the_same_document_and_controller() { + use colossus_native_browser::remote::RemoteAcceptedFrame; + let prior = RemoteAcceptedFrame { + lease: serde_json::from_value(serde_json::json!({"tab":3,"session_generation":1,"control_generation":2,"viewport_generation":8,"document_generation":5,"pixel_width":2004,"pixel_height":1280})).unwrap(), + sequence: 16, + center_bgra: [216,247,217,255], + count: 19, + }; + assert!(!same_document_frame_after(prior, prior)); + let fresh = RemoteAcceptedFrame { + sequence: 17, + count: 20, + ..prior + }; + assert!(same_document_frame_after(fresh, prior)); + let mut changed = fresh; + changed.lease.viewport_generation += 1; + changed.sequence = 1; + changed.count = 1; + assert!(same_document_frame_after(changed, prior)); + for field in 0..5 { + let mut foreign = changed; + match field { + 0 => foreign.lease.tab += 1, + 1 => foreign.lease.session_generation += 1, + 2 => foreign.lease.control_generation += 1, + 3 => foreign.lease.document_generation += 1, + _ => foreign.lease.viewport_generation = 7, + } + assert!(!same_document_frame_after(foreign, prior)); + } + } + + #[tokio::test] + async fn fixture_observes_fresh_document_after_click_before_navigation() { + use serde_json::{Value, json}; + let state = FixtureState::default(); + *state.target.lock().unwrap() = Some( + json!({"session_id":"session-owned", "control_generation":7, "tab_id":"tab-old", "document_id":"doc-old"}), + ); + let body = |call_id: &str, observation: Value| { + json!({ + "tools":[{"function":{"name":"browser_snapshot"}}, {"function":{"name":"browser_navigate"}}], + "messages":[{"role":"tool", "tool_call_id":call_id, "content":observation.to_string()}] + }) + }; + let clicked = body( + "fixture-2", + json!({"tab":{"tab_id":"tab-owned", "document_id":"doc-clicked"}}), + ); + let observed = model_response(3, &clicked, "http://127.0.0.1:12345", &state) + .await + .unwrap(); + let function = &observed["choices"][0]["message"]["tool_calls"][0]["function"]; + assert_eq!(function["name"], "browser_snapshot"); + let args: Value = serde_json::from_str(function["arguments"].as_str().unwrap()).unwrap(); + assert_eq!(args["tab_id"], "tab-owned"); + assert_eq!(args["document_id"], "doc-clicked"); + assert_eq!(args["max_nodes"], 128); + assert!(args.get("url").is_none()); + let fresh = body( + "fixture-3", + json!({"tab":{"tab_id":"tab-owned", "document_id":"doc-fresh"}, "snapshot":{"snapshot_id":"snapshot-fresh"}}), + ); + let navigate = model_response(4, &fresh, "http://127.0.0.1:12345", &state) + .await + .unwrap(); + let function = &navigate["choices"][0]["message"]["tool_calls"][0]["function"]; + assert_eq!(function["name"], "browser_navigate"); + let args: Value = serde_json::from_str(function["arguments"].as_str().unwrap()).unwrap(); + assert_eq!(args["session_id"], "session-owned"); + assert_eq!(args["control_generation"], 7); + assert_eq!(args["tab_id"], "tab-owned"); + assert_eq!(args["document_id"], "doc-fresh"); + assert_eq!(args["url"], "http://127.0.0.1:12345/agent"); + } + + #[test] + fn contained_fixture_bootstrap_passes_protocol_validation() { + let origin = "http://127.0.0.1:51813"; + let runtime = managed_runtime(origin, Duration::ZERO); + runtime.validate().expect("exact fixture runtime protocol"); + SidecarBootstrapConfig::new( + std::env::temp_dir(), + runtime, + grant().expect("fixture grant"), + ) + .expect("SDK must admit the fixed fixture configuration"); + + let mut malformed = managed_runtime(origin, Duration::ZERO); + malformed.providers[0].base_url = Some("http://fixture.example/v1".into()); + assert!(malformed.validate().is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/native_input.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/native_input.rs new file mode 100644 index 000000000..b15678b75 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/native_input.rs @@ -0,0 +1,88 @@ +//! Operator keyboard/Unicode commit checkpoint through the actual native `NSView`. +use super::*; + +pub(super) fn enabled() -> anyhow::Result { + parse_flag(std::env::var_os("COLOSSUS_BROWSER_ACCEPTANCE_NATIVE_INPUT").as_deref()) +} +fn parse_flag(value: Option<&std::ffi::OsStr>) -> anyhow::Result { + match value.and_then(std::ffi::OsStr::to_str) { + None if value.is_none() => Ok(false), + Some("1") => Ok(true), + _ => anyhow::bail!("native input checkpoint requires the exact opt-in value 1"), + } +} + +pub(super) async fn checkpoint( + app: &tauri::AppHandle, + id: &str, + view: &RemoteSurface, + rect: BrowserRect, + origin: &str, +) -> anyhow::Result<()> { + println!( + "HUMAN_INPUT_CHECKPOINT click Chromium input, type Mac input café😀x, then press Backspace; timeout_ms=90000" + ); + std::io::stdout().flush()?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + let initial = view.acceptance_probe().await?.page.target.tab_id; + loop { + let probe = view.acceptance_probe().await?; + ensure!( + view.human_control_available() + && probe.page.target.tab_id == initial + && probe.page.url == format!("{origin}/first"), + "native human input checkpoint lost its exact owned human page" + ); + if let Some(frame) = probe.frame { + ensure!( + frame.lease.control_generation == 0, + "native human input checkpoint acquired an agent writer" + ); + } + if probe.page.title == "Human input complete" { + let accepted = accepted_frame( + app, + id, + view, + rect, + ExpectedFrame { + title: "Human input complete", + url: &format!("{origin}/first"), + center_bgra: [255, 241, 231, 255], + core_target: None, + previous: None, + after: None, + }, + ) + .await?; + ensure!( + accepted + .frame + .is_some_and(|frame| frame.lease.control_generation == 0), + "native human input lacked a fresh authenticated human-frame acknowledgement" + ); + println!( + "PASS native Mac keyboard Backspace and Unicode commit reached Chromium; OS IME preedit remains untested" + ); + return Ok(()); + } + ensure!( + tokio::time::Instant::now() < deadline, + "operator native keyboard/Unicode input was not acknowledged within its deadline" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn native_input_checkpoint_has_only_one_explicit_opt_in() { + assert!(!parse_flag(None).unwrap()); + assert!(parse_flag(Some(std::ffi::OsStr::new("1"))).unwrap()); + for value in ["", "true", "0", "2", "inject"] { + assert!(parse_flag(Some(std::ffi::OsStr::new(value))).is_err()); + } + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/owned_tabs.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/owned_tabs.rs new file mode 100644 index 000000000..9b9f4b151 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/owned_tabs.rs @@ -0,0 +1,254 @@ +//! Exact Core-selected tabs presented through the existing native compositor. +use super::*; + +pub(super) const PAGE: &[u8] = b"Core owned second tab

Core owned second tab

Core opened and selected this exact owned tab.

"; +pub(super) fn enabled() -> anyhow::Result { + match std::env::var_os("COLOSSUS_BROWSER_ACCEPTANCE_OWNED_TABS") { + None => Ok(false), + Some(value) if value == "1" => Ok(true), + _ => anyhow::bail!("owned-tab checkpoint requires the exact opt-in value 1"), + } +} + +#[cfg(test)] +#[path = "owned_tabs_tests.rs"] +mod tests; +async fn acknowledged( + state: &FixtureState, + ordinal: usize, + target: BrowserTarget, +) -> anyhow::Result<()> { + *state.tab_event.lock().unwrap() = Some((ordinal, target)); + state.changed.notify_waiters(); + tokio::time::timeout(state.model_timeout, async { + while state.tabs_ack.load(Ordering::Acquire) < ordinal { + let notified = state.changed.notified(); + state.check()?; + if state.tabs_ack.load(Ordering::Acquire) < ordinal { + notified.await; + } + } + Ok::<(), anyhow::Error>(()) + }) + .await + .context("Core selected tab lacked a native presentation acknowledgement")? +} +pub(super) async fn response( + phase: usize, + body: &Value, + origin: &str, + state: &FixtureState, +) -> anyhow::Result { + let scope = state + .target + .lock() + .unwrap() + .clone() + .context("owned-tab scope absent")?; + let base = + json!({"session_id":scope["session_id"],"control_generation":scope["control_generation"]}); + match phase { + 6 => { + state + .wait(&state.tabs_begin, "owned Core tab acceptance") + .await?; + let mut args = base; + args["url"] = json!(format!("{origin}/tab-second")); + Ok(model_tool(phase, &alias(body, "browser.tab.open")?, &args)) + } + 7 => { + let opened = tool_result(body, "fixture-6")?; + let target = browser_target(&opened["tab"])?; + let original = state + .navigated_target + .lock() + .unwrap() + .clone() + .context("original owned tab absent")?; + ensure!( + opened["session_id"] == scope["session_id"] && target.tab_id != original.tab_id, + "Core did not open a distinct owned tab in the exact session" + ); + *state.second_tab.lock().unwrap() = Some(target.clone()); + acknowledged(state, 1, target).await?; + let mut args = base; + args["tab_id"] = json!(original.tab_id); + Ok(model_tool( + phase, + &alias(body, "browser.tab.select")?, + &args, + )) + } + 8 => { + let selected = tool_result(body, "fixture-7")?; + let target = browser_target(&selected["tab"])?; + ensure!( + Some(&target) == state.navigated_target.lock().unwrap().as_ref() + && selected["session_id"] == scope["session_id"], + "Core selected a different original tab" + ); + acknowledged(state, 2, target.clone()).await?; + let mut args = base; + args["tab_id"] = json!(target.tab_id); + Ok(model_tool(phase, &alias(body, "browser.tab.close")?, &args)) + } + 9 => { + let closed = tool_result(body, "fixture-8")?; + ensure!( + browser_target(&closed["tab"])? + == state + .navigated_target + .lock() + .unwrap() + .clone() + .context("closed original tab absent")? + && closed["session_id"] == scope["session_id"], + "Core close acknowledged the wrong tab" + ); + Ok(model_tool( + phase, + &alias(body, "browser.tabs")?, + &json!({"session_id":scope["session_id"]}), + )) + } + 10 | 11 => finish(phase, body, state, &scope, base).await, + _ => anyhow::bail!("unreviewed owned-tab fixture phase"), + } +} +pub(super) async fn checkpoint( + app: &tauri::AppHandle, + id: &str, + view: &RemoteSurface, + rect: BrowserRect, + fixture: &Fixture, +) -> anyhow::Result<()> { + fixture.state.signal(&fixture.state.tabs_begin); + let mut previous = view.acceptance_probe().await?; + let initial = previous + .frame + .context("owned tabs lacked original agent frame")? + .lease; + for ordinal in 1..=3 { + let target = tokio::time::timeout(PAGE_TIMEOUT, async { + loop { + let notified = fixture.state.changed.notified(); + fixture.state.check()?; + if let Some((actual, target)) = fixture.state.tab_event.lock().unwrap().as_ref() + && *actual == ordinal + { + return Ok::(target.clone()); + } + notified.await; + } + }) + .await + .context("Core owned-tab result absent")??; + let second = ordinal != 2; + let path = if second { "/tab-second" } else { "/agent" }; + let probe = accepted_frame( + app, + id, + view, + rect, + ExpectedFrame { + title: if second { + "Core owned second tab" + } else { + "Agent controlled page" + }, + url: &format!("{}{path}", fixture.origin), + center_bgra: if second { + [255, 255, 255, 255] + } else { + [216, 247, 217, 255] + }, + core_target: Some(&target), + previous: None, + after: None, + }, + ) + .await?; + let frame = probe + .frame + .context("selected Core tab lacked native frame")? + .lease; + let old = previous + .frame + .context("previous selected Core tab frame absent")? + .lease; + ensure!( + frame.tab != old.tab + && frame.control_generation == initial.control_generation + && frame.session_generation == initial.session_generation + && frame.viewport_generation > old.viewport_generation + && !view.human_control_available(), + "owned-tab retarget changed writer/session or reused its prior viewport" + ); + #[cfg(windows)] + windows::selected_tab( + app, + id, + view, + rect, + if second { + [255, 255, 255, 255] + } else { + [216, 247, 217, 255] + }, + ) + .await?; + println!( + "PASS Core owned tab checkpoint ordinal={ordinal} changed native tab under the same writer with a fresh authenticated frame" + ); + previous = probe; + fixture.state.tabs_ack.store(ordinal, Ordering::Release); + fixture.state.changed.notify_waiters(); + } + Ok(()) +} + +async fn finish( + phase: usize, + body: &Value, + state: &FixtureState, + scope: &Value, + base: Value, +) -> anyhow::Result { + match phase { + 10 => { + let tabs = tool_result(body, "fixture-9")?; + let list = tabs["tabs"].as_array().context("Core tab list absent")?; + ensure!( + list.len() == 1 + && tabs["session_id"] == scope["session_id"] + && tabs["control_generation"] == scope["control_generation"], + "Core retained an unexpected tab or writer epoch" + ); + let target = browser_target(&list[0])?; + ensure!( + Some(&target) == state.second_tab.lock().unwrap().as_ref() + && tabs["selected_tab_id"] == list[0]["tab_id"], + "Core selected a foreign or closed tab" + ); + acknowledged(state, 3, target).await?; + state + .wait( + &state.viewer_detached, + "native viewer detachment after owned tabs", + ) + .await?; + Ok(model_tool(phase, &alias(body, "browser.close")?, &base)) + } + 11 => { + let closed = tool_result(body, "fixture-10")?; + ensure!( + closed["closed"] == true && closed["session_id"] == scope["session_id"], + "Core did not close the exact owned tab session" + ); + Ok( + json!({"id":"fixture-complete","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"Mac contained browser tabs diagnostic complete."},"finish_reason":"stop"}]}), + ) + } + _ => anyhow::bail!("unreviewed owned-tab close phase"), + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/owned_tabs_tests.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/owned_tabs_tests.rs new file mode 100644 index 000000000..63b9a61c7 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/owned_tabs_tests.rs @@ -0,0 +1,116 @@ +use super::*; +fn target(value: char) -> BrowserTarget { + BrowserTarget { + tab_id: BrowserTabId::parse(format!("bt_{}", value.to_string().repeat(32))).unwrap(), + document_id: BrowserDocumentId::parse(format!("bd_{}", value.to_string().repeat(32))) + .unwrap(), + } +} +fn state() -> FixtureState { + FixtureState { + native_tabs: true, + model_timeout: Duration::from_millis(100), + tabs_begin: AtomicBool::new(true), + tabs_ack: AtomicUsize::new(3), + viewer_detached: AtomicBool::new(true), + target: Mutex::new(Some( + json!({"session_id":format!("bs_{}", "1".repeat(32)),"control_generation":1}), + )), + navigated_target: Mutex::new(Some(target('1'))), + ..FixtureState::default() + } +} +fn result(state: &FixtureState, target: &BrowserTarget) -> Value { + json!({"session_id":state.target.lock().unwrap().as_ref().unwrap()["session_id"],"tab":target}) +} +fn body(call: &str, result: &Value) -> Value { + let tools = [ + "browser.tab.open", + "browser.tab.select", + "browser.tab.close", + "browser.tabs", + "browser.close", + ] + .map(|name| json!({"function":{"name":name.replace('.',"_")}})); + json!({"tools":tools, + "messages":[{"role":"tool","tool_call_id":call,"content":result.to_string()}]}) +} +#[tokio::test] +async fn native_tab_transcript_preserves_exact_owned_session_and_selected_targets() { + let state = state(); + let second = target('2'); + let session = state.target.lock().unwrap().as_ref().unwrap()["session_id"].clone(); + let mut transcript = Vec::new(); + for (phase, call, value) in [ + (6, "", json!({})), + (7, "fixture-6", result(&state, &second)), + (8, "fixture-7", result(&state, &target('1'))), + (9, "fixture-8", result(&state, &target('1'))), + ( + 10, + "fixture-9", + json!({"session_id":session,"control_generation":1,"tabs":[second],"selected_tab_id":second.tab_id}), + ), + ] { + let response = response(phase, &body(call, &value), "http://127.0.0.1:12345", &state) + .await + .unwrap(); + let tool = &response["choices"][0]["message"]["tool_calls"][0]["function"]; + transcript.push(tool["name"].as_str().unwrap().to_owned()); + } + assert_eq!( + transcript, + [ + "browser_tab_open", + "browser_tab_select", + "browser_tab_close", + "browser_tabs", + "browser_close" + ] + ); + assert_eq!( + state.tab_event.lock().unwrap().as_ref().unwrap(), + &(3, second) + ); +} +#[tokio::test] +async fn native_tab_transcript_rejects_duplicated_foreign_closed_or_changed_writer_receipts() { + let state = state(); + assert!( + response( + 7, + &body("fixture-6", &result(&state, &target('1'))), + "http://127.0.0.1:12345", + &state + ) + .await + .is_err() + ); + let second = target('2'); + let mut foreign = result(&state, &second); + foreign["session_id"] = json!(format!("bs_{}", "9".repeat(32))); + assert!( + response( + 7, + &body("fixture-6", &foreign), + "http://127.0.0.1:12345", + &state + ) + .await + .is_err() + ); + *state.second_tab.lock().unwrap() = Some(second.clone()); + for (tab, generation) in [(target('1'), 1), (second, 2)] { + let list = json!({"session_id":state.target.lock().unwrap().as_ref().unwrap()["session_id"],"control_generation":generation,"tabs":[tab],"selected_tab_id":tab.tab_id}); + assert!( + response( + 10, + &body("fixture-9", &list), + "http://127.0.0.1:12345", + &state + ) + .await + .is_err() + ); + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/viewport_heartbeat.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/viewport_heartbeat.rs new file mode 100644 index 000000000..532a0281d --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/viewport_heartbeat.rs @@ -0,0 +1,218 @@ +//! A fixture observer must not stall the independent trusted viewport heartbeat. + +use super::{BrowserRect, present}; +use anyhow::{Context as _, ensure}; +use std::{future::Future, sync::Arc, time::Duration}; +use tokio::{sync::Mutex, task::JoinHandle}; + +const PERIOD: Duration = Duration::from_millis(100); + +#[derive(Clone)] +struct Pulse { + tab: String, + rect: BrowserRect, +} + +enum Mode { + Paused, + Visible(Pulse), + Stopped, +} + +pub(super) struct ViewportHeartbeat { + mode: Arc>, + task: Option>>, +} + +impl ViewportHeartbeat { + pub(super) fn start(app: tauri::AppHandle) -> Self { + Self::start_with(move |pulse| { + let app = app.clone(); + async move { present(&app, &pulse.tab, pulse.rect).await } + }) + } + + fn start_with(renew: F) -> Self + where + F: Fn(Pulse) -> Fut + Send + 'static, + Fut: Future> + Send, + { + let mode = Arc::new(Mutex::new(Mode::Paused)); + let owned = mode.clone(); + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(PERIOD); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let mode = owned.lock().await; + match &*mode { + Mode::Paused => {} + Mode::Stopped => return Ok(()), + Mode::Visible(pulse) => { + // Pause/stop wait for this complete native acknowledgement. + // Holding an async mutex also fences queued main-thread show. + renew(pulse.clone()).await?; + } + } + } + }); + Self { + mode, + task: Some(task), + } + } + + pub(super) fn check(&self) -> anyhow::Result<()> { + ensure!( + self.task.as_ref().is_some_and(|task| !task.is_finished()), + "trusted fixture viewport heartbeat ended before shutdown" + ); + Ok(()) + } + + pub(super) async fn resume(&self, tab: &str, rect: BrowserRect) -> anyhow::Result<()> { + self.check()?; + let mut mode = self.mode.lock().await; + ensure!( + !matches!(*mode, Mode::Stopped), + "viewport heartbeat already stopped" + ); + *mode = Mode::Visible(Pulse { + tab: tab.to_owned(), + rect, + }); + drop(mode); + self.check() + } + + pub(super) async fn pause(&self) -> anyhow::Result<()> { + self.check()?; + // Await the previous pulse before publishing the pause; callers hide only + // after this returns. Subsequent ticks observe Paused and cannot show. + *self.mode.lock().await = Mode::Paused; + self.check() + } + + pub(super) async fn stop(&mut self) -> anyhow::Result<()> { + if self.task.is_none() { + return Ok(()); + } + *self.mode.lock().await = Mode::Stopped; + let result = self.task.as_mut().expect("retained heartbeat task").await; + self.task.take(); + result.context("viewport heartbeat task did not settle")? + } +} + +impl Drop for ViewportHeartbeat { + fn drop(&mut self) { + // Unexpected outer task cancellation drops the native acknowledgement + // receiver, which the real compositor checks before any queued show. + // Normal and assertion-failure paths explicitly stop and await instead. + if let Some(task) = &self.task { + task.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::Notify; + + fn rect(width: f64) -> BrowserRect { + BrowserRect { + x: 24.0, + y: 96.0, + width, + height: 300.0, + } + } + + #[tokio::test] + async fn blocked_observer_does_not_stall_trusted_viewport_pulses() { + let count = Arc::new(AtomicUsize::new(0)); + let observed = count.clone(); + let mut heartbeat = ViewportHeartbeat::start_with(move |_| { + observed.fetch_add(1, Ordering::AcqRel); + std::future::ready(Ok(())) + }); + heartbeat.resume("tab", rect(400.0)).await.unwrap(); + // Longer than the unchanged native 1.5s lease. The observer has not + // returned, while its independent viewport owner must keep renewing. + tokio::time::sleep(Duration::from_millis(1700)).await; + assert!(count.load(Ordering::Acquire) >= 8); + tokio::time::timeout(Duration::from_secs(2), heartbeat.stop()) + .await + .unwrap() + .unwrap(); + let stopped = count.load(Ordering::Acquire); + tokio::time::sleep(PERIOD * 2).await; + assert_eq!(count.load(Ordering::Acquire), stopped); + } + + #[tokio::test] + async fn pause_and_stop_wait_for_native_ack_and_prevent_any_late_show() { + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let count = Arc::new(AtomicUsize::new(0)); + let did_enter = entered.clone(); + let did_release = release.clone(); + let observed = count.clone(); + let heartbeat = Arc::new(ViewportHeartbeat::start_with(move |_| { + let entered = did_enter.clone(); + let release = did_release.clone(); + let count = observed.clone(); + async move { + count.fetch_add(1, Ordering::AcqRel); + entered.notify_one(); + release.notified().await; + Ok(()) + } + })); + heartbeat.resume("tab", rect(400.0)).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), entered.notified()) + .await + .unwrap(); + let pausing = heartbeat.clone(); + let pause = tokio::spawn(async move { pausing.pause().await }); + tokio::time::sleep(PERIOD).await; + assert!( + !pause.is_finished(), + "pause returned before native show acknowledged" + ); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(2), pause) + .await + .unwrap() + .unwrap() + .unwrap(); + let paused = count.load(Ordering::Acquire); + tokio::time::sleep(PERIOD * 2).await; + assert_eq!(count.load(Ordering::Acquire), paused); + let mut heartbeat = Arc::try_unwrap(heartbeat).ok().unwrap(); + heartbeat.resume("tab", rect(500.0)).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), entered.notified()) + .await + .unwrap(); + let stopping = tokio::spawn(async move { + heartbeat.stop().await.unwrap(); + heartbeat + }); + tokio::time::sleep(PERIOD).await; + assert!( + !stopping.is_finished(), + "stop returned before native show acknowledged" + ); + release.notify_one(); + let heartbeat = tokio::time::timeout(Duration::from_secs(2), stopping) + .await + .unwrap() + .unwrap(); + assert!(heartbeat.resume("tab", rect(500.0)).await.is_err()); + let stopped = count.load(Ordering::Acquire); + tokio::time::sleep(PERIOD * 2).await; + assert_eq!(count.load(Ordering::Acquire), stopped); + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/native_input.html b/apps/desktop/src-tauri/src/browser/acceptance/native_input.html new file mode 100644 index 000000000..cd744b09d --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/native_input.html @@ -0,0 +1,50 @@ + + + + + Human fixture + + +

Human fixture

+

+ Click the input, type Mac input café😀x, then press + Backspace. +

+ + +

Waiting for human native input

+ + Waiting for Core + + + diff --git a/apps/desktop/src-tauri/src/browser/acceptance/pki.rs b/apps/desktop/src-tauri/src/browser/acceptance/pki.rs new file mode 100644 index 000000000..aed4d372d --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/pki.rs @@ -0,0 +1,283 @@ +//! Opt-in real Chromium TLS/key-use evidence using operator-provisioned unique fixtures. +//! This module never installs/deletes OS certificates or reads a private key/password. + +use std::{collections::BTreeMap, path::PathBuf, time::Duration}; + +use colossus_native_browser::{BrowserView, chromium::Surface}; +use serde::Deserialize; +use tauri::Manager as _; + +use crate::{browser::dto::BrowserAction, state::AppState}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Fixture { + schema_version: u16, + fixture_id: String, + fingerprints_sha256: BTreeMap, + ca_file: String, + identity_file: String, + alternate_identity_file: String, + passphrase_file: String, + changes_os_trust: bool, + production_acceptance: bool, + urls: BTreeMap, +} + +impl Fixture { + fn read(path: PathBuf) -> anyhow::Result { + anyhow::ensure!(path.is_absolute(), "PKI fixture manifest must be absolute"); + let bytes = crate::desktop_settings::read_ca_bundle_source(&path) + .map_err(|_| anyhow::anyhow!("PKI fixture manifest could not be opened"))?; + anyhow::ensure!( + bytes.len() <= 16 * 1024, + "PKI fixture manifest exceeded bounds" + ); + let fixture: Self = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + fixture.schema_version == 1 + && uuid::Uuid::parse_str(&fixture.fixture_id).is_ok() + && fixture.ca_file == "ca.der" + && fixture.identity_file == "client.pfx" + && fixture.alternate_identity_file == "alternate_client.pfx" + && fixture.passphrase_file == "passphrase.txt" + && !fixture.changes_os_trust + && !fixture.production_acceptance + && fixture.urls.len() == 6 + && fixture.fingerprints_sha256.len() == 10, + "PKI fixture metadata was invalid" + ); + for fingerprint in fixture.fingerprints_sha256.values() { + anyhow::ensure!( + fingerprint.len() == 64 + && fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "PKI fixture fingerprint was invalid" + ); + } + for name in [ + "public", + "mtls", + "redirect_mtls", + "untrusted", + "wrong_hostname", + "expired", + ] { + let origin = fixture.origin(name)?; + let url = tauri::Url::parse(origin)?; + anyhow::ensure!( + url.scheme() == "https" + && url.host_str() == Some("127.0.0.1") + && url.port().is_some() + && url.origin().ascii_serialization() == origin, + "PKI fixture origin was invalid" + ); + } + Ok(fixture) + } + + fn origin(&self, name: &str) -> anyhow::Result<&str> { + self.urls + .get(name) + .map(String::as_str) + .ok_or_else(|| anyhow::anyhow!("PKI fixture origin missing")) + } + + fn fingerprint(&self, name: &str) -> anyhow::Result<&str> { + self.fingerprints_sha256 + .get(name) + .map(String::as_str) + .ok_or_else(|| anyhow::anyhow!("PKI fixture fingerprint missing")) + } +} + +pub(super) async fn exercise_if_configured(app: &tauri::AppHandle) -> anyhow::Result<()> { + let Some(path) = std::env::var_os("COLOSSUS_BROWSER_PKI_FIXTURE") else { + return Ok(()); + }; + let fixture = Fixture::read(PathBuf::from(path))?; + let state = app.state::(); + state + .select_target(Some("chromium-pki-acceptance".into())) + .await; + let (_, trusted) = open(app, &format!("{}/public", fixture.origin("public")?)).await?; + wait_title(&trusted, "Colossus private CA verified").await?; + println!("PASS native Chromium validates operator-provisioned fixture CA"); + for name in ["untrusted", "wrong_hostname", "expired"] { + let (id, view) = open(app, &format!("{}/public", fixture.origin(name)?)).await?; + wait_denied(app, &id, &view).await?; + } + println!("PASS native Chromium rejects unknown CA wrong hostname and expired server"); + + let origin = fixture.origin("mtls")?; + let url = format!("{origin}/client-check"); + let (id, missing) = open(app, &url).await?; + let review = wait_review(&missing, origin).await?; + // Unknown selections must fail before consuming the exact native handshake. + anyhow::ensure!( + missing + .select_client_identity(&review, Some(&"0".repeat(64))) + .await + .is_err(), + "unknown certificate fingerprint was accepted" + ); + missing.select_client_identity(&review, None).await?; + wait_denied(app, &id, &missing).await?; + println!("PASS native Chromium rejects unselected and unknown client identity"); + + let (_, alternate) = open(app, &url).await?; + let review = wait_review(&alternate, origin).await?; + require_candidates(&review, &fixture)?; + alternate + .select_client_identity(&review, Some(fixture.fingerprint("alternate_client")?)) + .await?; + wait_title(&alternate, "Colossus identity rejected").await?; + println!( + "PASS native Chromium can select exact alternate fixture identity for server rejection" + ); + + let (_, accepted) = open(app, &url).await?; + let review = wait_review(&accepted, origin).await?; + require_candidates(&review, &fixture)?; + accepted + .select_client_identity(&review, Some(fixture.fingerprint("client")?)) + .await?; + wait_title(&accepted, "Colossus mTLS identity verified").await?; + anyhow::ensure!( + accepted + .select_client_identity(&review, Some(fixture.fingerprint("client")?)) + .await + .is_err(), + "consumed native identity request was reused" + ); + println!("PASS native Chromium uses exact reviewed client key and rejects selection replay"); + + let (id, redirected) = open( + app, + &format!("{}/redirect-denied", fixture.origin("public")?), + ) + .await?; + let review = wait_review(&redirected, fixture.origin("redirect_mtls")?).await?; + anyhow::ensure!( + review.origin != origin, + "redirect retained old identity origin" + ); + // A separately reviewed origin is required. The accepted origin's identity + // must not silently follow this cross-origin redirect. + redirected.select_client_identity(&review, None).await?; + wait_denied(app, &id, &redirected).await?; + println!("PASS native Chromium redirect requires independent exact-origin identity review"); + super::action(app, BrowserAction::Clear).await?; + state + .select_target(Some("chromium-acceptance-b".into())) + .await; + println!( + "PKI_NATIVE_TLS_CONFORMANCE_PASSED fixture={}", + fixture.fixture_id + ); + println!( + "PKI_OS_STORE_CUSTODY_PENDING operator must remove only generated fixture certificates and private keys" + ); + Ok(()) +} + +fn require_candidates( + review: &colossus_native_browser::pki::IdentityRequest, + fixture: &Fixture, +) -> anyhow::Result<()> { + for name in ["client", "alternate_client"] { + let fingerprint = fixture.fingerprint(name)?; + anyhow::ensure!( + review + .candidates + .iter() + .any(|candidate| candidate.fingerprint_sha256 == fingerprint), + "both unique fixture identities must be provisioned in the native store" + ); + } + Ok(()) +} + +async fn open(app: &tauri::AppHandle, url: &str) -> anyhow::Result<(String, Surface)> { + super::action(app, BrowserAction::Clear).await?; + let snapshot = super::action( + app, + BrowserAction::New { + conversation_id: None, + url: url.into(), + }, + ) + .await?; + let id = snapshot + .selected_tab_id + .ok_or_else(|| anyhow::anyhow!("PKI fixture tab missing"))?; + let (view, _) = app + .state::() + .browser + .tab(&id, "chromium-pki-acceptance") + .map_err(|error| anyhow::anyhow!(error.message))?; + match view { + BrowserView::Chromium(view) => Ok((id, view)), + _ => anyhow::bail!("PKI child-view acceptance requires the in-process native fixture"), + } +} + +async fn wait_title(view: &Surface, expected: &str) -> anyhow::Result<()> { + for _ in 0..160 { + let page = view.inspect().await?; + if page.title == expected && !page.loading { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + anyhow::bail!( + "native PKI fixture page did not complete; verify unique native CA/key provisioning" + ) +} + +async fn wait_review( + view: &Surface, + origin: &str, +) -> anyhow::Result { + for _ in 0..160 { + if let Some(review) = view.pending_client_identity()? { + anyhow::ensure!( + review.origin == origin && !review.is_expired(), + "native identity request origin changed" + ); + return Ok(review); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + anyhow::bail!( + "native client identities unavailable; provision only the generated fixture PFX files" + ) +} + +async fn wait_denied(app: &tauri::AppHandle, id: &str, view: &Surface) -> anyhow::Result<()> { + for _ in 0..160 { + let page = view.inspect().await?; + anyhow::ensure!( + page.title != "Colossus private CA verified" + && page.title != "Colossus mTLS identity verified", + "negative native TLS fixture unexpectedly succeeded" + ); + let snapshot = app + .state::() + .browser + .snapshot() + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + if snapshot + .tabs + .iter() + .any(|tab| tab.id == id && (tab.error.is_some() || tab.notice.is_some())) + && view.pending_client_identity()?.is_none() + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + anyhow::bail!("native browser did not acknowledge negative TLS fixture denial") +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs b/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs new file mode 100644 index 000000000..e5c17c34b --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs @@ -0,0 +1,83 @@ +//! Windows native frame acknowledgements; foreground pixels are checked separately. +use super::{ + BrowserError, BrowserRect, ExpectedFrame, PAGE_TIMEOUT, RemoteAcceptanceProbe, RemoteSurface, + present, same_document_frame_after, +}; +use anyhow::{Context as _, ensure}; +use std::time::Duration; +use tauri::Manager as _; + +pub(super) async fn focus(app: &tauri::AppHandle) -> anyhow::Result<()> { + let window = app.get_window("main").context("Desktop window missing")?; + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + window.show()?; + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + loop { + if colossus_native_browser::is_active(&controller).await? { + return Ok(()); + } + window.set_focus()?; + ensure!( + tokio::time::Instant::now() < deadline, + "owned browser acceptance requires a foreground Desktop window" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +pub(super) async fn accepted_frame( + app: &tauri::AppHandle, + id: &str, + view: &RemoteSurface, + rect: BrowserRect, + expected: ExpectedFrame<'_>, +) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + loop { + present(app, id, rect).await?; + let probe = match view.acceptance_probe().await { + Ok(probe) => Some(probe), + Err(BrowserError::Stale | BrowserError::Unavailable) => None, + Err(error) => return Err(error.into()), + }; + if let Some(probe) = probe { + let target_matches = expected + .core_target + .is_none_or(|target| probe.page.target == *target); + if let Some(frame) = probe.frame { + let new_document = expected.previous.is_none_or(|previous| { + previous.frame.is_some_and(|prior| { + probe.page.target.document_id != previous.page.target.document_id + && frame.lease.document_generation > prior.lease.document_generation + && frame.lease.viewport_generation > prior.lease.viewport_generation + && frame.lease.control_generation > prior.lease.control_generation + }) + }); + if probe.visible + && probe.width > 0 + && probe.height > 0 + && !probe.page.loading + && probe.page.title == expected.title + && probe.page.url == expected.url + && target_matches + && new_document + && expected + .after + .is_none_or(|prior| same_document_frame_after(frame, prior)) + && frame.sequence > 0 + && frame.count > 0 + && frame.center_bgra == expected.center_bgra + { + return Ok(probe); + } + } + } + ensure!( + tokio::time::Instant::now() < deadline, + "native HWND did not accept an authenticated frame for the current document and fixture color" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs b/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs new file mode 100644 index 000000000..28e41b5d9 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs @@ -0,0 +1,275 @@ +//! Real Windows HWND rendering and native input for the supervised Core fixture. +use super::{BrowserRect, PAGE_TIMEOUT, ViewportHeartbeat, present, title}; +use anyhow::{Context as _, ensure}; +use colossus_native_browser::{presentation::WindowsScreenEvidence, remote::RemoteSurface}; +use std::{fs::OpenOptions, path::Path, time::Duration}; +use tauri::Manager as _; + +async fn screen( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, + expected: [u8; 4], +) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; + let factor = app + .get_window("main") + .context("Desktop window missing")? + .scale_factor()?; + let expected_size = tauri::LogicalSize::new(rect.width, rect.height).to_physical::(factor); + let mut last = String::from("no native screen receipt"); + loop { + present(app, tab, rect).await?; + if let Ok(screen) = view.acceptance_windows_screen().await { + last = format!( + "foreground={} visible={} hit_test={} {}x{} dpi={} center_bgra={:?}", + screen.foreground, + screen.visible, + screen.hit_test, + screen.width, + screen.height, + screen.dpi, + screen.center_bgra + ); + if screen.foreground + && screen.visible + && screen.hit_test + && screen.center_bgra == expected + && screen.width == expected_size.width + && screen.height == expected_size.height + { + return Ok(screen); + } + } + ensure!( + tokio::time::Instant::now() < deadline, + "the actual foreground Windows desktop never showed the current browser pixels: {last}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn save(screen: &WindowsScreenEvidence, home: &Path, name: &str) -> anyhow::Result<()> { + let mut rgba = screen.pixels.clone(); + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + let image = image::RgbaImage::from_raw(screen.width, screen.height, rgba) + .context("native desktop capture dimensions disagreed")?; + let path = home.join(name); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + image::DynamicImage::ImageRgba8(image).write_to(&mut file, image::ImageFormat::Png)?; + file.sync_all()?; + println!( + "EVIDENCE foreground Windows viewport PNG {}", + path.display() + ); + Ok(()) +} + +fn cursor_edge(screen: &WindowsScreenEvidence) -> anyhow::Result { + let dpi = u64::from(screen.dpi); + let x = ((40 * dpi + 48) / 96) + .checked_sub(1) + .context("invalid native DPI")?; + let y = (165 * dpi + 48) / 96; + ensure!( + x < u64::from(screen.width) && y < u64::from(screen.height), + "cursor probe lies outside the actual native viewport" + ); + Ok(usize::try_from((y * u64::from(screen.width) + x) * 4)?) +} + +pub(super) async fn human( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, + home: &Path, + heartbeat: &ViewportHeartbeat, +) -> anyhow::Result<()> { + // Windows resize and overlay assertions own their viewport pulses. Settle + // the ordinary heartbeat so it cannot restore old geometry or show an overlay. + heartbeat.pause().await?; + let initial = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; + save(&initial, home, "windows-human-foreground.png")?; + let smaller = BrowserRect { + width: rect.width - 120.0, + height: rect.height - 80.0, + ..rect + }; + let resized = screen(app, tab, view, smaller, [255, 241, 231, 255]).await?; + let factor = f64::from(resized.dpi) / 96.0; + let expected_size = + tauri::LogicalSize::new(smaller.width, smaller.height).to_physical::(factor); + ensure!( + resized.width == expected_size.width + && resized.height == expected_size.height + && resized.width < initial.width + && resized.height < initial.height, + "native HWND resizing/DPI conversion disagreed with the actual client rectangle" + ); + println!( + "PASS real HWND resize and current display DPI: {}x{} -> {}x{} dpi={}", + initial.width, initial.height, resized.width, resized.height, resized.dpi + ); + let _ = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + controller.eval("const overlay=document.createElement('div');overlay.id='owned-windows-overlay';overlay.style.cssText='position:fixed;inset:0;z-index:2147483647;background:var(--surface-primary,#fff);color:var(--text-primary,#111)';overlay.textContent='Colossus Desktop native overlay acceptance';document.body.appendChild(overlay);")?; + let state = app.state::(); + let generation = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))? + .generation; + state + .browser + .viewport( + app, + &super::BrowserViewportRequest { + generation, + tab_id: Some(tab.into()), + rect: None, + }, + ) + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + tokio::time::sleep(Duration::from_millis(150)).await; + let hidden = view.acceptance_windows_screen().await?; + ensure!( + hidden.foreground + && !hidden.visible + && !hidden.hit_test + && hidden.center_bgra != [255, 241, 231, 255], + "native browser pixels remained above the real Desktop overlay" + ); + save(&hidden, home, "windows-overlay-foreground.png")?; + println!("PASS actual Desktop overlay occludes the native browser HWND"); + controller.eval("document.getElementById('owned-windows-overlay')?.remove();")?; + let _ = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; + heartbeat.resume(tab, rect).await?; + view.acceptance_windows_input().await?; + title(view, "Human input Ab3Ω😀").await?; + println!( + "PASS native HWND mouse/key dispatch, Unicode surrogate IME commit and IME cancel reached real Chromium" + ); + Ok(()) +} + +pub(super) async fn agent( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, + home: &Path, +) -> anyhow::Result<()> { + let screen = screen(app, tab, view, rect, [216, 247, 217, 255]).await?; + ensure!( + view.acceptance_windows_input().await.is_err(), + "agent viewer accepted human input" + ); + save(&screen, home, "windows-agent-foreground.png")?; + println!( + "PASS actual foreground Windows desktop shows the Core-controlled browser: {}x{} dpi={} center_bgra={:?}", + screen.width, screen.height, screen.dpi, screen.center_bgra + ); + Ok(()) +} + +pub(super) async fn agent_click_indicator( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, + home: &Path, +) -> anyhow::Result<()> { + println!("EVIDENCE native agent click title acknowledged; checking foreground cursor pixels"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let mut candidate_saved = false; + let marked = loop { + present(app, tab, rect).await?; + let screen = match view.acceptance_windows_screen().await { + Ok(screen) => screen, + Err(error) => { + if tokio::time::Instant::now() >= deadline { + println!("EVIDENCE cursor foreground capture failure: {error:?}"); + anyhow::bail!("Core click agent cursor capture failed: {error:?}"); + } + tokio::time::sleep(Duration::from_millis(30)).await; + continue; + } + }; + // The synthetic button begins at DIP (40,160). Its outer agent outline + // lies outside the page's own border, so this cannot pass on HTML focus CSS. + let index = cursor_edge(&screen)?; + if !candidate_saved { + save(&screen, home, "windows-agent-cursor-candidate.png")?; + candidate_saved = true; + } + if screen.foreground + && screen.visible + && screen.hit_test + && screen.pixels.get(index..index + 4) == Some(&[0, 0, 0, 255]) + { + break screen; + } + if tokio::time::Instant::now() >= deadline { + println!( + "EVIDENCE cursor foreground failure: foreground={} visible={} hit_test={} {}x{} sample={:?}", + screen.foreground, + screen.visible, + screen.hit_test, + screen.width, + screen.height, + screen.pixels.get(index..index + 4) + ); + anyhow::bail!("Core click agent outline never reached actual Windows desktop pixels"); + } + tokio::time::sleep(Duration::from_millis(30)).await; + }; + save(&marked, home, "windows-agent-cursor-foreground.png")?; + tokio::time::sleep(Duration::from_millis(2400)).await; + let cleared = view.acceptance_windows_screen().await?; + let index = cursor_edge(&cleared)?; + ensure!( + cleared.pixels.get(index..index + 4) == Some(&[255, 241, 231, 255]), + "expired agent cursor remained painted on the native viewer" + ); + save(&cleared, home, "windows-agent-cursor-expired.png")?; + println!( + "PASS Core click shows agent cursor/target outline in actual foreground Windows pixels and removes it after expiry" + ); + Ok(()) +} + +pub(super) async fn agent_ready( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, +) -> anyhow::Result<()> { + let _ = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; + println!("PASS replacement agent viewer is foreground before Core input begins"); + Ok(()) +} + +pub(super) async fn selected_tab( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, + color: [u8; 4], +) -> anyhow::Result<()> { + let frame = screen(app, tab, view, rect, color).await?; + println!( + "PASS Core-selected tab reached the actual foreground Windows HWND: {}x{} dpi={} center_bgra={:?}", + frame.width, frame.height, frame.dpi, frame.center_bgra, + ); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/browser/bootstrap.rs b/apps/desktop/src-tauri/src/browser/bootstrap.rs new file mode 100644 index 000000000..25879dd5b --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/bootstrap.rs @@ -0,0 +1,192 @@ +//! Entry-point CEF installation and nonblocking UI-thread message pumping. + +#[cfg(feature = "embedded-chromium-preview")] +use colossus_native_browser::chromium; +#[cfg(any(feature = "embedded-chromium-preview", test))] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(feature = "embedded-chromium-preview")] +static INITIALIZED: AtomicBool = AtomicBool::new(false); + +/// Own the preview's private cache for the entire native application lifecycle. +pub(crate) fn initialize() -> Option { + #[cfg(feature = "embedded-chromium-preview")] + { + let store = crate::desktop_settings::SettingsStore::open_application().ok()?; + let cache = tempfile::Builder::new() + .prefix("browser-session-cef-") + .tempdir_in(store.application_root()) + .ok()?; + let executable = std::env::current_exe().ok()?; + let helper = helper_path(&executable)?; + match chromium::bootstrap(cache.path(), &helper) { + chromium::Bootstrap::SubprocessExit(code) => std::process::exit(code), + chromium::Bootstrap::Ready => { + INITIALIZED.store(true, Ordering::Release); + Some(cache) + } + chromium::Bootstrap::Unavailable(_) => None, + } + } + #[cfg(not(feature = "embedded-chromium-preview"))] + None +} + +#[cfg(any(feature = "embedded-chromium-preview", all(test, target_os = "macos")))] +fn helper_path(executable: &std::path::Path) -> Option { + let directory = executable.parent()?; + #[cfg(target_os = "macos")] + { + // CEF requires a signed helper app inside the application bundle. A + // sibling command-line helper cannot establish that sandbox topology. + if directory.file_name()? != "MacOS" { + return None; + } + let contents = directory.parent()?; + if contents.file_name()? != "Contents" { + return None; + } + Some( + contents.join( + "Frameworks/Colossus Browser Helper.app/Contents/MacOS/Colossus Browser Helper", + ), + ) + } + #[cfg(not(target_os = "macos"))] + Some(directory.join(if cfg!(windows) { + // Sandboxed children use a minimal CEF-only DLL, not the Tauri client. + "colossus-browser-helper.exe" + } else { + "colossus-browser-helper" + })) +} + +#[cfg_attr( + not(feature = "embedded-chromium-preview"), + allow( + clippy::unnecessary_wraps, + reason = "Ordinary builds preserve the fallible preview lifecycle API." + ) +)] +pub(crate) fn start_pump( + app: &tauri::AppHandle, +) -> Result<(), colossus_native_browser::BrowserError> { + #[cfg(feature = "embedded-chromium-preview")] + if chromium::readiness().is_ok() { + let quit = app.clone(); + chromium::bind_quit_handler(move || { + eprintln!("Desktop native Quit handler received AppKit request"); + let quit = quit.clone(); + // AppKit invokes terminate: from its main-thread event dispatch. + // Queue the Tauri exit request from a worker so ExitRequested can + // settle native browser closes before the event loop exits. + tauri::async_runtime::spawn(async move { + eprintln!("Desktop native Quit worker requests Tauri exit"); + quit.exit(0); + }); + })?; + eprintln!("Desktop native Quit handler bound"); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(10)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let (sender, receive) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + chromium::pump(); + let _ = sender.send(()); + }) + .is_err() + { + break; + } + // At most one main-thread pump is queued. A stalled Tauri loop + // cannot accumulate unbounded timer callbacks. + if receive.await.is_err() { + break; + } + } + }); + } + #[cfg(not(feature = "embedded-chromium-preview"))] + let _ = app; + Ok(()) +} + +#[cfg_attr( + not(feature = "embedded-chromium-preview"), + allow( + clippy::unnecessary_wraps, + reason = "Ordinary builds preserve the fallible preview lifecycle API." + ) +)] +pub(crate) fn shutdown() -> Result<(), colossus_native_browser::BrowserError> { + #[cfg(feature = "embedded-chromium-preview")] + return shutdown_initialized(&INITIALIZED, chromium::shutdown); + #[cfg(not(feature = "embedded-chromium-preview"))] + Ok(()) +} + +#[cfg(any(feature = "embedded-chromium-preview", test))] +fn shutdown_initialized( + initialized: &AtomicBool, + shutdown: impl FnOnce() -> Result<(), colossus_native_browser::BrowserError>, +) -> Result<(), colossus_native_browser::BrowserError> { + if !initialized.load(Ordering::Acquire) { + // An unavailable preview owns no initialized engine or native cache. + return Ok(()); + } + // Preserve the obligation on any failure: an initialized engine must never + // be reclassified as a no-op merely because its teardown was rejected. + shutdown()?; + initialized.store(false, Ordering::Release); + Ok(()) +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "macos")] + use super::helper_path; + use super::{AtomicBool, Ordering, shutdown_initialized}; + use colossus_native_browser::BrowserError; + #[cfg(target_os = "macos")] + use std::path::Path; + + #[test] + fn absent_browser_bootstrap_requires_no_native_shutdown() { + let initialized = AtomicBool::new(false); + assert_eq!( + shutdown_initialized(&initialized, || panic!("no engine exists")), + Ok(()) + ); + } + + #[test] + fn failed_native_shutdown_preserves_the_acknowledgement_obligation() { + let initialized = AtomicBool::new(true); + assert_eq!( + shutdown_initialized(&initialized, || Err(BrowserError::Unavailable)), + Err(BrowserError::Unavailable) + ); + assert!(initialized.load(Ordering::Acquire)); + assert_eq!(shutdown_initialized(&initialized, || Ok(())), Ok(())); + assert!(!initialized.load(Ordering::Acquire)); + assert_eq!( + shutdown_initialized(&initialized, || panic!("already shut down")), + Ok(()) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn helper_is_discovered_in_the_signed_frameworks_bundle() { + assert_eq!( + helper_path(Path::new("/private/Colossus Preview.app/Contents/MacOS/colossus-desktop")), + Some(Path::new("/private/Colossus Preview.app/Contents/Frameworks/Colossus Browser Helper.app/Contents/MacOS/Colossus Browser Helper").to_path_buf()) + ); + assert!(helper_path(Path::new("/private/target/debug/colossus-desktop")).is_none()); + assert!(helper_path(Path::new("/private/Other/MacOS/colossus-desktop")).is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/browser/certificates.rs b/apps/desktop/src-tauri/src/browser/certificates.rs new file mode 100644 index 000000000..6efe12cef --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/certificates.rs @@ -0,0 +1,229 @@ +//! Native PKI setup. Renderer requests actions/preferences, never secret material. + +#[cfg(feature = "embedded-chromium-preview")] +mod review; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State, Webview}; + +use super::{commands::require_controller, manager::error}; +use crate::{dto::CommandErrorDto, provider_enrollment::DialogAppearanceInput, state::AppState}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CertificateAction { + Status, + ImportCa, + ImportClientIdentity, + ReviewClientIdentity, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct CertificateRequest { + generation: u64, + action: CertificateAction, + tab_id: Option, + appearance: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] // Independent native capability metadata. +pub(crate) struct CertificateStatusDto { + scope: &'static str, + ca_import_available: bool, + pfx_import_available: bool, + client_identity_review_available: bool, + client_identity_selection_ready: bool, + acceptance_pending: bool, + message: String, + fingerprints_sha256: Vec, +} + +impl CertificateStatusDto { + fn current() -> Self { + #[cfg(feature = "embedded-chromium-preview")] + { + let status = colossus_native_browser::pki::status(); + Self { + scope: status.scope, + ca_import_available: status.ca_import_available, + pfx_import_available: status.pfx_import_available, + client_identity_review_available: status.client_identity_review_available, + client_identity_selection_ready: status.client_identity_selection_ready, + acceptance_pending: status.acceptance_pending, + message: status.message.into(), + fingerprints_sha256: Vec::new(), + } + } + #[cfg(not(feature = "embedded-chromium-preview"))] + Self { scope: "operating_system_user", ca_import_available: false, pfx_import_available: false, client_identity_review_available: false, client_identity_selection_ready: false, acceptance_pending: false, message: "The current browser uses native operating-system trust. Browser certificate import is available only in the embedded Chromium preview. Colossus network CA bundles and client identities do not configure this browser.".into(), fingerprints_sha256: Vec::new() } + } +} + +#[tauri::command] +pub(crate) async fn browser_certificates( + app: AppHandle, + caller: Webview, + state: State<'_, AppState>, + request: CertificateRequest, +) -> Result { + require_controller(&caller)?; + let scope = state.browser.validate(request.generation)?; + if !matches!(request.action, CertificateAction::ReviewClientIdentity) + && request.tab_id.is_some() + { + return Err(error( + "A tab can be selected only for native identity review.", + )); + } + if matches!(request.action, CertificateAction::Status) { + return Ok(CertificateStatusDto::current()); + } + #[cfg(not(feature = "embedded-chromium-preview"))] + { + let _ = (app, scope, request.appearance); + Err(error( + "Browser certificate import is unavailable in this build.", + )) + } + #[cfg(feature = "embedded-chromium-preview")] + { + if matches!(request.action, CertificateAction::ReviewClientIdentity) { + review::review(app, &caller, &state, request, &scope).await + } else { + import(app, &caller, &state, request, &scope).await + } + } +} + +#[cfg(feature = "embedded-chromium-preview")] +async fn import( + app: AppHandle, + caller: &Webview, + state: &AppState, + request: CertificateRequest, + scope: &str, +) -> Result { + use colossus_native_browser::pki; + use sha2::Digest as _; + use tauri::Manager as _; + use tauri_plugin_dialog::{DialogExt as _, MessageDialogButtons, MessageDialogKind}; + let pki = pki::status(); + let client = matches!(request.action, CertificateAction::ImportClientIdentity); + if (client && !pki.pfx_import_available) || (!client && !pki.ca_import_available) { + return Err(error( + "Native browser certificate import is unavailable on this platform.", + )); + } + state.browser.hide_all(); + // The native confirmation identifies the actual trust scope. Browser profile + // isolation must never be implied for user-store mutations. + let dialog = app.dialog().file(); + let selected = if client { + dialog + .add_filter("Password-protected PKCS#12 identity", &["pfx", "p12"]) + .blocking_pick_file() + } else { + dialog + .add_filter( + "CA trust anchor (PEM or DER)", + &["pem", "crt", "cer", "der"], + ) + .blocking_pick_file() + }; + let Some(selected) = selected else { + return Ok(CertificateStatusDto::current()); + }; + let path = selected + .into_path() + .map_err(|_| error("The native certificate file could not be opened."))?; + // Existing no-follow bounded reader works for native-selected binary input; + // the bytes never enter a command result or the renderer. + let bytes = zeroize::Zeroizing::new( + crate::desktop_settings::read_ca_bundle_source(&path) + .map_err(|_| error("Choose a regular certificate file no larger than 4 MiB."))?, + ); + let password = if client { + let parent = app + .get_window("main") + .ok_or_else(|| error("The Desktop window has closed."))?; + Some( + colossus_native_credential_ui::prompt_password( + parent, + request.appearance.map(Into::into).unwrap_or_default(), + ) + .await + .map_err(|failure| error(&failure.to_string()))?, + ) + } else { + None + }; + let fingerprint = if client { + hex::encode(sha2::Sha256::digest(&bytes)) + } else { + pki::ca_fingerprint(&bytes).map_err(|failure| error(&failure.to_string()))? + }; + let description = if client { + "PKCS#12 package SHA-256" + } else { + "CA certificate DER SHA-256" + }; + let agreed = app.dialog().message(format!("Import this selected native certificate input?\n\n{description}: {fingerprint}\n\nThis changes the operating-system user's certificate store and can affect other applications. Browser profiles do not isolate this trust. All Colossus browser tabs will close before import. Native Chromium client identity requests require a separate exact-origin fingerprint review. Installed key-use acceptance remains pending. Manage removal through the operating system's certificate manager.")).title("Review browser certificate import (preview)").kind(MessageDialogKind::Warning).buttons(MessageDialogButtons::OkCancel).blocking_show(); + if !agreed { + return Ok(CertificateStatusDto::current()); + } + require_controller(caller)?; + if state.browser.validate(request.generation)? != scope { + return Err(error( + "The selected workspace changed before certificate import.", + )); + } + let (generation, operation) = state + .browser + .close_all_authorized(request.generation, scope) + .await?; + // Recheck the selected controller after teardown and before native mutation. + require_controller(caller)?; + let lifecycle = state.browser.lock()?; + if lifecycle.scope.as_deref() != Some(scope) + || lifecycle.generation != generation + || !lifecycle.tabs.is_empty() + { + return Err(error( + "The selected workspace changed before certificate import.", + )); + } + let receipt = if let Some(password) = password { + pki::import_pfx(&bytes, password.expose()) + } else { + pki::import_ca(&bytes) + } + .map_err(|failure| error(&failure.to_string()))?; + drop(lifecycle); + drop(operation); + let mut status = CertificateStatusDto::current(); + status.fingerprints_sha256 = receipt.fingerprints_sha256; + status.message = "Imported into the operating-system user store. Review the exact origin and certificate fingerprint when Chromium requests a client identity. Installed trust/key-use acceptance remains pending. Manage removal through the operating system's certificate manager.".into(); + Ok(status) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn certificate_requests_have_no_path_password_or_key_fields() { + for secret in [ + "path", + "password", + "privateKey", + "keyBytes", + "origin", + "fingerprint", + ] { + let value = serde_json::json!({"generation": 1, "action": "import_client_identity", "appearance": null, (secret): "must not cross IPC"}); + assert!(serde_json::from_value::(value).is_err()); + } + } +} diff --git a/apps/desktop/src-tauri/src/browser/certificates/review.rs b/apps/desktop/src-tauri/src/browser/certificates/review.rs new file mode 100644 index 000000000..c18025f55 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/certificates/review.rs @@ -0,0 +1,137 @@ +//! Native-only review of one expiring Chromium client identity request. + +use colossus_native_browser::pki::IdentityRequest; +use tauri::{AppHandle, Manager as _, Webview, Window}; +use tauri_plugin_dialog::{ + DialogExt as _, MessageDialogButtons, MessageDialogKind, MessageDialogResult, +}; + +use super::{CertificateRequest, CertificateStatusDto}; +use crate::{ + browser::{commands::require_controller, manager::error}, + dto::CommandErrorDto, + state::AppState, +}; + +pub(super) async fn review( + app: AppHandle, + caller: &Webview, + state: &AppState, + request: CertificateRequest, + scope: &str, +) -> Result { + use colossus_native_browser::{BrowserView, chromium::Surface, pki::IdentityRequest}; + struct PendingReview { + surface: Surface, + request: IdentityRequest, + } + impl Drop for PendingReview { + fn drop(&mut self) { + self.surface.cancel_client_identity(&self.request); + } + } + let _review_operation = state + .browser + .identity_review + .try_lock() + .map_err(|_| error("Another native client identity review is already open."))?; + if !colossus_native_browser::pki::status().client_identity_review_available { + return Err(error( + "Native client identity review is unavailable on this platform.", + )); + } + let id = request + .tab_id + .filter(|id| !id.is_empty() && id.len() <= 128) + .ok_or_else(|| error("Select a browser tab with a pending client certificate request."))?; + let (BrowserView::Chromium(surface), _) = state.browser.tab(&id, scope)? else { + return Err(error( + "Client identity review requires the actual Chromium guest.", + )); + }; + let native_request = surface.pending_client_identity().map_err(|failure| error(&failure.to_string()))? + .ok_or_else(|| error("This tab has no pending client certificate request. Reload the website to request one."))?; + let review = PendingReview { + surface, + request: native_request, + }; + state.browser.hide_all(); + let parent = app + .get_window("main") + .ok_or_else(|| error("The Desktop window has closed."))?; + let fingerprint = prompt( + &app, + &parent, + &review.request, + caller, + state, + request.generation, + scope, + ) + .await?; + // No ownership locks remain held while the operator considers a native + // dialog. At dispatch, prevent workspace mutation and shutdown/commands, + // then recheck the original controller generation and exact native request. + let _operation = state.browser.operation.lock().await; + let _selection = state.browser_selection().await; + require_controller(caller)?; + if state.browser.validate(request.generation)? != scope { + return Err(error( + "The selected workspace changed before identity selection.", + )); + } + review + .surface + .select_client_identity(&review.request, fingerprint.as_deref()) + .await + .map_err(|failure| error(&failure.to_string()))?; + let mut status = CertificateStatusDto::current(); + if let Some(fingerprint) = fingerprint { + status.fingerprints_sha256.push(fingerprint); + status.message = "Selected the reviewed native certificate for the exact pending HTTPS handshake. Chromium may reuse this choice for the exact origin in this temporary session. The server determines authentication success. Installed key-use acceptance remains pending.".into(); + } else { + status.message = "No client identity was selected for the pending handshake.".into(); + } + Ok(status) +} + +async fn prompt( + app: &AppHandle, + parent: &Window, + native_request: &IdentityRequest, + caller: &Webview, + state: &AppState, + generation: u64, + scope: &str, +) -> Result, CommandErrorDto> { + for (index, candidate) in native_request.candidates.iter().enumerate() { + require_controller(caller)?; + if state.browser.validate(generation)? != scope || native_request.is_expired() { + return Err(error( + "The native identity request expired or its workspace changed.", + )); + } + let (sender, receive) = tokio::sync::oneshot::channel(); + app.dialog().message(format!("Use this native client identity for this exact HTTPS origin?\n\nOrigin: {}\nCertificate DER SHA-256: {}\nCandidate {} of {}\n\nThe operating system keeps the private key. Chromium may reuse this choice for this exact HTTPS origin within this temporary browser session. Close all browser tabs to end the session. This does not grant agent authority. Select Next identity to review another candidate, or Cancel to send no identity. Native key-use acceptance for this preview is pending.", native_request.origin, candidate.fingerprint_sha256, index + 1, native_request.candidates.len())) + .parent(&parent).title("Review browser client identity (preview)") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::YesNoCancelCustom("Use identity".into(), "Next identity".into(), "Cancel".into())) + .show_with_result(move |result| { let _ = sender.send(result); }); + let response = tokio::time::timeout(std::time::Duration::from_mins(2), receive) + .await + .map_err(|_| error("The native identity request expired."))? + .map_err(|_| error("The native identity dialog has closed."))?; + match response { + MessageDialogResult::Yes => { + return Ok(Some(candidate.fingerprint_sha256.clone())); + } + MessageDialogResult::Custom(label) if label == "Use identity" => { + return Ok(Some(candidate.fingerprint_sha256.clone())); + } + MessageDialogResult::No => {} + MessageDialogResult::Custom(label) if label == "Next identity" => {} + _ => return Ok(None), + } + } + Ok(None) +} diff --git a/apps/desktop/src-tauri/src/browser/commands.rs b/apps/desktop/src-tauri/src/browser/commands.rs index ad01b8edf..d5b1c8f93 100644 --- a/apps/desktop/src-tauri/src/browser/commands.rs +++ b/apps/desktop/src-tauri/src/browser/commands.rs @@ -24,8 +24,12 @@ fn trusted_document(url: &tauri::Url, development: bool) -> bool { && url.scheme() == "http" && url.host_str() == Some("127.0.0.1") && url.port() == Some(1420); + // URL preserves an empty path for the bundled custom-scheme root that + // Tauri loads on macOS. HTTP(S) roots are normalized to "/" instead. + let document = matches!(url.path(), "/" | "/index.html") + || (url.scheme() == "tauri" && url.path().is_empty()); (release && url.port().is_none() || dev) - && matches!(url.path(), "/" | "/index.html") + && document && url.username().is_empty() && url.password().is_none() && !url.query_pairs().any(|(key, _)| key == "surface") @@ -33,13 +37,34 @@ fn trusted_document(url: &tauri::Url, development: bool) -> bool { #[tauri::command] pub(crate) async fn browser_context( + app: AppHandle, caller: Webview, state: State<'_, AppState>, + run_id: Option, + conversation_id: Option, ) -> Result { require_controller(&caller)?; let _operation = state.browser.operation.lock().await; + state.browser.require_open()?; let selected = state.browser_selection().await; state.browser.selection_changed(selected.clone()); + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + if let (Some(scope), Some(run), Some(conversation)) = + (selected.clone(), run_id, conversation_id) + { + state + .browser + .watch_agent(&app, &scope, &run, &conversation) + .await?; + } + #[cfg(not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )))] + let _ = (app, run_id, conversation_id); state.browser.snapshot().await } @@ -99,4 +124,27 @@ mod tests { false )); } + + #[test] + fn bundled_tauri_root_without_a_slash_retains_all_origin_checks() { + let root = "tauri://localhost".parse::().unwrap(); + assert_eq!(root.path(), ""); + assert!(trusted_document(&root, false)); + for denied in [ + "tauri://other-host", + "tauri://localhost:444", + "tauri://user@localhost", + "tauri://user:password@localhost", + "tauri://localhost?surface=terminal", + "tauri://localhost?surface=browser", + "tauri://localhost/other.html", + "tauri://localhost/terminal", + "other://localhost", + ] { + assert!( + !trusted_document(&denied.parse().unwrap(), false), + "{denied}" + ); + } + } } diff --git a/apps/desktop/src-tauri/src/browser/contained.rs b/apps/desktop/src-tauri/src/browser/contained.rs new file mode 100644 index 000000000..1cb7811da --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/contained.rs @@ -0,0 +1,167 @@ +//! Native-only composition of the attested managed sidecar and the OS pixel view. +use async_trait::async_trait; +use colossus_contracts::BrowserUrl; +use colossus_native_browser::{ + self as engine, BrowserError, BrowserView, + contained::{ContainedGuestFactory, ContainedGuestRequest, RemoteHostOwner}, +}; +use colossus_sdk::{NativeBrowserClient, NativeBrowserOpenRequest, NativeBrowserSessionOwner}; +use std::sync::Arc; +use tauri::{AppHandle, Manager as _, Window}; + +pub(crate) fn install(app: &AppHandle) -> Result<(), BrowserError> { + engine::contained::ContainedBrowserComposition::install(app, Arc::new(ManagedFactory)) +} + +struct ManagedFactory; +async fn client(window: &Window, request: &ContainedGuestRequest) -> Option { + window + .state::() + .managed_browser_for(&request.scope) + .await +} + +#[async_trait] +impl ContainedGuestFactory for ManagedFactory { + async fn available( + &self, + window: &Window, + request: &ContainedGuestRequest, + ) -> Result { + if !engine::contained::native_presentation_available() { + return Ok(false); + } + if request.initial_url.is_none() { + return Ok(false); + } + let Some(client) = client(window, request).await else { + return Ok(false); + }; + client + .available() + .await + .map_err(|_| BrowserError::Unavailable) + } + + async fn create( + &self, + window: &Window, + request: ContainedGuestRequest, + ) -> Result { + let client = client(window, &request) + .await + .ok_or(BrowserError::Unavailable)?; + let url = request + .initial_url + .as_ref() + .ok_or(BrowserError::Unavailable)?; + request.policy.authorize(url)?; + let url = BrowserUrl::parse(url.as_str()).map_err(|_| BrowserError::Unavailable)?; + let attachment = client + .open_human(NativeBrowserOpenRequest { + conversation_id: request.conversation_id, + url, + width: 800, + height: 600, + scale_milli: 1000, + viewport_generation: 1, + lease_ms: 1500, + }) + .await + .map_err(|_| BrowserError::Unavailable)?; + let owner = Arc::new(ManagedOwner { + client, + conversation_id: attachment.conversation_id().to_owned(), + session_id: attachment.owner().session_id().to_string(), + native: attachment.owner(), + digest: attachment.enrollment_digest(), + }); + engine::contained::attach( + window, + attachment.presentation(), + attachment.configure(), + attachment.enrollment_digest(), + owner, + request.sink, + ) + .await + } +} + +struct ManagedOwner { + client: NativeBrowserClient, + conversation_id: String, + session_id: String, + native: Arc, + digest: [u8; 32], +} + +pub(super) async fn watch_agent( + window: &Window, + scope: &str, + run_id: &str, + conversation_id: &str, + sink: engine::EventSink, +) -> Result, BrowserError> { + let Some(client) = window + .state::() + .managed_browser_for(scope) + .await + else { + return Ok(None); + }; + let Some(attachment) = client + .watch_agent(run_id, conversation_id) + .await + .map_err(|_| BrowserError::Unavailable)? + else { + return Ok(None); + }; + let owner = Arc::new(ManagedOwner { + client, + conversation_id: attachment.conversation_id().to_owned(), + session_id: attachment.owner().session_id().to_string(), + native: attachment.owner(), + digest: attachment.enrollment_digest(), + }); + engine::contained::attach_read_only( + window, + attachment.presentation(), + attachment.configure(), + attachment.enrollment_digest(), + owner, + sink, + ) + .await + .map(Some) +} +#[async_trait] +impl RemoteHostOwner for ManagedOwner { + fn conversation_id(&self) -> &str { + &self.conversation_id + } + fn session_id(&self) -> &str { + &self.session_id + } + async fn close(&self) -> Result<(), BrowserError> { + self.native.detach().await.map_err(|_| BrowserError::Closed) + } + async fn handoff( + &self, + run_id: &str, + ) -> Result { + let granted = self + .client + .handoff( + self.native.session_id(), + run_id, + colossus_contracts::BrowserLimits::default().max_lease_ms, + ) + .await + .map_err(|_| BrowserError::Unavailable)?; + if granted.enrollment_digest() != self.digest { + return Err(BrowserError::Unavailable); + } + Ok(granted.configure()) + } +} diff --git a/apps/desktop/src-tauri/src/browser/dto.rs b/apps/desktop/src-tauri/src/browser/dto.rs index 01fb421ea..911ed07c5 100644 --- a/apps/desktop/src-tauri/src/browser/dto.rs +++ b/apps/desktop/src-tauri/src/browser/dto.rs @@ -5,6 +5,9 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub(crate) struct BrowserTabDto { pub(crate) id: String, + pub(crate) session_id: String, + pub(crate) conversation_id: Option, + pub(crate) control: &'static str, #[serde(flatten)] pub(crate) page: PageState, pub(crate) error: Option, @@ -19,6 +22,71 @@ pub(crate) struct BrowserSnapshotDto { pub(crate) generation: u64, pub(crate) tabs: Vec, pub(crate) selected_tab_id: Option, + pub(crate) engine: BrowserEngineDto, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrowserEngineDto { + pub(crate) kind: &'static str, + pub(crate) preview: bool, + pub(crate) ready: bool, + pub(crate) message: Option, + pub(crate) agent_control_available: bool, +} + +impl BrowserEngineDto { + pub(super) fn current() -> Self { + #[cfg(feature = "embedded-chromium-preview")] + { + let status = colossus_native_browser::chromium::readiness(); + Self { + kind: "embedded_chromium", + preview: true, + ready: status.is_ok(), + message: status.err().map(|error| error.to_string()), + agent_control_available: false, + } + } + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + { + let ready = colossus_native_browser::contained::native_presentation_available(); + Self { + kind: if cfg!(feature = "windows-browser-development") { + "owned_chromium" + } else { + "contained_chromium" + }, + preview: !cfg!(feature = "windows-browser-development"), + ready, + message: (!ready).then(|| "The native pixel adapter is unavailable.".into()), + agent_control_available: false, + } + } + #[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) + )))] + Self { + kind: if cfg!(windows) { + "webview2" + } else if cfg!(target_os = "macos") { + "webkit" + } else { + "unavailable" + }, + preview: true, + ready: cfg!(windows) || cfg!(all(feature = "browser-preview", target_os = "macos")), + message: None, + agent_control_available: false, + } + } } #[derive(Debug, Deserialize)] @@ -36,17 +104,46 @@ pub(crate) struct BrowserRequest { deny_unknown_fields )] pub(crate) enum BrowserAction { - New { url: String }, - Navigate { tab_id: String, url: String }, - Select { tab_id: String }, - Close { tab_id: String }, - Back { tab_id: String }, - Forward { tab_id: String }, - Reload { tab_id: String }, - Stop { tab_id: String }, - OpenExternal { tab_id: String }, - OpenPopup { tab_id: String }, - DismissNotice { tab_id: String }, + New { + url: String, + #[serde(default)] + conversation_id: Option, + }, + Handoff { + tab_id: String, + run_id: String, + }, + Navigate { + tab_id: String, + url: String, + }, + Select { + tab_id: String, + }, + Close { + tab_id: String, + }, + Back { + tab_id: String, + }, + Forward { + tab_id: String, + }, + Reload { + tab_id: String, + }, + Stop { + tab_id: String, + }, + OpenExternal { + tab_id: String, + }, + OpenPopup { + tab_id: String, + }, + DismissNotice { + tab_id: String, + }, Clear, } diff --git a/apps/desktop/src-tauri/src/browser/guest.rs b/apps/desktop/src-tauri/src/browser/guest.rs new file mode 100644 index 000000000..5643eb37a --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/guest.rs @@ -0,0 +1,183 @@ +//! Native guest construction. Neither branch exposes app capability to a page. + +use std::path::PathBuf; + +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] +use colossus_native_browser::BrowserEvent; +use colossus_native_browser::{self as engine, BrowserView, EventSink, NavigationPolicy}; +use tauri::{AppHandle, Manager as _, Webview}; +#[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +)))] +use tauri::{ + WebviewUrl, + webview::{DownloadEvent, NewWindowResponse, WebviewBuilder}, +}; + +use super::manager::error; +use crate::dto::CommandErrorDto; + +pub(super) struct GuestRequest { + pub id: String, + pub generation: u64, + pub scope: String, + pub conversation_id: Option, + pub initial_url: Option, + pub directory: PathBuf, + pub source: Option, + pub policy: NavigationPolicy, + pub sink: EventSink, +} + +// Compile-time engine branches share one guest entry point and authority check. +#[allow(clippy::too_many_lines)] +pub(super) async fn create( + app: &AppHandle, + request: GuestRequest, +) -> Result { + let GuestRequest { + id, + generation, + scope, + conversation_id, + initial_url, + directory, + source, + policy, + sink, + } = request; + let window = app + .get_window("main") + .ok_or_else(|| error("The Desktop window has closed."))?; + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + { + let _ = (directory, source); + let composition = app + .try_state::() + .ok_or_else(|| error("The supervised browser is unavailable."))?; + let request = engine::contained::ContainedGuestRequest { + id, + generation, + scope, + conversation_id, + initial_url, + policy, + sink, + }; + if !composition + .0 + .available(&window, &request) + .await + .map_err(|failure| error(&failure.to_string()))? + { + return Err(error("The supervised browser is unavailable.")); + } + return composition + .0 + .create(&window, request) + .await + .map_err(|failure| error(&failure.to_string())); + } + #[cfg(feature = "embedded-chromium-preview")] + { + if let Some(composition) = app.try_state::() + { + let request = engine::contained::ContainedGuestRequest { + id: id.clone(), + generation, + scope, + conversation_id, + initial_url: initial_url.clone(), + policy: policy.clone(), + sink: sink.clone(), + }; + if composition + .0 + .available(&window, &request) + .await + .map_err(|failure| error(&failure.to_string()))? + { + return composition + .0 + .create(&window, request) + .await + .map_err(|failure| error(&failure.to_string())); + } + } + let _ = source; + return engine::chromium::Surface::create( + &window, &id, generation, directory, policy, sink, + ) + .await + .map(BrowserView::Chromium) + .map_err(|failure| error(&failure.to_string())); + } + #[cfg(not(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) + )))] + { + let _ = (generation, scope, initial_url, conversation_id); + let popup_sink = sink.clone(); + let download_sink = sink.clone(); + let navigation = policy.clone(); + let mut builder = WebviewBuilder::new( + id, + WebviewUrl::External( + "about:blank" + .parse() + .map_err(|_| error("Browser initialization failed."))?, + ), + ) + .incognito(true) + .data_directory(directory) + .focused(false) + .devtools(false) + .on_navigation(move |url| navigation.allows(url.as_str())) + .on_new_window(move |url, _| { + popup_sink(BrowserEvent::Popup(url.to_string())); + NewWindowResponse::Deny + }) + .on_download(move |_, event| { + if matches!(event, DownloadEvent::Requested { .. }) { + download_sink(BrowserEvent::Download); + } + false + }); + if let Some(source) = source { + builder = engine::share_session(builder, &source) + .await + .map_err(|failure| error(&failure.to_string()))?; + } + let view = window + .add_child( + builder, + tauri::LogicalPosition::new(-10_000.0, -10_000.0), + tauri::LogicalSize::new(16.0, 16.0), + ) + .map_err(|_| error("The browser engine could not start."))?; + let _ = view.hide(); + if let Err(failure) = engine::harden(&view, policy, sink).await { + let _ = engine::release(&view).await; + let _ = view.close(); + return Err(error(&failure.to_string())); + } + Ok(BrowserView::System(view)) + } +} diff --git a/apps/desktop/src-tauri/src/browser/manager.rs b/apps/desktop/src-tauri/src/browser/manager.rs index 20c9449b8..0be39d43c 100644 --- a/apps/desktop/src-tauri/src/browser/manager.rs +++ b/apps/desktop/src-tauri/src/browser/manager.rs @@ -1,23 +1,31 @@ use colossus_native_browser::{ - self as engine, BrowserEvent, EventSink, NavigationAction, NavigationPolicy, PageState, + self as engine, BrowserEvent, BrowserView, EventSink, NavigationAction, NavigationPolicy, + PageState, }; -use std::sync::{Arc, Mutex, MutexGuard}; -use tauri::{ - AppHandle, Manager as _, Webview, WebviewUrl, - webview::{DownloadEvent, NewWindowResponse, WebviewBuilder}, +use std::sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicU64, Ordering}, }; +use tauri::{AppHandle, Manager as _, Webview}; use super::{ dto::{BrowserAction, BrowserSnapshotDto, BrowserTabDto}, inspection::{SNAPSHOT_BUDGET, collect_pages}, - registry::{MAX_TABS, Registry, Tab}, + registry::{DRAINING_MESSAGE, MAX_TABS, Registry, Tab}, }; -use crate::{desktop_settings::SettingsStore, dto::CommandErrorDto}; +#[cfg(not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" +)))] +use crate::desktop_settings::SettingsStore; +use crate::dto::CommandErrorDto; #[derive(Default)] pub(crate) struct BrowserManager { pub(super) data: Arc>, pub(crate) operation: tokio::sync::Mutex<()>, + #[cfg(feature = "embedded-chromium-preview")] + pub(super) identity_review: tokio::sync::Mutex<()>, } pub(super) fn error(message: &str) -> CommandErrorDto { @@ -27,7 +35,23 @@ fn engine_error(value: engine::BrowserError) -> CommandErrorDto { error(&value.to_string()) } +fn teardown_hide_result(result: Result<(), engine::BrowserError>) -> Result<(), CommandErrorDto> { + match result { + // A timed-out close may have completed after the caller retained its tab. + // Still require close() below to reconcile actual native ownership. + Ok(()) | Err(engine::BrowserError::Closed) => Ok(()), + Err(error) => Err(engine_error(error)), + } +} + impl BrowserManager { + pub(super) fn require_open(&self) -> Result<(), CommandErrorDto> { + if self.lock()?.draining { + return Err(error(DRAINING_MESSAGE)); + } + Ok(()) + } + pub(super) fn lock(&self) -> Result, CommandErrorDto> { self.data .lock() @@ -35,38 +59,83 @@ impl BrowserManager { } pub(crate) fn selection_changed(&self, scope: Option) { - if let Ok(mut state) = self.data.lock() + let views = if let Ok(mut state) = self.data.lock() && state.scope != scope { for tab in &mut state.tabs { - let _ = tab.view.hide(); + tab.view.revoke_client_identity(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); tab.heartbeat = None; } state.generation = state.generation.wrapping_add(1); state.scope = scope; + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } pub(crate) fn hide_all(&self) { - if let Ok(mut state) = self.data.lock() { + let views = if let Ok(mut state) = self.data.lock() { for tab in &mut state.tabs { - let _ = tab.view.hide(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); tab.heartbeat = None; } + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } pub(crate) fn controller_loading(&self) { - self.hide_all(); - if let Ok(mut state) = self.data.lock() { + let views = if let Ok(mut state) = self.data.lock() { + for tab in &mut state.tabs { + tab.view.revoke_client_identity(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + } state.generation = state.generation.wrapping_add(1); + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } pub(crate) fn validate(&self, generation: u64) -> Result { let state = self.lock()?; + if state.draining { + return Err(error(DRAINING_MESSAGE)); + } if !state.snapshot().available { - return Err(error("Browser preview is not enabled in this build.")); + return Err(error( + state + .snapshot() + .engine + .message + .as_deref() + .unwrap_or("Browser preview is not enabled in this build."), + )); } if state.generation != generation { return Err(error( @@ -83,7 +152,7 @@ impl BrowserManager { &self, id: &str, scope: &str, - ) -> Result<(Webview, NavigationPolicy), CommandErrorDto> { + ) -> Result<(BrowserView, NavigationPolicy), CommandErrorDto> { self.lock()? .tabs .iter() @@ -92,9 +161,130 @@ impl BrowserManager { .ok_or_else(|| error("This tab is no longer available in the selected workspace.")) } + #[cfg(all( + feature = "browser-test-bridge", + not(feature = "embedded-chromium-preview"), + not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )) + ))] + pub(super) fn legacy_tab( + &self, + id: &str, + scope: &str, + ) -> Result<(Webview, NavigationPolicy), CommandErrorDto> { + let (view, policy) = self.tab(id, scope)?; + let view = view + .system_view() + .cloned() + .ok_or_else(|| error("Use the Chromium native acceptance harness for this build."))?; + Ok((view, policy)) + } + + /// Revoke controller generations, hide, then settle native teardown before + /// temporary profiles are dropped. Failed cleanup keeps profile ownership. + #[cfg(any( + test, + all( + feature = "browser-test-bridge", + any( + feature = "embedded-chromium-preview", + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) + ) + ))] + pub(crate) async fn close_all_settled(&self) -> Result { + let operation = self.operation.lock().await; + self.close_all_locked(&operation, None).await + } + + /// Stop admitting work before teardown, without retaining the operation lock + /// through the application's later runtime/client cleanup. Trusted Quit retries + /// may reconcile delayed close acknowledgements while ordinary controls stay revoked. + pub(crate) async fn drain_for_shutdown(&self) -> Result { + let operation = self.operation.lock().await; + { + let mut state = self.lock()?; + for tab in &state.tabs { + tab.view.revoke_client_identity(); + } + state.draining = true; + } + self.close_all_locked(&operation, None).await + } + + #[cfg(any(test, feature = "embedded-chromium-preview"))] + pub(super) async fn close_all_authorized<'a>( + &'a self, + expected_generation: u64, + expected_scope: &str, + ) -> Result<(u64, tokio::sync::MutexGuard<'a, ()>), CommandErrorDto> { + let operation = self.operation.lock().await; + let generation = self + .close_all_locked(&operation, Some((expected_generation, expected_scope))) + .await?; + // The native store caller retains this guard through its final + // lifecycle check and mutation, excluding creation of a new guest. + Ok((generation, operation)) + } + + async fn close_all_locked( + &self, + _operation: &tokio::sync::MutexGuard<'_, ()>, + expected: Option<(u64, &str)>, + ) -> Result { + let (generation, tabs, views) = { + let mut state = self.lock()?; + if expected.is_some() && state.draining { + return Err(error(DRAINING_MESSAGE)); + } + if let Some((generation, scope)) = expected + && (state.generation != generation || state.scope.as_deref() != Some(scope)) + { + return Err(error( + "The selected workspace changed before browser teardown.", + )); + } + for tab in &mut state.tabs { + tab.view.revoke_client_identity(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + } + state.generation = state.generation.wrapping_add(1); + ( + state.generation, + state + .tabs + .iter() + .map(|tab| (tab.scope.clone(), tab.dto.id.clone())) + .collect::>(), + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>(), + ) + }; + for view in views { + teardown_hide_result(view.hide())?; + } + for (scope, id) in tabs { + self.close(&scope, &id).await?; + } + if !self.lock()?.tabs.is_empty() { + return Err(error("Native browser sessions did not finish closing.")); + } + Ok(generation) + } + pub(crate) async fn snapshot(&self) -> Result { let views: Vec<_> = { let state = self.lock()?; + if state.draining { + return Ok(state.snapshot()); + } state .tabs .iter() @@ -104,13 +294,18 @@ impl BrowserManager { }; let inspections = views .into_iter() - .map(|(id, view)| async move { (id, engine::inspect(&view).await) }); + .map(|(id, view)| async move { (id, view.inspect().await) }); for (id, mut page) in collect_pages(inspections, SNAPSHOT_BUDGET).await { let mut state = self.lock()?; if let Some(tab) = state.tabs.iter_mut().find(|t| t.dto.id == id) { #[cfg(windows)] { - page.loading = tab.dto.page.loading; + // WebView2's event-owned loading flag is independent from + // its inspection API. The supervised host instead returns + // authenticated loading metadata for its exact document. + if tab.view.contained_session_id().is_none() { + page.loading = tab.dto.page.loading; + } } if page.url == "about:blank" { if page.loading { @@ -133,7 +328,23 @@ impl BrowserManager { ) -> Result<(), CommandErrorDto> { let scope = self.validate(generation)?; match action { - BrowserAction::New { url } => self.create(app, &scope, &url).await?, + BrowserAction::New { + url, + conversation_id, + } => self.create(app, &scope, &url, conversation_id).await?, + BrowserAction::Handoff { tab_id, run_id } => { + let (view, _) = self.tab(&tab_id, &scope)?; + view.handoff(&run_id).await.map_err(engine_error)?; + if let Some(tab) = self + .lock()? + .tabs + .iter_mut() + .find(|tab| tab.dto.id == tab_id) + { + tab.dto.control = "agent"; + tab.agent_run_id = Some(run_id); + } + } BrowserAction::Clear => { let ids: Vec<_> = self .lock()? @@ -161,7 +372,10 @@ impl BrowserManager { } BrowserAction::Close { tab_id } => self.close(&scope, &tab_id).await?, BrowserAction::OpenExternal { tab_id } => { - let (view, _) = self.tab(&tab_id, &scope)?; + self.tab(&tab_id, &scope)?; + let view = app + .get_webview("main") + .ok_or_else(|| error("The Desktop controller has closed."))?; let url = self .lock()? .tabs @@ -183,7 +397,7 @@ impl BrowserManager { .find(|t| t.dto.id == tab_id) .and_then(|t| t.dto.popup_url.clone()) .ok_or_else(|| error("This page has no pending popup."))?; - self.create(app, &scope, &url).await?; + self.create(app, &scope, &url, None).await?; self.dismiss(&tab_id)?; } BrowserAction::DismissNotice { tab_id } => { @@ -204,7 +418,7 @@ impl BrowserManager { } else { self.reset_navigation(&id, None)?; } - engine::control(&view, action).await.map_err(engine_error)?; + view.control(action).await.map_err(engine_error)?; } } Ok(()) @@ -235,33 +449,60 @@ impl BrowserManager { &self, scope: &str, ) -> Result<(std::path::PathBuf, Option), CommandErrorDto> { - let mut state = self.lock()?; - if state.tabs.len() >= MAX_TABS { - return Err(error( - "Eight browser tabs are already open. Close a tab before opening another.", - )); + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + { + let state = self.lock()?; + if state.draining || state.tabs.len() >= MAX_TABS { + return Err(error("The supervised browser cannot open another tab.")); + } + let _ = scope; + // The separate host owns its temporary profile. Desktop must not + // create a second local browser profile for a remote pixel view. + Ok((std::path::PathBuf::new(), None)) } - if !state.profiles.contains_key(scope) { - let store = SettingsStore::open_application()?; - let directory = tempfile::Builder::new() - .prefix("browser-session-") - .tempdir_in(store.application_root()) - .map_err(|_| error("The temporary browser session could not be created."))?; - state.profiles.insert(scope.to_owned(), directory); + #[cfg(not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )))] + { + let mut state = self.lock()?; + if state.draining { + return Err(error(DRAINING_MESSAGE)); + } + if state.tabs.len() >= MAX_TABS { + return Err(error( + "Eight browser tabs are already open. Close a tab before opening another.", + )); + } + if !state.profiles.contains_key(scope) { + let store = SettingsStore::open_application()?; + let directory = tempfile::Builder::new() + .prefix("browser-session-") + .tempdir_in(store.application_root()) + .map_err(|_| error("The temporary browser session could not be created."))?; + state.profiles.insert(scope.to_owned(), directory); + state.sessions.insert( + scope.to_owned(), + format!("browser-session-{}", uuid::Uuid::new_v4()), + ); + } + Ok(( + state + .profiles + .get(scope) + .ok_or_else(|| error("The browser profile is unavailable."))? + .path() + .to_owned(), + state + .tabs + .iter() + .filter(|t| t.scope == scope) + .find_map(|t| t.view.system_view().cloned()), + )) } - Ok(( - state - .profiles - .get(scope) - .ok_or_else(|| error("The browser profile is unavailable."))? - .path() - .to_owned(), - state - .tabs - .iter() - .find(|t| t.scope == scope) - .map(|t| t.view.clone()), - )) } async fn create( @@ -269,6 +510,7 @@ impl BrowserManager { app: &AppHandle, scope: &str, address: &str, + conversation_id: Option, ) -> Result<(), CommandErrorDto> { let url = if address.trim().is_empty() { None @@ -284,70 +526,65 @@ impl BrowserManager { let weak = Arc::downgrade(&self.data); let event_id = id.clone(); let sink: EventSink = Arc::new(move |event| { - if let Some(data) = weak.upgrade() - && let Ok(mut state) = data.lock() - { + let crashed = matches!(event, BrowserEvent::Crashed); + let view = weak.upgrade().and_then(|data| { + let mut state = data.lock().ok()?; state.event(&event_id, event); + crashed + .then(|| { + state + .tabs + .iter() + .find(|tab| tab.dto.id == event_id) + .map(|tab| tab.view.clone()) + }) + .flatten() + }); + if let Some(view) = view { + let _ = view.hide(); } }); - let popup_sink = sink.clone(); - let download_sink = sink.clone(); - let navigation = policy.clone(); - let mut builder = WebviewBuilder::new( - &id, - WebviewUrl::External( - "about:blank" - .parse() - .map_err(|_| error("Browser initialization failed."))?, - ), + let generation = self.lock()?.generation; + let view = super::guest::create( + app, + super::guest::GuestRequest { + id: id.clone(), + generation, + scope: scope.to_owned(), + conversation_id, + initial_url: url.clone(), + directory, + source, + policy: policy.clone(), + sink, + }, ) - .incognito(true) - .data_directory(directory) - .focused(false) - .devtools(false) - .on_navigation(move |url| navigation.allows(url.as_str())) - .on_new_window(move |url, _| { - popup_sink(BrowserEvent::Popup(url.to_string())); - NewWindowResponse::Deny - }) - .on_download(move |_, event| { - if matches!(event, DownloadEvent::Requested { .. }) { - download_sink(BrowserEvent::Download); - } - false - }); - if let Some(source) = source { - builder = engine::share_session(builder, &source) - .await - .map_err(engine_error)?; - } - let window = app - .get_window("main") - .ok_or_else(|| error("The Desktop window has closed."))?; - let view = window - .add_child( - builder, - tauri::LogicalPosition::new(-10_000.0, -10_000.0), - tauri::LogicalSize::new(16.0, 16.0), - ) - .map_err(|_| error("The browser engine could not start."))?; - let _ = view.hide(); - if let Err(failure) = engine::harden(&view, policy.clone(), sink).await { - let _ = engine::release(&view).await; - let _ = view.close(); - return Err(engine_error(failure)); - } + .await?; self.hide_all(); { let mut state = self.lock()?; + let session_id = view + .contained_session_id() + .map(str::to_owned) + .or_else(|| state.sessions.get(scope).cloned()) + .ok_or_else(|| error("The browser session identity is unavailable."))?; state.selected.insert(scope.to_owned(), id.clone()); state.tabs.push(Tab { scope: scope.to_owned(), view: view.clone(), + agent_run_id: None, policy, heartbeat: None, + presentation_epoch: Arc::new(AtomicU64::new(0)), dto: BrowserTabDto { id: id.clone(), + session_id, + conversation_id: view.conversation_id().map(str::to_owned), + control: if view.human_control_available() { + "human" + } else { + "agent" + }, page: PageState { url: url.as_ref().map(ToString::to_string).unwrap_or_default(), title: "New tab".into(), @@ -360,6 +597,13 @@ impl BrowserManager { }, }); } + // The managed sidecar already opened the exact initial address before + // returning the RemoteSurface. Sending a second human navigation here + // could race the authenticated presentation's first frame. + #[cfg(not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )))] if let Some(url) = url && view.navigate(url).is_err() { @@ -368,11 +612,102 @@ impl BrowserManager { Ok(()) } - async fn close(&self, scope: &str, id: &str) -> Result<(), CommandErrorDto> { + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + pub(super) async fn watch_agent( + &self, + app: &AppHandle, + scope: &str, + run_id: &str, + conversation_id: &str, + ) -> Result<(), CommandErrorDto> { + #[cfg(feature = "windows-browser-development")] + if !self + .reconcile_windows_agent(app, scope, run_id, conversation_id) + .await? + { + return Ok(()); + } + // Dropping an old read-only viewer preserves an active Core writer; + // confirmed run cleanup has already retired its native owner otherwise. + let prior = self + .lock()? + .tabs + .iter() + .filter(|tab| { + tab.scope == scope && tab.agent_run_id.as_deref().is_some_and(|id| id != run_id) + }) + .map(|tab| tab.dto.id.clone()) + .collect::>(); + for id in prior { + self.close(scope, &id).await?; + } + if self + .lock()? + .tabs + .iter() + .any(|tab| tab.scope == scope && tab.agent_run_id.as_deref() == Some(run_id)) + { + return Ok(()); + } + if self.lock()?.tabs.len() >= MAX_TABS { + return Err(error("Close a browser tab before attaching another page.")); + } + let window = app + .get_window("main") + .ok_or_else(|| error("The Desktop window has closed."))?; + let id = format!("browser-{}", uuid::Uuid::new_v4()); + let weak = Arc::downgrade(&self.data); + let event_id = id.clone(); + let sink: EventSink = Arc::new(move |event| { + if let Some(data) = weak.upgrade() + && let Ok(mut state) = data.lock() + { + state.event(&event_id, event); + } + }); + let Some(view) = + super::contained::watch_agent(&window, scope, run_id, conversation_id, sink) + .await + .map_err(engine_error)? + else { + return Ok(()); + }; + let session_id = view + .contained_session_id() + .ok_or_else(|| error("The browser session identity is unavailable."))? + .to_owned(); + self.hide_all(); + let mut state = self.lock()?; + state.selected.insert(scope.to_owned(), id.clone()); + state.tabs.push(Tab { + scope: scope.to_owned(), + view, + agent_run_id: Some(run_id.to_owned()), + policy: NavigationPolicy::default(), + heartbeat: None, + presentation_epoch: Arc::new(AtomicU64::new(0)), + dto: BrowserTabDto { + id, + session_id, + conversation_id: Some(conversation_id.to_owned()), + control: "agent", + page: PageState::default(), + error: None, + notice: None, + popup_url: None, + }, + }); + Ok(()) + } + + pub(super) async fn close(&self, scope: &str, id: &str) -> Result<(), CommandErrorDto> { let (view, _) = self.tab(id, scope)?; let _ = view.hide(); - let _ = engine::release(&view).await; view.close() + .await .map_err(|_| error("This browser tab could not close."))?; let mut state = self.lock()?; state.tabs.retain(|t| t.dto.id != id); @@ -393,7 +728,103 @@ impl BrowserManager { } if !state.tabs.iter().any(|t| t.scope == scope) { state.profiles.remove(scope); + state.sessions.remove(scope); } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delayed_close_can_reach_reconciliation_but_other_hide_errors_block_teardown() { + assert!(teardown_hide_result(Err(engine::BrowserError::Closed)).is_ok()); + for failure in [ + engine::BrowserError::TimedOut, + engine::BrowserError::Unavailable, + ] { + assert!(teardown_hide_result(Err(failure)).is_err()); + } + } + + #[tokio::test] + async fn ordinary_tab_teardown_does_not_enter_application_shutdown() { + let manager = BrowserManager::default(); + manager.close_all_settled().await.unwrap(); + assert!(manager.require_open().is_ok()); + assert!(!manager.lock().unwrap().draining); + } + + #[tokio::test] + async fn browser_admission_stays_revoked_during_remaining_application_cleanup() { + let manager = BrowserManager::default(); + manager.lock().unwrap().scope = Some("workspace".into()); + let generation = manager.drain_for_shutdown().await.unwrap(); + // AppState still has asynchronous runtime/client cleanup ahead of it. + // No operation guard is held, but even a refreshed generation is rejected. + assert!(manager.operation.try_lock().is_ok()); + assert_eq!( + manager.require_open().unwrap_err().message, + DRAINING_MESSAGE + ); + assert_eq!( + manager.validate(generation).unwrap_err().message, + DRAINING_MESSAGE + ); + let snapshot = manager.snapshot().await.unwrap(); + assert!(!snapshot.available && !snapshot.engine.ready); + assert_eq!(snapshot.engine.message.as_deref(), Some(DRAINING_MESSAGE)); + assert!(!snapshot.engine.agent_control_available); + assert!( + manager + .close_all_authorized(generation, "workspace") + .await + .is_err() + ); + assert_eq!(manager.lock().unwrap().generation, generation); + // Only a trusted Quit retry can resume reconciliation. + assert_eq!(manager.drain_for_shutdown().await.unwrap(), generation + 1); + assert!(manager.require_open().is_err()); + } + + #[tokio::test] + async fn stale_certificate_teardown_is_rejected_after_waiting_for_operation_ownership() { + let manager = Arc::new(BrowserManager::default()); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state.generation = 7; + } + let operation = manager.operation.lock().await; + let queued = manager.clone(); + let attempt = + tokio::spawn(async move { queued.close_all_authorized(7, "workspace").await.is_err() }); + tokio::task::yield_now().await; + // A same-workspace controller reload revokes the original authorization. + manager.lock().unwrap().generation = 8; + drop(operation); + assert!(attempt.await.unwrap()); + let state = manager.lock().unwrap(); + // Rejection occurs before advancing lifecycle or planning native effects. + assert_eq!(state.generation, 8); + assert_eq!(state.scope.as_deref(), Some("workspace")); + assert!(state.tabs.is_empty()); + } + + #[tokio::test] + async fn certificate_teardown_retains_operation_ownership_until_import_finishes() { + let manager = BrowserManager::default(); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state.generation = 7; + } + let (generation, operation) = manager.close_all_authorized(7, "workspace").await.unwrap(); + assert_eq!(generation, 8); + assert!(manager.operation.try_lock().is_err()); + drop(operation); + assert!(manager.operation.try_lock().is_ok()); + } +} diff --git a/apps/desktop/src-tauri/src/browser/mod.rs b/apps/desktop/src-tauri/src/browser/mod.rs index bc6ef4d0f..cc94c2dd0 100644 --- a/apps/desktop/src-tauri/src/browser/mod.rs +++ b/apps/desktop/src-tauri/src/browser/mod.rs @@ -2,12 +2,28 @@ #[cfg(feature = "browser-test-bridge")] pub(crate) mod acceptance; +pub(crate) mod bootstrap; +pub(crate) mod certificates; pub(crate) mod commands; +#[cfg(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) +))] +pub(crate) mod contained; pub(crate) mod dto; +mod guest; mod inspection; mod manager; +mod presentation; mod registry; pub(crate) mod viewport; +#[cfg(all(windows, feature = "embedded-chromium-preview"))] +mod windows_entry; +#[cfg(feature = "windows-browser-development")] +mod windows_viewer; pub(crate) use manager::BrowserManager; pub(crate) use viewport::{handle_window_event, start_watchdog}; diff --git a/apps/desktop/src-tauri/src/browser/presentation.rs b/apps/desktop/src-tauri/src/browser/presentation.rs new file mode 100644 index 000000000..d9a0f0d2e --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/presentation.rs @@ -0,0 +1,52 @@ +//! A late main-thread show cannot outlive workspace/overlay authority revocation. + +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use colossus_native_browser::{BrowserError, BrowserView}; +use tauri::Window; + +use super::dto::BrowserRect; + +pub(super) async fn apply( + window: &Window, + view: BrowserView, + lease: Arc, + epoch: u64, + bounds: Option, +) -> Result<(), BrowserError> { + let (sender, receive) = tokio::sync::oneshot::channel(); + window + .run_on_main_thread(move || { + let result = if !sender.is_closed() && lease.load(Ordering::Acquire) == epoch { + if let Some(rect) = bounds { + view.set_bounds(tauri::Rect { + position: tauri::LogicalPosition::new(rect.x, rect.y).into(), + size: tauri::LogicalSize::new(rect.width, rect.height).into(), + }) + .and_then(|()| { + // Resizing can invoke native callbacks. Recheck the + // current presentation owner immediately before show. + if !sender.is_closed() && lease.load(Ordering::Acquire) == epoch { + view.show() + } else { + view.hide()?; + Err(BrowserError::Closed) + } + }) + } else { + view.hide() + } + } else { + Err(BrowserError::Closed) + }; + let _ = sender.send(result); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(std::time::Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? +} diff --git a/apps/desktop/src-tauri/src/browser/registry.rs b/apps/desktop/src-tauri/src/browser/registry.rs index c1fe96937..c9301aed9 100644 --- a/apps/desktop/src-tauri/src/browser/registry.rs +++ b/apps/desktop/src-tauri/src/browser/registry.rs @@ -1,37 +1,80 @@ -use super::dto::{BrowserSnapshotDto, BrowserTabDto}; -use colossus_native_browser::{BrowserEvent, NavigationPolicy}; -use std::{collections::HashMap, time::Instant}; -use tauri::Webview; +use super::dto::{BrowserEngineDto, BrowserSnapshotDto, BrowserTabDto}; +use colossus_native_browser::{BrowserEvent, BrowserView, NavigationPolicy}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; pub(super) const MAX_TABS: usize = 8; +pub(super) const DRAINING_MESSAGE: &str = "The browser is closing for application shutdown."; +pub(super) const CRASHED_MESSAGE: &str = + "This browser tab stopped responding. Close it and open a new tab."; pub(super) struct Tab { pub(super) scope: String, - pub(super) view: Webview, + pub(super) view: BrowserView, + pub(super) agent_run_id: Option, pub(super) policy: NavigationPolicy, pub(super) dto: BrowserTabDto, pub(super) heartbeat: Option, + pub(super) presentation_epoch: Arc, } #[derive(Default)] pub(super) struct Registry { + pub(super) draining: bool, pub(super) generation: u64, pub(super) scope: Option, pub(super) tabs: Vec, pub(super) selected: HashMap, pub(super) profiles: HashMap, + pub(super) sessions: HashMap, + #[cfg(feature = "windows-browser-development")] + pub(super) viewer_recoveries: HashMap, } impl Registry { pub(super) fn snapshot(&self) -> BrowserSnapshotDto { + let mut engine = BrowserEngineDto::current(); + if self.tabs.iter().any(|tab| { + Some(&tab.scope) == self.scope.as_ref() && tab.view.contained_session_id().is_some() + }) { + // A source-only Mac diagnostic remains a preview even when the + // independently supervised native surface is actually attached. + #[cfg(not(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + )))] + { + engine.preview = false; + } + engine.ready = true; + engine.agent_control_available = true; + engine.message = None; + } + if self.draining { + engine.ready = false; + engine.message = Some(DRAINING_MESSAGE.into()); + } BrowserSnapshotDto { - available: cfg!(windows) || cfg!(all(feature = "browser-preview", target_os = "macos")), + available: engine.ready, + engine, generation: self.generation, tabs: self .tabs .iter() .filter(|t| Some(&t.scope) == self.scope.as_ref()) - .map(|t| t.dto.clone()) + .map(|t| { + let mut dto = t.dto.clone(); + if !t.view.human_control_available() && dto.control == "human" { + dto.control = "paused"; + } + dto + }) .collect(), selected_tab_id: self .scope @@ -61,10 +104,9 @@ impl Registry { } BrowserEvent::Crashed => { tab.dto.page.loading = false; - tab.dto.error = Some( - "This browser tab stopped responding. Close it and open a new tab.".into(), - ); - let _ = tab.view.hide(); + tab.dto.error = Some(CRASHED_MESSAGE.into()); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; } BrowserEvent::Blocked => { tab.dto.notice = Some("This page requested access that is unavailable in the embedded browser. You can open it in your system browser.".into()); @@ -83,6 +125,14 @@ impl Registry { tab.dto.notice = Some("This page wants to open another tab.".into()); } } + BrowserEvent::TlsFailed => { + tab.dto.page.loading = false; + tab.dto.error = Some("The website's certificate could not be verified. Check its hostname, expiry, and native CA trust configuration. TLS verification cannot be bypassed.".into()); + } + BrowserEvent::AuthenticationRequired => { + tab.dto.page.loading = false; + tab.dto.notice = Some("This website requires a client certificate. A native identity must be provisioned and reviewed for this exact HTTPS origin.".into()); + } } } } diff --git a/apps/desktop/src-tauri/src/browser/viewport.rs b/apps/desktop/src-tauri/src/browser/viewport.rs index 91ea8d202..394ca87fc 100644 --- a/apps/desktop/src-tauri/src/browser/viewport.rs +++ b/apps/desktop/src-tauri/src/browser/viewport.rs @@ -4,7 +4,10 @@ use super::{ manager::error, }; use crate::{dto::CommandErrorDto, state::AppState}; -use std::time::{Duration, Instant}; +use std::{ + sync::atomic::Ordering, + time::{Duration, Instant}, +}; use tauri::{AppHandle, Manager as _}; pub(crate) fn valid_rect(rect: BrowserRect, width: f64, height: f64) -> bool { @@ -42,47 +45,77 @@ impl BrowserManager { .inner_size() .map_err(|_| error("The browser viewport is unavailable."))? .to_logical::(scale); - let mut state = self.lock()?; - let selected = state.selected.get(&scope).cloned(); - for tab in &mut state.tabs { - let visible = active - && tab.scope == scope - && Some(&tab.dto.id) == request.tab_id.as_ref() - && request.tab_id == selected - && tab.dto.error.is_none(); - if let Some(rect) = request - .rect - .filter(|r| visible && valid_rect(*r, size.width, size.height)) + let operations = { + let mut state = self.lock()?; + if state.draining { + return Err(error(super::registry::DRAINING_MESSAGE)); + } + if state.generation != request.generation + || state.scope.as_deref() != Some(scope.as_str()) { - tab.view - .set_bounds(tauri::Rect { - position: tauri::LogicalPosition::new(rect.x, rect.y).into(), - size: tauri::LogicalSize::new(rect.width, rect.height).into(), - }) - .map_err(|_| error("The browser viewport could not be resized."))?; - tab.view - .show() - .map_err(|_| error("The browser tab could not be displayed."))?; - tab.heartbeat = Some(Instant::now()); - } else { - let _ = tab.view.hide(); - tab.heartbeat = None; + return Err(error( + "The selected workspace changed. Reopen the browser pane.", + )); + } + let selected = state.selected.get(&scope).cloned(); + state + .tabs + .iter_mut() + .map(|tab| { + let visible = active + && tab.scope == scope + && Some(&tab.dto.id) == request.tab_id.as_ref() + && request.tab_id == selected + && tab.dto.error.is_none(); + let rect = request + .rect + .filter(|rect| visible && valid_rect(*rect, size.width, size.height)); + tab.heartbeat = rect.map(|_| Instant::now()); + ( + tab.view.clone(), + tab.presentation_epoch.clone(), + tab.presentation_epoch + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1), + rect, + ) + }) + .collect::>() + }; + // Drop registry locks before UI work: native event callbacks also need + // them. Each queued operation rechecks its independently revocable lease. + for (view, lease, epoch, rect) in operations { + match super::presentation::apply(&window, view, lease, epoch, rect).await { + Ok(()) | Err(colossus_native_browser::BrowserError::Closed) => {} + Err(_) => return Err(error("The browser viewport could not be updated.")), } } Ok(()) } fn hide_expired(&self) { - if let Ok(mut state) = self.data.lock() { - for tab in &mut state.tabs { - if tab - .heartbeat - .is_some_and(|last| last.elapsed() > Duration::from_secs(2)) - { - let _ = tab.view.hide(); - tab.heartbeat = None; - } - } + let views = if let Ok(mut state) = self.data.lock() { + state + .tabs + .iter_mut() + .filter_map(|tab| { + if tab + .heartbeat + .is_some_and(|last| last.elapsed() > Duration::from_secs(2)) + { + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + Some(tab.view.clone()) + } else { + None + } + }) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } } diff --git a/apps/desktop/src-tauri/src/browser/windows_entry.rs b/apps/desktop/src-tauri/src/browser/windows_entry.rs new file mode 100644 index 000000000..cb9faa012 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/windows_entry.rs @@ -0,0 +1,45 @@ +//! Debug-only Windows client DLL entry called by the pinned CEF bootstrap. + +use std::ffi::c_void; + +// CEF's header declares this export with C calling convention, not WINAPI. The +// bootstrap owns all pointer arguments for the duration of this synchronous call. +#[unsafe(no_mangle)] +#[allow( + non_snake_case, + reason = "CEF's bootstrap resolves this exact export name." +)] +unsafe extern "C" fn RunWinMain( + instance: usize, + _: *mut u16, + _: i32, + sandbox: *mut c_void, + version: *mut c_void, +) -> i32 { + // SAFETY: This private DLL export is the CEF bootstrap ABI. Native startup + // checks version/sandbox, loads libcef, and handles sub-processes before Rust UI. + unsafe { + colossus_native_browser::chromium::run_windows_client( + instance, + sandbox, + version, + run_application, + ) + } +} + +unsafe extern "C" fn run_application() -> i32 { + // A Rust panic must not unwind through the CEF bootstrap's C callback. + std::panic::catch_unwind(|| { + #[cfg(feature = "browser-test-bridge")] + { + crate::run_browser_acceptance() + } + #[cfg(not(feature = "browser-test-bridge"))] + { + crate::run(); + 0 + } + }) + .unwrap_or(1) +} diff --git a/apps/desktop/src-tauri/src/browser/windows_viewer.rs b/apps/desktop/src-tauri/src/browser/windows_viewer.rs new file mode 100644 index 000000000..c99e2a698 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/windows_viewer.rs @@ -0,0 +1,102 @@ +//! Reconcile Windows viewers through the canonical run and acknowledged native detach. +use super::{ + manager::BrowserManager, + registry::{CRASHED_MESSAGE, MAX_TABS}, +}; +use crate::{dto::CommandErrorDto, state::AppState}; +use colossus_sdk::{GetRunRequest, RunStatus}; +use std::time::Duration; +use tauri::{AppHandle, Manager as _}; + +fn terminal(status: RunStatus) -> bool { + matches!( + status, + RunStatus::Completed + | RunStatus::Failed + | RunStatus::Cancelled + | RunStatus::Interrupted + | RunStatus::OutcomeUnknown + ) +} + +impl BrowserManager { + pub(super) async fn reconcile_windows_agent( + &self, + app: &AppHandle, + scope: &str, + run_id: &str, + conversation_id: &str, + ) -> Result { + // Only the selected conversation's current run can own this viewer. + // Keep its bounded recovery budget across fresh attachments, without + // accumulating counters for already replaced runs or workspaces. + self.lock()?.viewer_recoveries.retain(|id, _| id == run_id); + let existing = self + .lock()? + .tabs + .iter() + .find(|tab| { + tab.scope == scope + && tab.agent_run_id.as_deref() == Some(run_id) + && tab.dto.conversation_id.as_deref() == Some(conversation_id) + }) + .map(|tab| { + ( + tab.dto.id.clone(), + tab.dto.error.as_deref() == Some(CRASHED_MESSAGE), + ) + }); + let Some((id, crashed)) = existing else { + return Ok(true); + }; + let state = app.state::(); + let Some(target) = state.target(scope).await else { + return Ok(true); + }; + let Some(_slot) = target.try_unary_slot() else { + return Ok(true); + }; + let Ok(Ok(response)) = tokio::time::timeout( + Duration::from_secs(1), + target.client.get_run(GetRunRequest { + run_id: run_id.into(), + }), + ) + .await + else { + // Unavailable run metadata never becomes terminal/cleanup authority. + return Ok(true); + }; + if response.run.session_id != conversation_id || response.run.run_id != run_id { + return Ok(true); + } + if terminal(response.run.status) { + // Ending a viewer is distinct from claiming browser cleanup. Detach + // must acknowledge either an active writer's hidden view or Core's + // completed cleanup before any Desktop tab metadata is removed. + self.close(scope, &id).await?; + self.lock()?.viewer_recoveries.remove(run_id); + return Ok(false); + } + if !crashed { + return Ok(true); + } + { + let mut registry = self.lock()?; + if registry.viewer_recoveries.len() >= MAX_TABS + && !registry.viewer_recoveries.contains_key(run_id) + { + return Ok(true); + } + let attempts = registry.viewer_recoveries.entry(run_id.into()).or_default(); + if *attempts >= 2 { + return Ok(true); + } + *attempts += 1; + } + // Recover only presentation. The following normal watch obtains a fresh + // authenticated read-only admission to the existing Core-owned page. + self.close(scope, &id).await?; + Ok(true) + } +} diff --git a/apps/desktop/src-tauri/src/dictation.rs b/apps/desktop/src-tauri/src/dictation.rs index 97dbdcb0e..49dc47861 100644 --- a/apps/desktop/src-tauri/src/dictation.rs +++ b/apps/desktop/src-tauri/src/dictation.rs @@ -176,6 +176,8 @@ pub(crate) async fn get_dictation_settings( #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +// The IPC schema remains stable when the dictation backend is disabled. +#[cfg_attr(not(feature = "dictation"), allow(dead_code))] pub(crate) struct DictationSettingsRequest { enabled: bool, model_id: String, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 455cea604..d41ed724b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,24 @@ +#[cfg(all( + feature = "macos-contained-browser-diagnostic", + any(not(target_os = "macos"), not(debug_assertions)) +))] +compile_error!("the contained Mac browser diagnostic is Debug-only on macOS"); +#[cfg(all( + feature = "macos-contained-browser-diagnostic", + feature = "embedded-chromium-preview" +))] +compile_error!("the contained diagnostic cannot enable the in-process Chromium preview"); +#[cfg(all( + feature = "windows-browser-development", + any(not(windows), not(debug_assertions)) +))] +compile_error!("Windows browser development is Debug-only on Windows"); +#[cfg(all( + feature = "windows-browser-development", + feature = "embedded-chromium-preview" +))] +compile_error!("Windows browser development uses the separate host, not the in-process preview"); + mod cloud_connector; mod control_plane_profiles; use cloud_connector::{ @@ -6,6 +27,7 @@ use cloud_connector::{ }; use control_plane_profiles::{control_plane_profiles, save_control_plane_profiles}; mod app_context; +mod application_lifecycle; mod approval_adapter; mod browser; mod bundle; @@ -39,6 +61,7 @@ mod managed_configuration_commands; mod managed_diagnostics; mod managed_runtime; mod mcp_health; +mod native_browser_authentication; #[cfg(windows)] mod outlook_companion; mod plugin_adapter; @@ -73,6 +96,7 @@ pub fn run_browser_acceptance() -> i32 { browser::acceptance::run() } +use browser::certificates::browser_certificates; use browser::commands::{browser_command, browser_context, browser_viewport}; use codex_auth::{codex_auth_login, codex_auth_logout, codex_auth_status}; use command_review::{command_review_context, finish_command_review}; @@ -155,6 +179,9 @@ pub fn run() { if let Some(code) = uninstall::run_if_requested() { std::process::exit(code); } + // Non-browser workers never create CEF/AppKit. Browser helpers have their + // own sandbox entry; install CEF before Tauri or late-loaded UI plugins. + let browser_cache = browser::bootstrap::initialize(); let context = app_context::create(); #[cfg(windows)] let launch_guard = @@ -190,6 +217,13 @@ pub fn run() { .setup(|app| { status_bar::setup(app)?; browser::start_watchdog(app.handle().clone()); + browser::bootstrap::start_pump(app.handle())?; + #[cfg(any( + feature = "embedded-chromium-preview", + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ))] + browser::contained::install(app.handle())?; #[cfg(windows)] outlook_companion::start_watchdog(app.handle().clone()); terminal_commands::pane::start_watchdog(app.handle().clone()); @@ -233,6 +267,7 @@ pub fn run() { browser_context, browser_command, browser_viewport, + browser_certificates, command_review_context, finish_command_review, remembered_command_count, @@ -380,22 +415,26 @@ pub fn run() { .expect("failed to build the Colossus desktop application"); #[cfg(windows)] drop(launch_guard); - application.run(|app, event| { - #[cfg(target_os = "macos")] - if matches!( - event, - tauri::RunEvent::Reopen { - has_visible_windows: false, - .. - } - ) { - status_bar::show_main_window(app); - } - if matches!(event, tauri::RunEvent::ExitRequested { .. }) { - use tauri::Manager as _; - app.state::().cancel(); - - tauri::async_runtime::block_on(app.state::().close_all()); + #[cfg(desktop)] + { + // App::run exits the process inside Tao, skipping Rust local drops. + // Return from the native loop so the private Chromium cache is removed + // after CefShutdown and before the desktop process terminates. + let (callback, native_shutdown) = application_lifecycle::callback(); + let code = application.run_return(callback); + if native_shutdown.load(std::sync::atomic::Ordering::Acquire) { + drop(browser_cache); + std::process::exit(code); } - }); + // Preserve native ownership evidence when shutdown was not acknowledged. + // Process exit does not authorize deleting a possibly live CEF cache. + std::mem::forget(browser_cache); + std::process::exit(if code == 0 { 1 } else { code }); + } + #[cfg(mobile)] + { + let _ = &browser_cache; + let (callback, _) = application_lifecycle::callback(); + application.run(callback); + } } diff --git a/apps/desktop/src-tauri/src/managed_runtime.rs b/apps/desktop/src-tauri/src/managed_runtime.rs index f8fad04bb..300452974 100644 --- a/apps/desktop/src-tauri/src/managed_runtime.rs +++ b/apps/desktop/src-tauri/src/managed_runtime.rs @@ -77,6 +77,39 @@ const PRIMARY_SCOPES: [&str; 15] = [ ]; const TRUSTED_BUILTIN_TOOL_GRANT: &[&str] = &[ + "browser.open", + "browser.status", + "browser.tabs", + "browser.tab.open", + "browser.tab.select", + "browser.tab.close", + "browser.navigate", + "browser.back", + "browser.forward", + "browser.reload", + "browser.stop", + "browser.snapshot", + "browser.screenshot", + "browser.screenshot.annotated", + "browser.click", + "browser.evaluate", + "browser.cdp", + "browser.hover", + "browser.double_click", + "browser.right_click", + "browser.drag", + "browser.click_at", + "browser.drag_at", + "browser.annotate", + "browser.annotation.clear", + "browser.fill", + "browser.select", + "browser.press", + "browser.scroll", + "browser.wait", + "browser.upload", + "browser.download", + "browser.close", "workflow.definition.list", "workflow.definition.get", "workflow.schedule.list", @@ -906,6 +939,13 @@ async fn prepare_managed_bootstrap( ) })?; let worker_bootstrap_secret = worker_authentication.copy_secret(); + let native_browser_key = crate::native_browser_authentication::generate().map_err(|_| { + classified( + "runtime_authentication", + "Managed Local browser authentication could not be created.", + RuntimeFailureCodeDto::Authentication, + ) + })?; let paths = ManagedBootstrapPaths { ca_bundle: ca_bundle_path.as_deref(), codex_auth: codex_auth_path.as_deref(), @@ -923,7 +963,9 @@ async fn prepare_managed_bootstrap( &paths, companion, ) - .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Configuration))?; + .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Configuration))? + .with_native_browser_authentication(native_browser_key) + .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Authentication))?; if let Some(identity) = settings.client_identity.as_ref() { let (certificate, key) = credentials .read_client_identity(&identity.identity_id, &identity.leaf_fingerprint_sha256) @@ -1326,6 +1368,9 @@ async fn install_managed_target( ) })? .copy_secret(); + let native_browser = lifecycle + .native_browser_client(&options) + .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Authentication))?; let client = Colossus::start_sidecar(&lifecycle, options) .await .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Internal))?; @@ -1352,6 +1397,9 @@ async fn install_managed_target( .await; debug_assert!(previous.is_none()); state.configure_managed_worker_for(space_id, worker).await; + state + .configure_managed_browser_for(space_id, native_browser) + .await; state .configure_managed_terminal_for(space_id, terminal_workspace, terminal_enabled) .await; @@ -1372,7 +1420,9 @@ fn managed_worker_endpoint( worker_ipc_endpoint(&canonical.join("state.redb")) } -fn application_grant(profile: AccessProfileSetting) -> Result { +pub(crate) fn application_grant( + profile: AccessProfileSetting, +) -> Result { application_grant_for(profile, APPLICATION_ID, false) } @@ -1649,6 +1699,14 @@ mod tests { let grant = application_grant(AccessProfileSetting::Development).expect("grant"); let debug = format!("{grant:?}"); for required in [ + "browser.open", + "browser.snapshot", + "browser.screenshot", + "browser.tab.open", + "browser.tab.select", + "browser.tab.close", + "browser.upload", + "browser.download", "agent.delegate", "filesystem.read", "shell.run", @@ -1732,6 +1790,38 @@ mod tests { let debug = format!("{grant:?}"); assert!(debug.contains("echo")); assert!(!debug.contains("filesystem.read")); + assert!(!debug.contains("browser.")); + } + + #[test] + fn nonminimal_grants_admit_rich_browser_tools() { + for profile in [ + AccessProfileSetting::Pinned, + AccessProfileSetting::Development, + AccessProfileSetting::AllowAll, + ] { + for grant in [ + application_grant(profile).expect("Desktop grant"), + connector_application_grant(profile).expect("connector grant"), + ] { + let debug = format!("{grant:?}"); + for tool in [ + "browser.evaluate", + "browser.cdp", + "browser.hover", + "browser.double_click", + "browser.right_click", + "browser.drag", + "browser.click_at", + "browser.drag_at", + "browser.annotate", + "browser.annotation.clear", + "browser.screenshot.annotated", + ] { + assert!(debug.contains(tool), "{profile:?} grant missing {tool}"); + } + } + } } #[test] diff --git a/apps/desktop/src-tauri/src/native_browser_authentication.rs b/apps/desktop/src-tauri/src/native_browser_authentication.rs new file mode 100644 index 000000000..bf6e972be --- /dev/null +++ b/apps/desktop/src-tauri/src/native_browser_authentication.rs @@ -0,0 +1,37 @@ +//! Private Desktop key material for the authenticated native browser channel. + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use colossus_sdk::Secret; +use zeroize::Zeroizing; + +/// Generate the SDK's exact 32-byte visible-ASCII native browser secret. +/// +/// The inherited channel re-encodes these bytes as a 32-byte transport key. +/// Twenty-four fresh random bytes yield 32 base64url characters and retain +/// 192 bits of entropy without changing the SDK's credential validator. +pub(crate) fn generate() -> anyhow::Result { + let mut raw = Zeroizing::new([0_u8; 24]); + getrandom::fill(&mut raw[..])?; + let encoded = URL_SAFE_NO_PAD.encode(&raw[..]); + anyhow::ensure!(encoded.len() == 32, "native browser token shape changed"); + Ok(Secret::new(encoded.into_bytes())?) +} + +#[cfg(test)] +mod tests { + use super::generate; + + #[test] + fn generated_native_authentication_meets_sdk_shape_without_binary_exception() { + let secret = generate().expect("random native browser authentication"); + assert_eq!(secret.expose().len(), 32); + assert!( + secret + .expose() + .iter() + .all(|byte| (0x21..=0x7e).contains(byte)) + ); + assert!(colossus_sdk::Secret::new(vec![0_u8; 32]).is_err()); + assert!(colossus_sdk::Secret::new(vec![b' '; 32]).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs index f6bb174b4..356fbb12f 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -1,6 +1,6 @@ use colossus_sdk::{ - ApiError, Colossus, ListRunsRequest, ListRunsResponse, NativeSidecarFailure, - NativeSidecarStatus, + ApiError, Colossus, ListRunsRequest, ListRunsResponse, NativeBrowserClient, + NativeSidecarFailure, NativeSidecarStatus, }; use colossus_worker_protocol::WorkerControlClient; use std::{ @@ -351,6 +351,7 @@ struct ManagedSpaceRuntime { lifecycle_generation: AtomicU64, lifecycle: StdMutex>, worker: RwLock>, + native_browser: RwLock>, approval_mode: AtomicU8, approval_mode_synchronized: AtomicBool, approval_mode_run_guard: Arc>, @@ -379,6 +380,7 @@ impl Default for ManagedSpaceRuntime { lifecycle_generation: AtomicU64::new(0), lifecycle: StdMutex::new(None), worker: RwLock::new(None), + native_browser: RwLock::new(None), approval_mode: AtomicU8::new(DesktopApprovalModeDto::Ask as u8), approval_mode_synchronized: AtomicBool::new(false), approval_mode_run_guard: Arc::new(RwLock::new(())), @@ -910,9 +912,39 @@ impl AppState { pub(crate) async fn clear_managed_worker_for(&self, space_id: &str) { let runtime = self.managed_space_runtime(space_id).await; runtime.worker.write().await.take(); + runtime.native_browser.write().await.take(); reset_space_approval_mode(&runtime); } + pub(crate) async fn configure_managed_browser_for( + &self, + space_id: &str, + client: Option, + ) { + self.managed_space_runtime(space_id) + .await + .native_browser + .write() + .await + .clone_from(&client); + } + + #[cfg(any( + feature = "embedded-chromium-preview", + any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development" + ) + ))] + pub(crate) async fn managed_browser_for(&self, space_id: &str) -> Option { + self.existing_managed_space_runtime(space_id) + .await? + .native_browser + .read() + .await + .clone() + } + pub(crate) async fn managed_worker_for(&self, space_id: &str) -> Option { self.existing_managed_space_runtime(space_id) .await? @@ -1341,7 +1373,13 @@ impl AppState { self.approval_guard.try_lock().ok() } + #[cfg(test)] pub(crate) async fn close_all(&self) { + let _ = self.close_all_settled().await; + } + + pub(crate) async fn close_all_settled(&self) -> Result<(), crate::dto::CommandErrorDto> { + self.browser.drain_for_shutdown().await?; #[cfg(windows)] self.stop_all_outlook_companions().await; { @@ -1390,6 +1428,7 @@ impl AppState { for client in clients { let _ = client.close().await; } + Ok(()) } pub(crate) fn terminal_manager(&self) -> TerminalManager { diff --git a/apps/desktop/src-tauri/src/status_bar.rs b/apps/desktop/src-tauri/src/status_bar.rs index 1c4166d83..bcc3cd776 100644 --- a/apps/desktop/src-tauri/src/status_bar.rs +++ b/apps/desktop/src-tauri/src/status_bar.rs @@ -231,6 +231,11 @@ fn status_bar_error() -> CommandErrorDto { ) } +// Keep the same fallible setup API used by the macOS and Windows composition. +#[cfg_attr( + not(any(target_os = "macos", windows)), + allow(clippy::unnecessary_wraps) +)] pub(crate) fn setup(app: &mut App) -> tauri::Result<()> { #[cfg(any(target_os = "macos", windows))] { diff --git a/apps/desktop/src-tauri/windows.manifest b/apps/desktop/src-tauri/windows.manifest new file mode 100644 index 000000000..685916cba --- /dev/null +++ b/apps/desktop/src-tauri/windows.manifest @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 118cc20b1..014d5cb5e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -987,9 +987,15 @@ export default function App() { const [conversationSkills, setConversationSkills] = useState< Record >({}); + const [browserConversation, setBrowserConversation] = useState<{ + targetId: string; + sessionId: string; + } | null>(null); const selectionSession = chat.activeRunId === null - ? undefined + ? browserConversation?.targetId === desktop.selectedTargetId + ? browserConversation.sessionId + : undefined : chat.views.get(chat.activeRunId)?.run.sessionId; const selectionKey = pluginSelectionKey( desktop.selectedTargetId, @@ -2396,6 +2402,7 @@ export default function App() { return; } setWorkNavigationOpen(false); + setBrowserConversation(null); setConversationSkills((current) => ({ ...current, [pluginSelectionKey(desktop.selectedTargetId, undefined)]: [], @@ -2988,7 +2995,10 @@ export default function App() { const sessionId = activeForkDraft === undefined - ? continuationView?.run.sessionId + ? (continuationView?.run.sessionId ?? + (browserConversation?.targetId === route.targetId + ? browserConversation.sessionId + : undefined)) : undefined; if (sessionId) setThreadQueuePaused(route.targetId, sessionId, false); const effectiveMode: RunMode = planRevision === null ? mode : "plan"; @@ -5964,6 +5974,19 @@ export default function App() { gitWorkspaceId={desktop.workspace?.workspaceId ?? null} gitAvailable={desktop.capabilities.files} browserScope={desktop.selectedTargetId} + browserConversationId={ + browserConversation?.targetId === desktop.selectedTargetId + ? browserConversation.sessionId + : undefined + } + onUseBrowserConversation={(sessionId) => { + if (!desktop.selectedTargetId || submitInFlight.current) return; + newWork(); + setBrowserConversation({ + targetId: desktop.selectedTargetId, + sessionId, + }); + }} processSessionsAvailable={ desktop.capabilities.processSessions === true } diff --git a/apps/desktop/src/aside-pane-width.test.ts b/apps/desktop/src/aside-pane-width.test.ts index 94a09f8a1..719147d00 100644 --- a/apps/desktop/src/aside-pane-width.test.ts +++ b/apps/desktop/src/aside-pane-width.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { - MAX_ASIDE_PANE_WIDTH, MIN_ASIDE_PANE_WIDTH, clampAsidePaneWidth, defaultAsidePaneWidth, + maximumAsidePaneWidth, } from "./aside-pane-width"; describe("Aside pane width", () => { @@ -14,7 +14,9 @@ describe("Aside pane width", () => { it("preserves a usable thread while constraining the Aside", () => { expect(clampAsidePaneWidth(50, 1_200)).toBe(MIN_ASIDE_PANE_WIDTH); - expect(clampAsidePaneWidth(2_000, 1_200)).toBe(MAX_ASIDE_PANE_WIDTH); + expect(clampAsidePaneWidth(2_000, 1_200)).toBe(802); expect(clampAsidePaneWidth(600, 700)).toBe(302); + expect(clampAsidePaneWidth(1_200, 1_800)).toBe(1_200); + expect(maximumAsidePaneWidth(1_800)).toBe(1_402); }); }); diff --git a/apps/desktop/src/aside-pane-width.ts b/apps/desktop/src/aside-pane-width.ts index c10c66b9e..fa8d44d3b 100644 --- a/apps/desktop/src/aside-pane-width.ts +++ b/apps/desktop/src/aside-pane-width.ts @@ -1,11 +1,17 @@ export const MIN_ASIDE_PANE_WIDTH = 280; -export const MAX_ASIDE_PANE_WIDTH = 760; const MIN_WORK_THREAD_WIDTH = 390; const ASIDE_RESIZE_HANDLE_WIDTH = 8; const DEFAULT_ASIDE_PANE_RATIO = 0.53; const STORAGE_KEY = "colossus.aside-pane-width:v1"; +export function maximumAsidePaneWidth(layoutWidth: number): number { + return Math.max( + MIN_ASIDE_PANE_WIDTH, + Math.floor(layoutWidth - MIN_WORK_THREAD_WIDTH - ASIDE_RESIZE_HANDLE_WIDTH), + ); +} + export function defaultAsidePaneWidth(layoutWidth: number): number { return clampAsidePaneWidth( layoutWidth * DEFAULT_ASIDE_PANE_RATIO, @@ -17,13 +23,7 @@ export function clampAsidePaneWidth( width: number, layoutWidth: number, ): number { - const responsiveMaximum = Math.max( - MIN_ASIDE_PANE_WIDTH, - Math.min( - MAX_ASIDE_PANE_WIDTH, - layoutWidth - MIN_WORK_THREAD_WIDTH - ASIDE_RESIZE_HANDLE_WIDTH, - ), - ); + const responsiveMaximum = maximumAsidePaneWidth(layoutWidth); return Math.round( Math.min(responsiveMaximum, Math.max(MIN_ASIDE_PANE_WIDTH, width)), ); diff --git a/apps/desktop/src/browser-api.ts b/apps/desktop/src/browser-api.ts index 287ed0a15..032d921a1 100644 --- a/apps/desktop/src/browser-api.ts +++ b/apps/desktop/src/browser-api.ts @@ -10,17 +10,50 @@ export interface BrowserTab { error: string | null; notice: string | null; popupUrl: string | null; + sessionId?: string; + conversationId?: string | null; + control?: "human" | "agent" | "paused" | "unavailable"; } +export interface BrowserEngine { + kind: + | "embedded_chromium" + | "contained_chromium" + | "owned_chromium" + | "webview2" + | "webkit" + | "unavailable"; + preview: boolean; + ready: boolean; + message: string | null; + agentControlAvailable: boolean; +} + +export interface BrowserCertificateStatus { + scope: "operating_system_user" | "unsupported"; + caImportAvailable: boolean; + pfxImportAvailable: boolean; + clientIdentityReviewAvailable?: boolean; + clientIdentitySelectionReady: boolean; + acceptancePending: boolean; + message: string; + fingerprintsSha256: string[]; +} + +export type BrowserCertificateAction = + "status" | "import_ca" | "import_client_identity" | "review_client_identity"; + export interface BrowserSnapshot { available: boolean; generation: number; tabs: BrowserTab[]; selectedTabId: string | null; + engine?: BrowserEngine; } export type BrowserAction = - | { type: "new"; url: string } + | { type: "new"; url: string; conversationId?: string } + | { type: "handoff"; tabId: string; runId: string } | { type: "navigate"; tabId: string; url: string } | { type: @@ -44,16 +77,30 @@ export interface BrowserViewport { } export interface BrowserApi { - context(): Promise; + context(runId?: string, conversationId?: string): Promise; command(generation: number, action: BrowserAction): Promise; viewport(request: BrowserViewport): Promise; + certificates?( + generation: number, + action: BrowserCertificateAction, + appearance: { + colorScheme: "system" | "dark" | "light"; + textSize: "compact" | "comfortable" | "large"; + }, + tabId?: string, + ): Promise; } export const nativeBrowserApi: BrowserApi = { - context: () => invoke("browser_context"), + context: (runId, conversationId) => + invoke("browser_context", { runId, conversationId }), command: (generation, action) => invoke("browser_command", { request: { generation, action } }), viewport: (request) => invoke("browser_viewport", { request }), + certificates: (generation, action, appearance, tabId) => + invoke("browser_certificates", { + request: { generation, action, appearance, tabId }, + }), }; export function browserErrorMessage(error: unknown): string { @@ -64,5 +111,5 @@ export function browserErrorMessage(error: unknown): string { typeof error.message === "string" ) return error.message; - return "The browser could not complete that action. Please try again."; + return "The browser action failed. Please try again."; } diff --git a/apps/desktop/src/components/WorkSurface.test.ts b/apps/desktop/src/components/WorkSurface.test.ts index cbdef38cb..3a91e2ef0 100644 --- a/apps/desktop/src/components/WorkSurface.test.ts +++ b/apps/desktop/src/components/WorkSurface.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RunView } from "../state"; import type { Run } from "../types"; import type { ArtifactViewItem } from "./ArtifactWorkspace"; +import { AppearanceProvider } from "../theme/AppearanceProvider"; import { WorkSurface } from "./WorkSurface"; import type { ToolOption } from "./tools/ToolSwitcher"; @@ -80,71 +81,72 @@ function renderSurface( }), }); try { + const surface = createElement(WorkSurface, { + title: "Primary", + view: renderedView, + conversationViews: renderedView === undefined ? [] : [renderedView], + connection: { + state: "connected", + message: "Connected securely.", + targetId: "managed-local", + }, + connecting: false, + runLoadError: "", + actionError: null, + participants: [], + selectedParticipantId: null, + delegateView: undefined, + delegateInspection: null, + delegateLoading: false, + delegateError: "", + sessionMap: null, + sessionMapLoading: false, + sessionMapError: "", + artifacts, + selectedSpaceName: "Colossus", + threadPinned: true, + followRequestSequence: 0, + composer: () => createElement("div"), + filesPanel: createElement("div", null, "Workspace file explorer"), + filesAvailable: capabilities.files, + browserScope: "external-runtime", + ...(processSessionsAvailable === undefined + ? {} + : { processSessionsAvailable }), + onOpenWorkspaceFile: vi.fn(), + artifactsAvailable: capabilities.artifacts, + asideView: undefined, + asideConversationViews: [], + asideHistory: [], + asideBusy: false, + asideError: null, + asideReadOnly: false, + planContinuationAvailable: false, + planWorkflowAvailable: false, + activityComparisonEnabled, + workNavigationOpen: false, + onConnect: vi.fn(), + onRespond: vi.fn(async () => undefined), + onResume: vi.fn(), + onSuggestion: vi.fn(), + onSelectParticipant: vi.fn(), + onBackToThreadDetails: vi.fn(), + onSelectArtifact: vi.fn(), + onOpenPlanWorkflow: vi.fn(), + onRevisePlan: vi.fn(), + onExecutePlan: vi.fn(), + onOpenWorkNavigation: vi.fn(), + onCloseWorkNavigation: vi.fn(), + onLoadAsides: vi.fn(async () => undefined), + onCreateAside: vi.fn(async () => true), + onContinueAside: vi.fn(async () => true), + onOpenAside: vi.fn(async () => undefined), + onNewAside: vi.fn(), + onRespondAside: vi.fn(async () => undefined), + onCloseAside: vi.fn(async () => true), + }); return renderToStaticMarkup( - createElement(WorkSurface, { - title: "Primary", - view: renderedView, - conversationViews: renderedView === undefined ? [] : [renderedView], - connection: { - state: "connected", - message: "Connected securely.", - targetId: "managed-local", - }, - connecting: false, - runLoadError: "", - actionError: null, - participants: [], - selectedParticipantId: null, - delegateView: undefined, - delegateInspection: null, - delegateLoading: false, - delegateError: "", - sessionMap: null, - sessionMapLoading: false, - sessionMapError: "", - artifacts, - selectedSpaceName: "Colossus", - threadPinned: true, - followRequestSequence: 0, - composer: () => createElement("div"), - filesPanel: createElement("div", null, "Workspace file explorer"), - filesAvailable: capabilities.files, - browserScope: "external-runtime", - ...(processSessionsAvailable === undefined - ? {} - : { processSessionsAvailable }), - onOpenWorkspaceFile: vi.fn(), - artifactsAvailable: capabilities.artifacts, - asideView: undefined, - asideConversationViews: [], - asideHistory: [], - asideBusy: false, - asideError: null, - asideReadOnly: false, - planContinuationAvailable: false, - planWorkflowAvailable: false, - activityComparisonEnabled, - workNavigationOpen: false, - onConnect: vi.fn(), - onRespond: vi.fn(async () => undefined), - onResume: vi.fn(), - onSuggestion: vi.fn(), - onSelectParticipant: vi.fn(), - onBackToThreadDetails: vi.fn(), - onSelectArtifact: vi.fn(), - onOpenPlanWorkflow: vi.fn(), - onRevisePlan: vi.fn(), - onExecutePlan: vi.fn(), - onOpenWorkNavigation: vi.fn(), - onCloseWorkNavigation: vi.fn(), - onLoadAsides: vi.fn(async () => undefined), - onCreateAside: vi.fn(async () => true), - onContinueAside: vi.fn(async () => true), - onOpenAside: vi.fn(async () => undefined), - onNewAside: vi.fn(), - onRespondAside: vi.fn(async () => undefined), - onCloseAside: vi.fn(async () => true), - }), + createElement(AppearanceProvider, null, surface), ); } finally { vi.unstubAllGlobals(); diff --git a/apps/desktop/src/components/WorkSurface.tsx b/apps/desktop/src/components/WorkSurface.tsx index 9211bb4f1..2d8af2f2e 100644 --- a/apps/desktop/src/components/WorkSurface.tsx +++ b/apps/desktop/src/components/WorkSurface.tsx @@ -35,11 +35,11 @@ import { useShellSessions, shellIsActive } from "../shellSessions"; import { WorkWelcome } from "@colossus/ui"; import { WorkSurfaceHeader } from "@colossus/ui/conversation"; import { - MAX_ASIDE_PANE_WIDTH, MIN_ASIDE_PANE_WIDTH, clampAsidePaneWidth, clearStoredAsidePaneWidth, defaultAsidePaneWidth, + maximumAsidePaneWidth, readStoredAsidePaneWidth, storeAsidePaneWidth, } from "../aside-pane-width"; @@ -111,6 +111,8 @@ interface WorkSurfaceProps { gitWorkspaceId?: string | null; gitAvailable?: boolean; browserScope?: string | null; + browserConversationId?: string | undefined; + onUseBrowserConversation?: ((sessionId: string) => void) | undefined; processSessionsAvailable?: boolean; browserFixture?: boolean; terminalSupported?: boolean; @@ -231,6 +233,8 @@ export function WorkSurface({ filesPanel, filesAvailable, browserScope = null, + browserConversationId, + onUseBrowserConversation, processSessionsAvailable = false, browserFixture = false, terminalSupported = false, @@ -313,6 +317,8 @@ export function WorkSurface({ browserScope, activeDrawer === "browser", browserFixture, + view?.run.sessionId ?? browserConversationId, + view?.run.runId, ); const browserTriggerRef = useRef(null); const [selectionLauncher, setSelectionLauncher] = useState< @@ -326,6 +332,8 @@ export function WorkSurface({ null, ); const [browserPaneWidth, setBrowserPaneWidth] = useState(null); + const [asidePaneMaximum, setAsidePaneMaximum] = + useState(MIN_ASIDE_PANE_WIDTH); const [filesPaneWidth, setFilesPaneWidth] = useState(null); const [filesExpanded, setFilesExpanded] = useState(false); const [gitPaneWidth, setGitPaneWidth] = useState(null); @@ -595,6 +603,7 @@ export function WorkSurface({ if (layoutWidth <= 0) { return; } + setAsidePaneMaximum(maximumAsidePaneWidth(layoutWidth)); setAsidePaneWidth((current) => clampAsidePaneWidth( current ?? @@ -1478,7 +1487,7 @@ export function WorkSurface({ } aria-orientation="vertical" aria-valuemin={MIN_ASIDE_PANE_WIDTH} - aria-valuemax={MAX_ASIDE_PANE_WIDTH} + aria-valuemax={asidePaneMaximum} aria-valuenow={asidePaneWidth ?? MIN_ASIDE_PANE_WIDTH} title="Drag to resize. Double-click to reset." tabIndex={0} @@ -1531,7 +1540,7 @@ export function WorkSurface({ } else if (event.key === "Home") { nextWidth = MIN_ASIDE_PANE_WIDTH; } else if (event.key === "End") { - nextWidth = MAX_ASIDE_PANE_WIDTH; + nextWidth = maximumAsidePaneWidth(layoutWidth); } if (nextWidth !== null) { event.preventDefault(); @@ -1693,6 +1702,12 @@ export function WorkSurface({ > setBrowserExpanded((expanded) => !expanded)} diff --git a/apps/desktop/src/components/browser/BrowserCertificates.tsx b/apps/desktop/src/components/browser/BrowserCertificates.tsx new file mode 100644 index 000000000..3a5826a56 --- /dev/null +++ b/apps/desktop/src/components/browser/BrowserCertificates.tsx @@ -0,0 +1,120 @@ +/** @jsxRuntime classic */ +/** @jsx element */ +// The classic JSX transform keeps this bounded browser surface compact. +import { element } from "./browser-jsx"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@colossus/ui/components/Controls"; +import type { + BrowserCertificateAction, + BrowserCertificateStatus, +} from "../../browser-api"; +import { browserErrorMessage } from "../../browser-api"; +import type { BrowserController } from "./useBrowser"; + +export function BrowserCertificates({ + controller, + onClose, +}: { + controller: BrowserController; + onClose: () => void; +}) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const closeRef = useRef(null); + const operation = useRef(0); + async function perform(action: BrowserCertificateAction) { + const sequence = ++operation.current; + setBusy(true); + setError(""); + try { + const status = await controller.certificates(action); + if (sequence === operation.current) setStatus(status); + } catch (cause) { + if (sequence === operation.current) setError(browserErrorMessage(cause)); + } finally { + if (sequence === operation.current) setBusy(false); + } + } + useEffect(() => { + closeRef.current?.focus(); + void perform("status"); + return () => { + ++operation.current; + }; + // Scope and controller generation are bound in the host request, not derived here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }} + > +
+

Browser certificates

+ +
+

+ {error || status?.message || "Loading certificates…"} +

+ {status ? ( +

+ Trust scope:{" "} + {status.scope === "operating_system_user" + ? "Operating-system user store" + : "Unavailable"} + . Installed key-use acceptance:{" "} + {status.clientIdentitySelectionReady ? "Complete" : "Pending"}.{" "} + {status.acceptancePending ? "Native acceptance pending." : ""} +

+ ) : null} + {status?.fingerprintsSha256.length ? ( +
+

Fingerprints (SHA-256)

+
{status.fingerprintsSha256.join("\n")}
+
+ ) : null} +
+ {( + [ + ["import_ca", "Import CA certificate", status?.caImportAvailable], + [ + "import_client_identity", + "Import PKCS#12 identity", + status?.pfxImportAvailable, + ], + [ + "review_client_identity", + "Review tab client identity", + status?.clientIdentityReviewAvailable && + controller.snapshot.selectedTabId, + ], + ] as const + ).map(([action, label, available]) => ( + + ))} +
+

+ Native dialogs handle files and passphrases. Imports affect other apps + and do not authorize agents. +

+
+ ); +} diff --git a/apps/desktop/src/components/browser/BrowserPane.test.ts b/apps/desktop/src/components/browser/BrowserPane.test.ts index 3550061b9..99af87c55 100644 --- a/apps/desktop/src/components/browser/BrowserPane.test.ts +++ b/apps/desktop/src/components/browser/BrowserPane.test.ts @@ -6,6 +6,8 @@ import type { BrowserController } from "./useBrowser"; function render( overrides: Partial = {}, + snapshotOverrides: Partial = {}, + props: Partial[0]> = {}, ) { const controller: BrowserController = { snapshot: { @@ -26,6 +28,7 @@ function render( ...overrides, }, ], + ...snapshotOverrides, }, error: "", busy: false, @@ -33,6 +36,15 @@ function render( fixture: true, command: vi.fn(async () => {}), viewport: vi.fn(async () => {}), + certificates: vi.fn(async () => ({ + scope: "unsupported" as const, + caImportAvailable: false, + pfxImportAvailable: false, + clientIdentitySelectionReady: false, + acceptancePending: true, + message: "Unavailable", + fingerprintsSha256: [], + })), }; return renderToStaticMarkup( createElement(BrowserPane, { @@ -40,11 +52,75 @@ function render( expanded: false, onExpand: vi.fn(), onClose: vi.fn(), + ...props, }), ); } describe("browser controls", () => { + it("offers handoff only for an accepted page in the run's canonical conversation", () => { + const engine = { + kind: "embedded_chromium" as const, + preview: false, + ready: true, + message: null, + agentControlAvailable: true, + }; + const props = { run: { runId: "run-1", sessionId: "conversation-1" } }; + expect( + render( + { control: "human", conversationId: "conversation-1" }, + { engine }, + props, + ), + ).toContain("Give agent control"); + expect( + render( + { control: "human", conversationId: "foreign-conversation" }, + { engine }, + props, + ), + ).not.toContain("Give agent control"); + expect( + render( + { control: "human", conversationId: "conversation-1" }, + { engine: { ...engine, preview: true } }, + props, + ), + ).not.toContain("Give agent control"); + }); + + it("makes agent and uncertain handoff views read-only while preserving close", () => { + for (const control of ["agent", "paused"] as const) { + const html = render({ control, canGoBack: true, loading: true }); + expect(html).toContain('readOnly=""'); + expect(html).toMatch(/aria-label="Go back" disabled/); + expect(html).toMatch(/aria-label="Stop loading" disabled/); + expect(html).not.toMatch(/aria-label="Close browser pane" disabled/); + } + }); + it("reports a missing Chromium component without claiming a working browser", () => { + const html = render( + {}, + { + available: false, + engine: { + kind: "embedded_chromium", + preview: true, + ready: false, + message: + "The verified Chromium component is not included in this build.", + agentControlAvailable: false, + }, + }, + ); + expect(html).toContain("Browser unavailable"); + expect(html).toContain("Control unavailable"); + expect(html).not.toContain("Human control"); + expect(html).toContain("The verified Chromium component is not included"); + expect(html).not.toContain("Chromium preview ·"); + expect(html).not.toContain("Resume agent"); + }); it("keeps stop and close available while a page loads", () => { const html = render({ loading: true }); expect(html).toContain('aria-label="Stop loading"'); diff --git a/apps/desktop/src/components/browser/BrowserPane.tsx b/apps/desktop/src/components/browser/BrowserPane.tsx index 8d4e5dccb..c8074ea5b 100644 --- a/apps/desktop/src/components/browser/BrowserPane.tsx +++ b/apps/desktop/src/components/browser/BrowserPane.tsx @@ -1,28 +1,63 @@ +/** @jsxRuntime classic */ +/** @jsx element */ +/** @jsxFrag Fragment */ +// The classic JSX transform keeps this bounded browser surface compact. +import { element, Fragment } from "./browser-jsx"; import { useEffect, useRef, useState } from "react"; +import type { TablerIcon } from "@tabler/icons-react"; import { IconArrowLeft, IconArrowRight, IconArrowsMaximize, IconArrowsMinimize, IconExternalLink, - IconGlobe, + IconWorld, IconLoader2, IconPlus, IconRefresh, IconX, } from "@tabler/icons-react"; import type { BrowserController } from "./useBrowser"; +import { BrowserCertificates } from "./BrowserCertificates"; import { useDesktopPreferences } from "../../DesktopPreferencesProvider"; import "./browser.css"; +function IconButton({ + label, + icon: Icon, + disabled, + onClick, +}: { + label: string; + icon: TablerIcon; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + export function BrowserPane({ controller, + run, + onUseConversation, docked = false, expanded, onExpand, onClose, }: { controller: BrowserController; + run?: { runId: string; sessionId: string } | undefined; + onUseConversation?: ((sessionId: string) => void) | undefined; docked?: boolean; expanded: boolean; onExpand: () => void; @@ -32,14 +67,46 @@ export function BrowserPane({ const { snapshot, command, error, busy, fixture, viewport } = controller; const active = snapshot.tabs.find((tab) => tab.id === snapshot.selectedTabId); const [address, setAddress] = useState(active?.url ?? ""); + const [showCertificates, setShowCertificates] = useState(false); const addressRef = useRef(null); const selectedTabRef = useRef(null); const viewportRef = useRef(null); + const ready = + snapshot.available && + snapshot.engine?.ready !== false && + snapshot.engine?.kind !== "unavailable"; + const engineLabel = !ready + ? "Browser unavailable" + : !snapshot.engine + ? "Browser" + : { + embedded_chromium: snapshot.engine.preview + ? "Chromium preview" + : "Chromium", + contained_chromium: snapshot.engine.preview + ? "Chromium preview" + : "Chromium", + owned_chromium: "Chromium (development)", + webview2: "WebView2", + webkit: "WebKit", + unavailable: "Browser unavailable", + }[snapshot.engine?.kind ?? "unavailable"]; + const controlLabel = { + human: "Human control", + agent: "Agent control", + paused: "Paused", + unavailable: "Control unavailable", + }[ready ? (active?.control ?? "human") : "unavailable"]; + const readOnly = active?.control === "agent" || active?.control === "paused"; useEffect(() => { setAddress(active?.url ?? ""); }, [active?.id, active?.url]); + useEffect(() => { + if (!snapshot.available) setShowCertificates(false); + }, [snapshot.available]); + useEffect(() => { selectedTabRef.current?.scrollIntoView({ block: "nearest", @@ -49,31 +116,26 @@ export function BrowserPane({ useEffect(() => { const element = viewportRef.current; - if ( - element === null || - active === undefined || - active.url === "" || - active.error !== null - ) - return; + if (!element || !active || !active.url || active.error !== null) return; let disposed = false; let sending = false; const generation = snapshot.generation; const tabId = active.id; + const hide = () => viewport({ generation, tabId: null, rect: null }); async function update() { - if (disposed || sending || element === null) return; - const overlays = Array.from( - document.querySelectorAll( + if (disposed || sending) return; + const overlays = [ + ...document.querySelectorAll( '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-browser-occluded]', ), - ); + ]; const occluded = document.hidden || overlays.some( (overlay) => !overlay.contains(element) && overlay.getClientRects().length > 0, ); - const bounds = element.getBoundingClientRect(); + const bounds = element!.getBoundingClientRect(); sending = true; await viewport({ generation, @@ -89,7 +151,7 @@ export function BrowserPane({ }, }); sending = false; - if (disposed) await viewport({ generation, tabId: null, rect: null }); + if (disposed) await hide(); } const observer = new ResizeObserver(() => void update()); const mutations = new MutationObserver(() => void update()); @@ -106,17 +168,21 @@ export function BrowserPane({ ], }); const timer = window.setInterval(() => void update(), 400); - window.addEventListener("resize", update); - document.addEventListener("visibilitychange", update); + const listeners = [ + [window, "resize"], + [document, "visibilitychange"], + ] as const; + for (const [target, event] of listeners) + target.addEventListener(event, update); void update(); return () => { disposed = true; observer.disconnect(); mutations.disconnect(); window.clearInterval(timer); - window.removeEventListener("resize", update); - document.removeEventListener("visibilitychange", update); - void viewport({ generation, tabId: null, rect: null }); + for (const [target, event] of listeners) + target.removeEventListener(event, update); + void hide(); }; }, [active?.id, active?.url, active?.error, snapshot.generation, viewport]); @@ -138,7 +204,7 @@ export function BrowserPane({
{snapshot.tabs.length === 0 ? ( - ) : null} @@ -161,7 +227,7 @@ export function BrowserPane({ aria-hidden="true" /> ) : ( -
))} - + />
{ event.preventDefault(); - if (address.trim()) + if (snapshot.available && address.trim() && !readOnly) void command( - active + active?.url ? { type: "navigate", tabId: active.id, url: address } : { type: "new", url: address }, ); }} > - - - + {( + [ + ["back", "Go back", IconArrowLeft, !active?.canGoBack], + ["forward", "Go forward", IconArrowRight, !active?.canGoForward], + [ + active?.loading ? "stop" : "reload", + active?.loading ? "Stop loading" : "Reload page", + active?.loading ? IconX : IconRefresh, + !active?.url, + ], + ] as const + ).map(([type, label, icon, disabled], index) => ( + active && void command({ type, tabId: active.id })} + /> + ))} setAddress(event.target.value)} /> - + /> {active?.url.startsWith("http:") ? (

HTTP connection · Not encrypted

@@ -288,6 +320,11 @@ export function BrowserPane({ {error} ) : null} + {snapshot.engine?.message ? ( +
+ {snapshot.engine.message} +
+ ) : null} {active?.notice ? (
{active.notice} @@ -305,54 +342,70 @@ export function BrowserPane({ ) : null} - + />
) : null}
- {active?.error ? ( -
- -

Unable to display this page

-

{active.error}

- -
- ) : !active?.url ? ( -
- -

Browse beside your work

-

Open a website or local preview using the address bar.

- - Your browsing session ends when you close its last tab or exit - Colossus. - -
- ) : fixture ? ( -
- -

{active.title}

-

{active.url}

- Native page content appears here in the Desktop app. + {showCertificates ? ( + setShowCertificates(false)} + /> + ) : null} + {active?.error || !active?.url || (import.meta.env.DEV && fixture) ? ( +
+ +

+ {active?.error + ? "Unable to display this page" + : !active?.url + ? "Browse beside your work" + : import.meta.env.DEV + ? active.title + : ""} +

+

+ {active?.error || + (!active?.url + ? "Open a website or local preview in the address bar." + : import.meta.env.DEV + ? active.url + : "")} +

+ {active?.error ? ( + + ) : ( + + {!active?.url + ? "This session ends when you close its last tab or exit Colossus." + : import.meta.env.DEV + ? "Native page content appears here in the Desktop app." + : ""} + + )}
) : null}