From d714d59f7d5c784b21ff9cdc5212a1e5adfb5ea8 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey <8761706+killswitch-GUI@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:46:53 -0400 Subject: [PATCH 1/3] fix(windows): repair browser startup and computer focus guidance --- .github/workflows/premerge.yml | 2 +- .../colossus/skills/computer-use/SKILL.md | 30 ++-- crates/colossus-api-runtime/src/service.rs | 29 +++- crates/colossus-cli/tests/ci_contract.rs | 2 +- crates/colossus-ports/src/computer.rs | 4 +- .../src/browser_package/windows_preview.rs | 136 ++++++++++++++---- .../tests/browser_windows_native.rs | 30 +++- .../tests/browser_windows_native/exercise.rs | 7 + .../tests/browser_windows_native/logging.rs | 60 ++++++++ crates/colossus-tools/src/computer.rs | 10 +- crates/colossus-windows-native/Cargo.toml | 1 + crates/colossus-windows-native/src/lib.rs | 2 +- crates/colossus-windows-native/src/windows.rs | 2 + .../src/windows/directory_creation.rs | 18 +++ .../src/windows/profile.rs | 72 ++++++++++ .../src/windows/profile/tests.rs | 124 ++++++++++++++++ docs/develop/testing.md | 6 + native/browser/src/host.cc | 8 ++ .../computer/examples/computer_acceptance.rs | 83 ++++++++++- scripts/browser-native-host-windows.ps1 | 17 ++- scripts/ci/browser-windows-native.ps1 | 21 ++- 21 files changed, 606 insertions(+), 58 deletions(-) create mode 100644 crates/colossus-sandbox/tests/browser_windows_native/logging.rs create mode 100644 crates/colossus-windows-native/src/windows/profile.rs create mode 100644 crates/colossus-windows-native/src/windows/profile/tests.rs diff --git a/.github/workflows/premerge.yml b/.github/workflows/premerge.yml index 2244a54aa..370b826fc 100644 --- a/.github/workflows/premerge.yml +++ b/.github/workflows/premerge.yml @@ -655,7 +655,7 @@ jobs: continue-on-error: true timeout-minutes: 45 shell: pwsh - run: ./scripts/ci/browser-windows-native.ps1 + run: ./scripts/ci/browser-windows-native.ps1 -HostProfile Release - name: Retain bounded owned Windows browser evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/bundled-plugins/colossus/skills/computer-use/SKILL.md b/bundled-plugins/colossus/skills/computer-use/SKILL.md index 5fb9355bd..d25812326 100644 --- a/bundled-plugins/colossus/skills/computer-use/SKILL.md +++ b/bundled-plugins/colossus/skills/computer-use/SKILL.md @@ -15,17 +15,25 @@ not confine actions performed through another app's interface. 1. Call `computer.windows` and select an existing window from the returned list. Use its opaque `window_id` with `computer.open`. Never invent window handles, - process IDs, element IDs, session IDs, or control generations. -2. Call `computer.snapshot` for accessible controls or `computer.screenshot` for - visual content. Screenshots return an image to image-capable models and an - inline preview to the user; a separate image upload is unnecessary. -3. Choose one action using the returned session, generation, and observation ID. + process IDs, element IDs, session IDs, or control generations. Opening control + does not activate the window. +2. Call `computer.snapshot` for accessible controls and current foreground metadata. +3. Find the entry in `observation.windows` whose `window_id` matches + `observation.window_id`. If its `foreground` is `false`, use `computer.focus` + with that observed window ID and the fresh observation. Then take a new + snapshot before any other input. Snapshots and screenshots can read background + windows; they do not activate them, and clicks do not focus them automatically. +4. Choose one action using the returned session, generation, and observation ID. Coordinates are physical pixels local to the captured window, including its frame, not coordinates on the full desktop. Prefer an observed element for a click when accessible semantics identify it accurately. -4. Observe again after every action. Each input consumes its observation; moving - or resizing the window, switching apps, or changing control invalidates old - observations. A successful input receipt does not prove the intended result. +5. Observe again after every action. Each input, including focus, consumes its + observation; moving or resizing the window, switching apps, or changing control + invalidates old observations. A successful input receipt does not prove the + intended result. + +Use `computer.screenshot` for visual content. It returns an image to image-capable +models and an inline preview to the user; a separate image upload is unnecessary. Use only the observation from the latest `computer.snapshot` or `computer.screenshot`; another snapshot or screenshot replaces the previous one. @@ -59,7 +67,11 @@ control. Do not repeatedly poll or try other input routes. A new run starts with no computer session: enumerate and open again rather than reusing a previous run's session, window, or observation IDs. -On a stale observation, inspect the current session before choosing another action. +On a stale observation, inspect `computer.status` and take a fresh snapshot before +choosing another action. Check the selected window's `foreground` flag and follow +the explicit focus sequence above if it is in the background. A fresh snapshot +alone does not fix missing focus; stale can also mean expired evidence, changed +geometry, or changed control, so do not assume that focus is always the cause. On an unknown external outcome, inspect the resulting app state and report the uncertainty. Never automatically repeat a click, submission, or text entry whose outcome is unknown. If the desktop is locked, Windows requests elevation, the app diff --git a/crates/colossus-api-runtime/src/service.rs b/crates/colossus-api-runtime/src/service.rs index 13d46bfbe..c65038063 100644 --- a/crates/colossus-api-runtime/src/service.rs +++ b/crates/colossus-api-runtime/src/service.rs @@ -2306,7 +2306,7 @@ fn released_computer_error(output: &str) -> Option { ( ComputerDriverError::Stale, "target_or_observation_changed", - "The computer target or observation changed. Check computer.status and take a fresh snapshot before choosing another action.", + "The computer target or observation changed. Check computer.status and take a fresh snapshot. If the selected window is in the background, use computer.focus with its observed window_id, then take a new snapshot before input.", ), ( ComputerDriverError::InvalidInput, @@ -2328,6 +2328,9 @@ fn released_computer_error(output: &str) -> Option { colossus_policy::GatewayError::Execution(native.to_string()).to_string(); let released_message = error.get("message").and_then(serde_json::Value::as_str); if released_message == Some(native_message.as_str()) + // Existing history retains this exact pre-guidance native message. + || (native == ComputerDriverError::Stale + && released_message == Some("effect failed: computer target or observation is stale")) || (error.get("reason").and_then(serde_json::Value::as_str) == Some(reason) && released_message == Some(guidance)) { @@ -3726,10 +3729,28 @@ mod tests { ); assert_eq!(released_computer_error(&preview), Some(preview)); } + let stale = ComputerDriverError::Stale; + let current_message = format!("effect failed: {stale}"); + let output = |message: &str| { + serde_json::json!({"error": { + "type": "execution_error", "message": message, "recoverable": true, + }}) + .to_string() + }; + assert_eq!( + released_computer_error(&output( + "effect failed: computer target or observation is stale" + )), + released_computer_error(&output(¤t_message)), + "exact historical native Stale errors retain the current safe recovery guidance" + ); for message in [ - "computer target or observation is stale", - "effect failed: computer target or observation is stale PRIVATE_SUFFIX", - "PRIVATE_PREFIX effect failed: computer target or observation is stale", + stale.to_string(), + format!("{current_message} PRIVATE_SUFFIX"), + format!("PRIVATE_PREFIX {current_message}"), + "computer target or observation is stale".into(), + "effect failed: computer target or observation is stale PRIVATE_SUFFIX".into(), + "PRIVATE_PREFIX effect failed: computer target or observation is stale".into(), ] { let output = serde_json::json!({"error": { "type": "execution_error", "message": message, "recoverable": true, diff --git a/crates/colossus-cli/tests/ci_contract.rs b/crates/colossus-cli/tests/ci_contract.rs index 35c96c603..e91c9ff3a 100644 --- a/crates/colossus-cli/tests/ci_contract.rs +++ b/crates/colossus-cli/tests/ci_contract.rs @@ -1831,7 +1831,7 @@ fn owned_windows_chromium_is_real_required_native_acceptance_with_bounded_eviden assert_eq!(field(native, "continue-on-error").as_bool(), Some(true)); assert_eq!( field(native, "run").as_str(), - Some("./scripts/ci/browser-windows-native.ps1") + Some("./scripts/ci/browser-windows-native.ps1 -HostProfile Release") ); let aggregate = named_step(desktop, "Require every Windows Desktop acceptance check"); assert_eq!( diff --git a/crates/colossus-ports/src/computer.rs b/crates/colossus-ports/src/computer.rs index 723746ebe..135477652 100644 --- a/crates/colossus-ports/src/computer.rs +++ b/crates/colossus-ports/src/computer.rs @@ -189,7 +189,9 @@ pub enum ComputerDriverError { #[error("computer control stopped by user")] UserStopped, /// Native identity, control generation, geometry, focus, or observation changed. - #[error("computer target or observation is stale")] + #[error( + "computer target or observation is stale; check computer.status and take a fresh snapshot; if the selected observation.window_id has foreground=false in observation.windows, use computer.focus for that observed window, then take a new snapshot before input" + )] Stale, /// Closed typed payload failed bounded validation. #[error("invalid computer input")] diff --git a/crates/colossus-runtime/src/browser_package/windows_preview.rs b/crates/colossus-runtime/src/browser_package/windows_preview.rs index d01ca39fc..0558005a1 100644 --- a/crates/colossus-runtime/src/browser_package/windows_preview.rs +++ b/crates/colossus-runtime/src/browser_package/windows_preview.rs @@ -5,9 +5,12 @@ use colossus_browser_bridge::BrowserHostFactory as _; use colossus_contracts::{BrowserActionKind, BrowserCapabilities, BrowserLimits, BrowserMode}; use colossus_ports::BrowserDriverError; use colossus_sandbox::{BrowserEgressLimits, WindowsBrowserConfig, WindowsBrowserSupervisor}; -use colossus_windows_native::{BoundPath, PrivateDirectoryCreation}; +use colossus_windows_native::{ + BoundPath, PrivateDirectoryCreation, current_user_profile_directory, +}; use std::{ collections::VecDeque, + os::windows::ffi::OsStrExt as _, path::Path, sync::{ Arc, Mutex, OnceLock, @@ -49,25 +52,37 @@ struct Cleanup { supervisor: Option>, state: Mutex>, _image: BoundPath, + _instance: BoundPath, slot: Mutex>>, } // Install hashes the full inventory on a blocking worker. A cancelled awaiting // future must transfer the exact allocation before its best-effort Drop runs. -struct Setup(Option<(PrivateDirectoryCreation, BoundPath, Slot<'static>)>); +struct SetupAllocation { + state: PrivateDirectoryCreation, + image: BoundPath, + instance: BoundPath, + slot: Slot<'static>, +} +struct Setup(Option); impl Drop for Setup { fn drop(&mut self) { - if let Some((state, image, slot)) = self.0.take() { - retain(Arc::new(Cleanup { - supervisor: None, - state: Mutex::new(Some(state)), - _image: image, - slot: Mutex::new(Some(slot)), - })); + if let Some(allocation) = self.0.take() { + retain(Arc::new(Cleanup::new(allocation, None))); } } } impl Cleanup { + fn new(allocation: SetupAllocation, supervisor: Option>) -> Self { + Self { + supervisor, + state: Mutex::new(Some(allocation.state)), + _image: allocation.image, + _instance: allocation.instance, + slot: Mutex::new(Some(allocation.slot)), + } + } + async fn shutdown(&self) -> Result<(), BrowserDriverError> { if let Some(supervisor) = &self.supervisor { supervisor.shutdown().await?; @@ -112,12 +127,18 @@ pub(super) async fn install( .map_err(|_| BrowserPackageError::Invalid)? .try_into() .map_err(|_| BrowserPackageError::Invalid)?; + // Retain the authenticated managed instance even though Chromium's cache + // cannot inherit its potentially long workspace ancestry on Windows. + let instance = bind_instance(instance_root)?; + let Some(state_name) = short_state_name() else { + // No state or native process has been allocated. Never fall back to the + // unsupported long managed path or an ambient temporary directory. + return Ok(None); + }; // Native FILE_CREATE returns custody before verification; existing names and // reparse parents cannot become this allocation. Never adopt a prior profile. let slot = Slot::reserve(&INSTALLATION_ACTIVE).ok_or(BrowserPackageError::CleanupUnknown)?; - let state = match PrivateDirectoryCreation::create( - &instance_root.join(format!("browser-preview-{}", uuid::Uuid::new_v4().simple())), - ) { + let state = match PrivateDirectoryCreation::create_in_current_user_profile(&state_name) { Ok(state) => state, Err(_) => { slot.retire(); @@ -125,7 +146,23 @@ pub(super) async fn install( } }; let state_root = state.path().to_owned(); - let mut setup = Setup(Some((state, image, slot))); + let mut setup = Setup(Some(SetupAllocation { + state, + image, + instance, + slot, + })); + // Recheck the actual retained allocation too: token profile resolution can + // change between the preallocation budget and the atomic native creator. + if !short_native_profile(&state_root) { + let allocation = setup.0.take().ok_or(BrowserPackageError::Invalid)?; + let cleanup = Arc::new(Cleanup::new(allocation, None)); + if cleanup.shutdown().await.is_err() { + retain(cleanup); + return Err(BrowserPackageError::CleanupUnknown); + } + return Ok(None); + } let limits = BrowserLimits::default(); let installation = WindowsBrowserSupervisor::install_for_functional_preview(WindowsBrowserConfig { @@ -143,18 +180,13 @@ pub(super) async fn install( }, }) .await; - let (state, image, slot) = setup.0.take().ok_or(BrowserPackageError::Invalid)?; + let allocation = setup.0.take().ok_or(BrowserPackageError::Invalid)?; let supervisor = Arc::new(match installation { Ok(supervisor) => supervisor, // Only the native preview constructor classifies ordinary-user desktop // prerequisites this way; invalid publisher bytes remain fatal. Err(error) => { - let cleanup = Arc::new(Cleanup { - supervisor: None, - state: Mutex::new(Some(state)), - _image: image, - slot: Mutex::new(Some(slot)), - }); + let cleanup = Arc::new(Cleanup::new(allocation, None)); if cleanup.shutdown().await.is_err() { retain(cleanup); return Err(BrowserPackageError::CleanupUnknown); @@ -166,12 +198,7 @@ pub(super) async fn install( }; } }); - let cleanup = Arc::new(Cleanup { - supervisor: Some(Arc::clone(&supervisor)), - state: Mutex::new(Some(state)), - _image: image, - slot: Mutex::new(Some(slot)), - }); + let cleanup = Arc::new(Cleanup::new(allocation, Some(Arc::clone(&supervisor)))); if supervisor.capabilities().restrictive_egress { retain(cleanup); return Err(BrowserPackageError::Invalid); @@ -190,6 +217,39 @@ pub(super) async fn install( }))) } +fn short_native_profile(state: &Path) -> bool { + // The supervisor appends a fresh 32-hex session and the profile leaf. Keep + // twenty UTF-16 units below MAX_PATH before Chromium adds its own files. + let profile = state + .join("session-00000000000000000000000000000000") + .join("profile"); + let native = profile.as_os_str().encode_wide().collect::>(); + native + .strip_prefix(&[92, 92, 63, 92]) + .unwrap_or(&native) + .len() + < 240 +} + +fn bind_instance(root: &Path) -> Result { + let instance = BoundPath::open_directory(root).map_err(|_| BrowserPackageError::Invalid)?; + instance + .validate_private_owner_dacl() + .and_then(|()| instance.validate_ancestor_namespace_authority()) + .and_then(|()| instance.revalidate()) + .map_err(|_| BrowserPackageError::Invalid)?; + Ok(instance) +} + +fn short_state_name() -> Option { + let parent = current_user_profile_directory().ok()?; + let name = std::ffi::OsString::from(format!( + "ColossusBrowserPreview-{}", + uuid::Uuid::new_v4().simple() + )); + short_native_profile(&parent.join(&name)).then_some(name) +} + fn capabilities(limits: BrowserLimits) -> BrowserCapabilities { use BrowserActionKind::*; BrowserCapabilities { @@ -309,3 +369,27 @@ fn retry(keeper: &'static Keeper) { } } } + +#[cfg(test)] +mod path_tests { + use super::short_native_profile; + use std::path::PathBuf; + + #[test] + fn native_profile_budget_counts_utf16_and_excludes_extended_path_prefix() { + let short = PathBuf::from(format!(r"C:\{}", "x".repeat(187))); + assert!(short_native_profile(&short)); + assert!(short_native_profile(&PathBuf::from(format!( + r"\\?\{}", + short.display() + )))); + assert!(!short_native_profile(&PathBuf::from(format!( + r"C:\{}", + "x".repeat(188) + )))); + assert!(!short_native_profile(&PathBuf::from(format!( + r"C:\{}", + "😀".repeat(94) + )))); + } +} diff --git a/crates/colossus-sandbox/tests/browser_windows_native.rs b/crates/colossus-sandbox/tests/browser_windows_native.rs index d79158bfb..04aa5985c 100644 --- a/crates/colossus-sandbox/tests/browser_windows_native.rs +++ b/crates/colossus-sandbox/tests/browser_windows_native.rs @@ -10,6 +10,8 @@ mod exercise; mod fixture; #[path = "browser_windows_native/keyboard.rs"] mod keyboard; +#[path = "browser_windows_native/logging.rs"] +mod logging; #[path = "browser_windows_native/transfer.rs"] mod transfer; @@ -64,6 +66,8 @@ async fn run(user_development: bool) { PrivateDirectoryCreation::create(&parent.join(format!("win-{}", hex::encode(nonce)))) .expect("owner-private exact state allocation"); let state_path = state.path().to_owned(); + let long_profile = std::env::var_os("COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE") + .is_some_and(|value| value == "1"); let state_binding = colossus_windows_native::BoundPath::open_directory(&state_path) .expect("native state root binding"); state_binding @@ -77,7 +81,17 @@ async fn run(user_development: bool) { component_binding .validate_immutable_directory_dacl() .expect("native component payload and ancestry DACL"); - drop((state_binding, component_binding)); + drop(component_binding); + let mut engine_state = long_profile.then(|| { + PrivateDirectoryCreation::create_in_current_user_profile(std::ffi::OsStr::new(&format!( + "ColossusBrowserPreview-{}", + hex::encode(nonce) + ))) + .expect("same exclusive native cache allocation as the functional preview") + }); + let engine_state_path = engine_state + .as_ref() + .map_or_else(|| state_path.clone(), |state| state.path().to_owned()); let fixture = fixture::Fixture::start().await; let request = BrowserDriverOpenRequest { binding: BrowserSessionBinding { @@ -103,7 +117,7 @@ async fn run(user_development: bool) { let config = WindowsBrowserConfig { component_root: component, manifest_sha256: Sha256::digest(&manifest).into(), - state_root: state_path.clone(), + state_root: engine_state_path.clone(), capabilities: BrowserCapabilities { available: true, engine_version: Some( @@ -170,6 +184,10 @@ async fn run(user_development: bool) { denied, fixture.origin.clone(), Arc::clone(&fixture.uploaded), + long_profile.then_some(logging::Layout { + managed_root: state_path.clone(), + engine_root: engine_state_path.clone(), + }), )); let result = tokio::time::timeout(Duration::from_secs(90), &mut check).await; if result.is_err() { @@ -193,6 +211,13 @@ async fn run(user_development: bool) { "shutdown retry idempotent" ); drop(supervisor); + if let Some(engine_state) = &mut engine_state { + engine_state + .remove_empty() + .expect("positive short native cache removal after full cleanup"); + assert!(!engine_state_path.exists(), "short native cache gone"); + } + drop(state_binding); state .remove_empty() .expect("positive exact state-root removal after process/filter/pipe/profile retirement"); @@ -234,6 +259,7 @@ async fn run(user_development: bool) { "exact_origin_denial":true,"native_upload_actual_http_bytes":true, "native_download_actual_http_bytes":true,"full_job_and_io_cleanup":true, "native_typed_automation":true,"native_tabs":true, + "long_managed_path_short_private_profile_without_file_logging":long_profile, "cef_shutdown":true,"whole_host_network_acceptance":false,"production_containment":false}) ); } diff --git a/crates/colossus-sandbox/tests/browser_windows_native/exercise.rs b/crates/colossus-sandbox/tests/browser_windows_native/exercise.rs index 3cb9b73d1..960c9db76 100644 --- a/crates/colossus-sandbox/tests/browser_windows_native/exercise.rs +++ b/crates/colossus-sandbox/tests/browser_windows_native/exercise.rs @@ -18,6 +18,7 @@ pub async fn run( denied: String, origin: String, uploaded: Arc, + long_profile_layout: Option, ) { let control = BrowserDriverControl::new(RunControl::default(), RunControl::default()); use colossus_browser_bridge::BrowserHostFactory as _; @@ -28,6 +29,9 @@ pub async fn run( .open_session(request.clone(), &control) .await .expect("native owned session readiness"); + if let Some(layout) = &long_profile_layout { + layout.assert_short_private_profile_without_logs(); + } let attachment = supervisor .take_presentation(&request) .await @@ -248,6 +252,9 @@ pub async fn run( attachment.client.disconnect(); super::keyboard::check(&*driver, &request, &mut configure.target, &control, &origin).await; let _ = snapshot(&*driver, &request, &configure.target, &control, 50).await; + if let Some(layout) = &long_profile_layout { + layout.assert_short_private_profile_without_logs(); + } driver .close_session(&request.session_id) .await diff --git a/crates/colossus-sandbox/tests/browser_windows_native/logging.rs b/crates/colossus-sandbox/tests/browser_windows_native/logging.rs new file mode 100644 index 000000000..ce132bb77 --- /dev/null +++ b/crates/colossus-sandbox/tests/browser_windows_native/logging.rs @@ -0,0 +1,60 @@ +//! Actual live Release-host cache evidence beside long managed ancestry. +use std::{os::windows::ffi::OsStrExt as _, path::PathBuf}; + +pub(super) struct Layout { + pub managed_root: PathBuf, + pub engine_root: PathBuf, +} + +fn native_length(path: &std::path::Path) -> usize { + let native = path.as_os_str().encode_wide().collect::>(); + native + .strip_prefix(&[92, 92, 63, 92]) + .unwrap_or(&native) + .len() +} + +impl Layout { + pub(super) fn assert_short_private_profile_without_logs(&self) { + assert!( + self.managed_root.is_dir(), + "managed instance remains retained" + ); + assert!( + native_length( + &self + .managed_root + .join("session-00000000000000000000000000000000") + .join("profile") + ) > 260, + "managed ancestry would exceed Windows MAX_PATH" + ); + let entries = std::fs::read_dir(&self.engine_root) + .expect("owned native state remains readable") + .take(2) + .collect::, _>>() + .expect("owned native state entries remain readable"); + assert_eq!(entries.len(), 1, "one actual owned browser session"); + let entry = &entries[0]; + assert!( + entry.file_name().to_string_lossy().starts_with("session-") + && entry.file_type().expect("session type").is_dir(), + "native session must exist before checking its profile" + ); + let profile = entry.path().join("profile"); + assert!(profile.is_dir(), "actual native profile exists"); + assert!( + native_length(&profile) < 240 && !profile.starts_with(&self.managed_root), + "native profile uses the distinct short private allocation" + ); + for name in ["startup.log", "debug.log", "chrome_debug.log"] { + assert!( + !profile + .join(name) + .try_exists() + .expect("logging evidence readable"), + "non-diagnostic Release host must not create file logs" + ); + } + } +} diff --git a/crates/colossus-tools/src/computer.rs b/crates/colossus-tools/src/computer.rs index 0f1e1577f..2025dbbe6 100644 --- a/crates/colossus-tools/src/computer.rs +++ b/crates/colossus-tools/src/computer.rs @@ -14,7 +14,7 @@ pub(super) fn computer_specs() -> Vec { ), tool( "computer.open", - "Acquire computer control for one previously observed opaque native window. A visible native control indicator lets the human pause or stop. Protected, elevated, and Colossus control windows are unavailable.", + "Acquire computer control for one previously observed opaque native window without activating it. Observe it next; if it is in the background, explicitly focus it and take a new snapshot before other input. A visible native control indicator lets the human pause or stop. Protected, elevated, and Colossus control windows are unavailable.", object(json!({"window_id":id("w")}), &["window_id"]), ), tool( @@ -24,7 +24,7 @@ pub(super) fn computer_specs() -> Vec { ), tool( "computer.snapshot", - "Read bounded native accessibility semantics for the selected window or a positively owned same-app dialog. The requested node count is an upper bound narrowed by workspace and native limits. Sensitive values are withheld. A released observation is required before each input action.", + "Read bounded native accessibility semantics without activating the selected window or positively owned same-app dialog. Match observation.window_id in observation.windows; if foreground is false, use computer.focus for that observed window and take a new snapshot before other input. The requested node count is narrowed by workspace and native limits. Sensitive values are withheld. Each input needs a fresh observation.", session( json!({"max_nodes":{"type":"integer","minimum":1,"maximum":1024}}), &[], @@ -32,7 +32,7 @@ pub(super) fn computer_specs() -> Vec { ), tool( "computer.screenshot", - "Capture the actual selected native window as a verified PNG. After output policy release, returns an inline image and owner-only artifact plus a fresh observation. No paths or encoded image arguments.", + "Capture the actual selected native window as a verified PNG without activating it. After output policy release, returns an inline image and owner-only artifact plus a fresh observation. Use computer.snapshot to inspect foreground and accessible controls before input. No paths or encoded image arguments.", session(json!({}), &[]), ), tool( @@ -44,7 +44,7 @@ pub(super) fn computer_specs() -> Vec { for (name, description, properties, required) in [ ( "computer.focus", - "Focus the selected window or a positively owned same-app dialog using a fresh observation.", + "Explicitly activate the selected window or a positively owned same-app dialog using its observed window_id and a fresh observation. Use when the observed target has foreground=false. Consumes the observation; take a new snapshot before other input. Windows may refuse activation; do not force focus through another route.", json!({"window_id":id("w")}), vec!["window_id"], ), @@ -56,7 +56,7 @@ pub(super) fn computer_specs() -> Vec { ), ( "computer.click", - "Click a fresh local physical point or observed semantic element. May submit data or trigger actions in an unsandboxed app; inspect first.", + "Click a fresh local physical point or observed semantic element in the foreground target. Does not activate a background window: explicitly computer.focus its observed window_id, then take a new snapshot first. May submit data or trigger actions in an unsandboxed app; inspect first.", json!({"target":{"oneOf":[object(json!({"kind":{"const":"point"},"x":coordinate(),"y":coordinate()}), &["kind","x","y"]),object(json!({"kind":{"const":"element"},"element_id":id("e")}), &["kind","element_id"])]},"button":{"type":"string","enum":["left","right","middle"]},"count":{"type":"integer","minimum":1,"maximum":2}}), vec!["target", "button", "count"], ), diff --git a/crates/colossus-windows-native/Cargo.toml b/crates/colossus-windows-native/Cargo.toml index 7689acda5..2eb94601d 100644 --- a/crates/colossus-windows-native/Cargo.toml +++ b/crates/colossus-windows-native/Cargo.toml @@ -31,6 +31,7 @@ windows-sys = { workspace = true, features = [ "Win32_System_SystemServices", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/colossus-windows-native/src/lib.rs b/crates/colossus-windows-native/src/lib.rs index e9483f3d4..7dfd647b3 100644 --- a/crates/colossus-windows-native/src/lib.rs +++ b/crates/colossus-windows-native/src/lib.rs @@ -31,7 +31,7 @@ pub use windows::AppContainerPrincipal; #[cfg(windows)] pub use windows::{ ExclusiveAppContainerProfile, OwnedAppContainerWindowStation, PrivateDirectoryCreation, - system_windows_directory, + current_user_profile_directory, system_windows_directory, }; #[cfg(windows)] diff --git a/crates/colossus-windows-native/src/windows.rs b/crates/colossus-windows-native/src/windows.rs index c9c7c4c0a..e913015cd 100644 --- a/crates/colossus-windows-native/src/windows.rs +++ b/crates/colossus-windows-native/src/windows.rs @@ -67,6 +67,8 @@ mod appcontainer; pub use appcontainer::AppContainerPrincipal; mod package; pub use package::{ExclusiveAppContainerProfile, system_windows_directory}; +mod profile; +pub use profile::current_user_profile_directory; mod directory_creation; pub use directory_creation::PrivateDirectoryCreation; pub(super) mod enrolled; diff --git a/crates/colossus-windows-native/src/windows/directory_creation.rs b/crates/colossus-windows-native/src/windows/directory_creation.rs index 08e51f8d1..bfcd6655f 100644 --- a/crates/colossus-windows-native/src/windows/directory_creation.rs +++ b/crates/colossus-windows-native/src/windows/directory_creation.rs @@ -57,6 +57,24 @@ impl PrivateDirectoryCreation { Self::allocate(parent, leaf, None) } + /// Allocate one fresh owner-private leaf beneath this process token's profile. + /// The existing OS parent must deny untrusted namespace mutation, but may + /// permit read-only access. The child uses the same exclusive allocation and + /// private descriptor as `create`; no caller-selected parent is accepted. + pub fn create_in_current_user_profile( + basename: &std::ffi::OsStr, + ) -> Result { + if basename == "." || basename == ".." { + return Err(WindowsNativeError::InvalidInput); + } + let profile = super::profile::current_user_profile_directory()?; + let parent = open_bound(&profile, BoundKind::Directory)?; + parent.validate_immutable_directory_dacl()?; + parent.validate_ancestor_namespace_authority()?; + parent.revalidate()?; + Self::allocate(parent, basename, None) + } + /// Create a fresh transfer directory beneath this process's exact enrolled profile. /// The current kernel token supplies the package SID; names are single components /// and allocation ownership is returned before fallible path binding. diff --git a/crates/colossus-windows-native/src/windows/profile.rs b/crates/colossus-windows-native/src/windows/profile.rs new file mode 100644 index 000000000..7d6404395 --- /dev/null +++ b/crates/colossus-windows-native/src/windows/profile.rs @@ -0,0 +1,72 @@ +//! Token-derived profile location; ambient environment never selects this parent. +use super::{BoundKind, WindowsNativeError, last_error, open_bound}; +use std::{ + ffi::OsString, + os::windows::{ + ffi::OsStringExt as _, + io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}, + }, + path::PathBuf, + ptr::null_mut, +}; +use windows_sys::Win32::{ + Security::TOKEN_QUERY, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + UI::Shell::GetUserProfileDirectoryW, +}; + +/// Resolve this process token's existing profile without reading environment. +/// +/// Returns only a no-follow, revalidated parent path. Callers still own private, +/// exclusive child allocation and must retain the resulting creation owner. +/// Reparse points, untrusted namespace mutation or unsafe ancestry fail closed. +/// Read-only access to the profile does not authorize reading a private child. +pub fn current_user_profile_directory() -> Result { + let mut token = null_mut(); + // SAFETY: the current-process pseudo handle is valid; success transfers one + // TOKEN_QUERY handle whose lifetime is owned below. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(last_error("open profile process token")); + } + // SAFETY: successful OpenProcessToken returns a uniquely owned valid handle. + let token = unsafe { OwnedHandle::from_raw_handle(token.cast()) }; + let mut value = vec![0u16; 32768]; + let mut written = value.len() as u32; + // SAFETY: the retained query token and bounded writable UTF-16 buffer remain + // live. The returned count includes the terminating NUL and is checked below. + if unsafe { + GetUserProfileDirectoryW( + token.as_raw_handle().cast(), + value.as_mut_ptr(), + &mut written, + ) + } == 0 + { + return Err(last_error("query token user profile directory")); + } + let directory = profile_path(&value, written)?; + let bound = open_bound(&directory, BoundKind::Directory)?; + bound.validate_immutable_directory_dacl()?; + bound.validate_ancestor_namespace_authority()?; + bound.revalidate()?; + Ok(bound.canonical_path) +} + +fn profile_path(value: &[u16], written: u32) -> Result { + let length = written as usize; + if length < 2 + || length > value.len() + || value[length - 1] != 0 + || value[..length - 1].contains(&0) + { + return Err(WindowsNativeError::InvalidInput); + } + let path = PathBuf::from(OsString::from_wide(&value[..length - 1])); + if !path.is_absolute() { + return Err(WindowsNativeError::InvalidInput); + } + Ok(path) +} + +#[cfg(test)] +mod tests; diff --git a/crates/colossus-windows-native/src/windows/profile/tests.rs b/crates/colossus-windows-native/src/windows/profile/tests.rs new file mode 100644 index 000000000..d626b5990 --- /dev/null +++ b/crates/colossus-windows-native/src/windows/profile/tests.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn profile_path_requires_a_complete_bounded_absolute_native_value() { + let value = "C:\\Users\\native-profile\0" + .encode_utf16() + .collect::>(); + assert_eq!( + profile_path(&value, value.len() as u32).expect("complete profile path"), + PathBuf::from("C:\\Users\\native-profile") + ); + for written in [0, 1, value.len() as u32 - 1, value.len() as u32 + 1] { + assert!(profile_path(&value, written).is_err()); + } + let mut embedded_nul = value.clone(); + embedded_nul[5] = 0; + assert!(profile_path(&embedded_nul, embedded_nul.len() as u32).is_err()); + for relative in ["relative\0", "C:relative\0", "\\rooted\0"] { + let value = relative.encode_utf16().collect::>(); + assert!(profile_path(&value, value.len() as u32).is_err()); + } +} + +#[test] +fn token_profile_supports_the_existing_exclusive_private_allocator() { + let profile = current_user_profile_directory().expect("current token's protected profile"); + let parent = crate::BoundPath::open_directory(&profile).expect("bind token profile"); + parent + .validate_immutable_directory_dacl() + .expect("profile denies untrusted namespace mutation"); + parent + .validate_ancestor_namespace_authority() + .expect("profile ancestry retains namespace authority"); + parent.revalidate().expect("same token profile identity"); + let temporary = tempfile::Builder::new() + .prefix("colossus-native-token-profile-") + .tempdir_in(&profile) + .expect("owned temporary fixture beneath token profile"); + let basename = temporary.path().file_name().unwrap().to_os_string(); + temporary.close().expect("retire unused name reservation"); + let mut creation = crate::PrivateDirectoryCreation::create_in_current_user_profile(&basename) + .expect("token parent allocator creates one owner-private child"); + let bound = creation.bind().expect("bind exact created child"); + bound + .validate_private_owner_dacl() + .expect("child retains private DACL"); + assert!(crate::PrivateDirectoryCreation::create_in_current_user_profile(&basename).is_err()); + let child = creation.path().join("private"); + let mut nested = crate::PrivateDirectoryCreation::create(&child) + .expect("existing allocator still accepts the owner-private child as parent"); + nested.remove_empty().expect("retire exact nested fixture"); + drop(nested); + drop(bound); + creation.remove_empty().expect("retire exact empty fixture"); +} + +#[test] +fn token_profile_allocation_rejects_non_leaf_names() { + for name in [ + "", + ".", + "..", + "../escape", + "parent\\child", + "C:\\absolute", + "a:b", + "nul\0", + ] { + assert!( + crate::PrivateDirectoryCreation::create_in_current_user_profile(std::ffi::OsStr::new( + name + )) + .is_err() + ); + } + assert!( + crate::PrivateDirectoryCreation::create_in_current_user_profile(std::ffi::OsStr::new( + &"a".repeat(256) + )) + .is_err() + ); +} + +#[test] +fn token_profile_ignores_ambient_directory_overrides() { + const FIXTURE: &str = "COLOSSUS_NATIVE_TOKEN_PROFILE_FIXTURE"; + const UNTRUSTED: &str = r"C:\ColossusAmbientProfileMustNotSelectParent"; + if std::env::var_os(FIXTURE).is_some() { + assert_eq!(std::env::var("USERPROFILE").unwrap(), UNTRUSTED); + let profile = + current_user_profile_directory().expect("native token profile ignores override"); + assert!(profile.is_absolute()); + assert!( + !profile + .to_string_lossy() + .contains("ColossusAmbientProfileMustNotSelectParent") + ); + return; + } + let mut command = std::process::Command::new(std::env::current_exe().expect("test executable")); + command + .args([ + "--exact", + "windows::profile::tests::token_profile_ignores_ambient_directory_overrides", + "--nocapture", + ]) + .env(FIXTURE, "1"); + for name in [ + "USERPROFILE", + "HOME", + "LOCALAPPDATA", + "APPDATA", + "TEMP", + "TMP", + ] { + command.env(name, UNTRUSTED); + } + assert!( + command + .status() + .expect("isolated environment fixture") + .success() + ); +} diff --git a/docs/develop/testing.md b/docs/develop/testing.md index 9f0d054d3..d42679173 100644 --- a/docs/develop/testing.md +++ b/docs/develop/testing.md @@ -435,6 +435,12 @@ For the native host tier alone: ./scripts/ci/browser-windows-native.ps1 -Mode Development ``` +Add `-HostProfile Release` to build the non-diagnostic native client and exercise +long managed ancestry with a distinct short private engine profile, without file +logging. Both use the actual token's OS profile and the exclusive native allocator. +The existing owned Windows CI tier uses this profile with the same input, +transfer, frame and cleanup assertions; it does not add another acceptance run. + Both component release modes remain false. This functional development lane retains Chromium's built-in sandbox and reports no outer containment or private certificate capability. AppContainer/WFP, owned CA/client-key custody, additional diff --git a/native/browser/src/host.cc b/native/browser/src/host.cc index a89e491b2..781a32688 100644 --- a/native/browser/src/host.cc +++ b/native/browser/src/host.cc @@ -180,6 +180,14 @@ class App final : public CefApp, public CefBrowserProcessHandler { if (const auto* selection = SyntheticProfileSwitch(process.empty())) line->AppendSwitch(selection); if (process.empty()) { +#if defined(OS_WIN) + // CEF enables Chrome file logging by default, even with severity DISABLE. + // Before Chrome initializes logging, disable its destination as well: the + // default Windows log path may be unavailable in an owned private profile. + // Explicit Debug startup diagnostics retain their private startup.log. + if (!windows_startup_diagnostic) + line->AppendSwitch("disable-logging"); +#endif if (no_display_) { line->AppendSwitchWithValue("ozone-platform", "headless"); line->AppendSwitch("disable-gpu"); diff --git a/native/computer/examples/computer_acceptance.rs b/native/computer/examples/computer_acceptance.rs index 2bb415125..ccf2e2b9e 100644 --- a/native/computer/examples/computer_acceptance.rs +++ b/native/computer/examples/computer_acceptance.rs @@ -232,6 +232,83 @@ async fn acceptance( return Err("foreign window admitted into selected session".into()); } } + if require_cover { + // Observation does not activate the target. Refreshing a background + // snapshot must never turn it into authority to click through cover. + for stage in [ + "background_click_without_focus", + "background_click_after_fresh_snapshot", + ] { + let ComputerDriverReply::Snapshot(observation) = + dispatch!(ComputerCommand::Snapshot { + session_id: session.session_id.clone(), + control_generation: session.control_generation, + max_nodes: 512 + }) + else { + return Err("missing background native UIA tree".into()); + }; + if !observation + .windows + .iter() + .any(|observed| observed.window_id == session.window_id && !observed.foreground) + || !observation + .nodes + .iter() + .any(|node| node.name == "Waiting for action") + { + return Err("background click setup changed or applied an action".into()); + } + let button = observation + .nodes + .iter() + .find(|node| node.name == "Fixture action") + .ok_or("background native button missing from UIA")?; + evidence.set(stage); + let denied = driver + .execute( + ComputerDriverRequest { + binding: binding.clone(), + command: ComputerCommand::Input { + session_id: session.session_id.clone(), + control_generation: session.control_generation, + observation_id: observation.observation_id, + input: ComputerInput::Click { + target: ComputerTarget::Element { + element_id: button.element_id.clone(), + }, + button: ComputerMouseButton::Left, + count: 1, + }, + }, + }, + &control, + ) + .await; + if denied != Err(ComputerDriverError::Stale) { + return Err("background click did not fail as a known no-effect Stale".into()); + } + } + let ComputerDriverReply::Snapshot(observation) = dispatch!(ComputerCommand::Snapshot { + session_id: session.session_id.clone(), + control_generation: session.control_generation, + max_nodes: 512 + }) else { + return Err("missing background denial result".into()); + }; + if !observation + .windows + .iter() + .any(|observed| observed.window_id == session.window_id && !observed.foreground) + || !observation + .nodes + .iter() + .any(|node| node.name == "Waiting for action") + { + return Err("denied background click changed focus or reached the fixture".into()); + } + println!("PASS background clicks remain no-effect Stale after fresh snapshots"); + } let ComputerDriverReply::Screenshot(capture) = dispatch!(ComputerCommand::Screenshot { session_id: session.session_id.clone(), control_generation: session.control_generation, @@ -488,7 +565,11 @@ async fn acceptance( evidence.set("publish_receipt"); colossus_windows_native::create_private_file( &output.join("acceptance.json"), - br#"{"schema_version":1,"passed":true,"owned_modal":true,"modal_reparent_denied":true,"writer_contention":true,"foreign_window_denied":true,"cancellation":true,"balanced_drag_helper_exit":true}"#, + &serde_json::to_vec(&serde_json::json!({ + "schema_version":1,"passed":true,"owned_modal":true,"modal_reparent_denied":true, + "writer_contention":true,"foreign_window_denied":true,"cancellation":true, + "balanced_drag_helper_exit":true,"background_focus_regression_checked":require_cover + }))?, )?; println!("PASS native helper acknowledged cleanup; synthetic user apps remain running"); Ok(()) diff --git a/scripts/browser-native-host-windows.ps1 b/scripts/browser-native-host-windows.ps1 index e5509eaca..d5fe9b93f 100644 --- a/scripts/browser-native-host-windows.ps1 +++ b/scripts/browser-native-host-windows.ps1 @@ -1,3 +1,7 @@ +param( + [ValidateSet('Debug', 'Release')][string]$HostProfile = 'Debug' +) + # Build/stage only. The private host requires native supervisor-owned channels; # it must not be launched directly or given renderer-nominated bootstrap paths. $ErrorActionPreference = "Stop" @@ -31,7 +35,14 @@ try { $env:COLOSSUS_CEF_ROOT = $CefRoot $env:COLOSSUS_CEF_NATIVE_LIB_DIR = Join-Path $Build "Release" $Target = Join-Path $Repository ".local/cef-native-host-build-windows" - Invoke-Checked cargo @("build", "--locked", "--manifest-path", "native/browser/driver/Cargo.toml", "--target-dir", $Target, "--lib") + $HostArguments = @("build", "--locked", "--manifest-path", "native/browser/driver/Cargo.toml", "--target-dir", $Target, "--lib") + if ($HostProfile -eq 'Release') { + # Exercise installed startup without Debug-only logging diagnostics. + # The explicit feature also admits the ordinary-user fixture; inventory + # modes remain false and every process still needs private supervision. + $HostArguments += @('--release', '--features', 'windows-browser-functional-preview') + } + Invoke-Checked cargo $HostArguments # The checkout may have shared write access. Component admission must retain # safe ancestry rather than inheriting the checkout's permissions. $UserProfile = [Environment]::GetFolderPath('UserProfile') @@ -40,10 +51,10 @@ try { Invoke-Checked python @("-B", "-c", 'import sys; from pathlib import Path; sys.path.insert(0, "native/browser/scripts"); from pki_fixture_private import private_directory; private_directory(Path(sys.argv[1]))', $StageParent) Write-Host "Private component allocation: $StageParent" $Stage = Join-Path $StageParent "component" - # Cargo links debug/ and debug/deps/ to the same MSVC DLL. Give the stager an + # Cargo links the profile and deps paths to the same MSVC DLL. Give the stager an # independent copy; installed component verification still rejects aliases. $ClientCopy = Join-Path $Target "stage-client-$([Guid]::NewGuid().ToString('N')).dll" - Copy-Item -LiteralPath (Join-Path $Target "debug/colossus_native_browser_host.dll") -Destination $ClientCopy + Copy-Item -LiteralPath (Join-Path $Target "$($HostProfile.ToLowerInvariant())/colossus_native_browser_host.dll") -Destination $ClientCopy try { $StageOutput = & python -B native/browser/scripts/stage_windows.py --host --cef-root $CefRoot --native-build $env:COLOSSUS_CEF_NATIVE_LIB_DIR --client-dll $ClientCopy --destination $Stage if ($LASTEXITCODE -ne 0) { throw "Private Windows browser host staging failed." } diff --git a/scripts/ci/browser-windows-native.ps1 b/scripts/ci/browser-windows-native.ps1 index 0415ca8c0..790f673b1 100644 --- a/scripts/ci/browser-windows-native.ps1 +++ b/scripts/ci/browser-windows-native.ps1 @@ -1,7 +1,8 @@ param( [string]$EvidenceDirectory = '.local/windows-owned-browser-evidence', [string]$Component = '', - [ValidateSet('Containment', 'Development')][string]$Mode = 'Containment' + [ValidateSet('Containment', 'Development')][string]$Mode = 'Containment', + [ValidateSet('Debug', 'Release')][string]$HostProfile = 'Debug' ) # Reviewed premerge diagnostic only. This script never accepts a release mode or @@ -30,13 +31,14 @@ $Checks = @( "full_job_and_io_cleanup", "cef_shutdown", "native_typed_automation", "native_tabs" ) if (-not $Development) { $Checks += @('low_appcontainer_token', 'low_package_profile_label') } +if ($HostProfile -eq 'Release') { $Checks += @('long_managed_path_short_private_profile_without_file_logging') } $Checked = @{} foreach ($Check in $Checks) { $Checked[$Check] = $false } $StartupChecked = [ordered]@{} Push-Location $Repository try { if ([string]::IsNullOrWhiteSpace($Component)) { - & (Join-Path $Repository "scripts/browser-native-host-windows.ps1") + & (Join-Path $Repository "scripts/browser-native-host-windows.ps1") -HostProfile $HostProfile $Component = $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_COMPONENT } else { & python -B native/browser/scripts/component.py verify --root $Component @@ -54,15 +56,25 @@ try { $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_COMPONENT = $Component $UserProfile = [Environment]::GetFolderPath('UserProfile') if ([string]::IsNullOrWhiteSpace($UserProfile)) { throw "Native acceptance requires an owned user profile." } - $Parent = Join-Path $UserProfile ("ColossusBrowserCI-" + [Guid]::NewGuid().ToString("N")) + $ParentName = "ColossusBrowserCI-" + [Guid]::NewGuid().ToString("N") + if ($HostProfile -eq 'Release') { + # Managed Desktop adds workspace/instance ancestry. Keep one private + # leaf to prove a distinct short native cache avoids that ancestry. + $Padding = [Math]::Max(1, 190 - $UserProfile.Length - 1 - $ParentName.Length) + $ParentName += '-' + ('r' * $Padding) + } + $Parent = Join-Path $UserProfile $ParentName & python -B -c 'import sys; from pathlib import Path; sys.path.insert(0, "native/browser/scripts"); from pki_fixture_private import private_directory; private_directory(Path(sys.argv[1]))' $Parent if ($LASTEXITCODE -ne 0) { throw "Private fixture state allocation failed." } $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_STATE_PARENT = $Parent $SavedPaths = @{} - foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP')) { + foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP', 'COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE')) { $SavedPaths[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process') + } + foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP')) { [Environment]::SetEnvironmentVariable($Name, $Parent, 'Process') } + $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE = if ($HostProfile -eq 'Release') { '1' } else { $null } if (-not $Development) { $StartupCases = @( @{ @@ -170,6 +182,7 @@ try { passed = $Passed sourceDiagnostic = $true standardUserDevelopment = $Development + requestedNativeHostProfile = $HostProfile componentManifestSha256 = $ManifestHash checks = $Checked startupChecks = $StartupChecked From 9e94ffe4d8fd5e6cd2c2f6398287ad5b77fde301 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey <8761706+killswitch-GUI@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:58:44 -0400 Subject: [PATCH 2/3] fix(ci): scope long browser paths to the runtime fixture --- scripts/ci/browser-windows-native.ps1 | 41 ++++++++++++++++++--------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/scripts/ci/browser-windows-native.ps1 b/scripts/ci/browser-windows-native.ps1 index 790f673b1..448d8c733 100644 --- a/scripts/ci/browser-windows-native.ps1 +++ b/scripts/ci/browser-windows-native.ps1 @@ -17,6 +17,7 @@ $Evidence = if ([IO.Path]::IsPathRooted($EvidenceDirectory)) { $EvidenceDirector if (Test-Path -LiteralPath $Evidence) { throw "Native evidence destination must be fresh." } $null = New-Item -ItemType Directory -Path $Evidence $Parent = $null +$ManagedParent = $null $Passed = $false $ManifestHash = $null $Development = $Mode -eq 'Development' @@ -57,24 +58,10 @@ try { $UserProfile = [Environment]::GetFolderPath('UserProfile') if ([string]::IsNullOrWhiteSpace($UserProfile)) { throw "Native acceptance requires an owned user profile." } $ParentName = "ColossusBrowserCI-" + [Guid]::NewGuid().ToString("N") - if ($HostProfile -eq 'Release') { - # Managed Desktop adds workspace/instance ancestry. Keep one private - # leaf to prove a distinct short native cache avoids that ancestry. - $Padding = [Math]::Max(1, 190 - $UserProfile.Length - 1 - $ParentName.Length) - $ParentName += '-' + ('r' * $Padding) - } $Parent = Join-Path $UserProfile $ParentName & python -B -c 'import sys; from pathlib import Path; sys.path.insert(0, "native/browser/scripts"); from pki_fixture_private import private_directory; private_directory(Path(sys.argv[1]))' $Parent if ($LASTEXITCODE -ne 0) { throw "Private fixture state allocation failed." } $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_STATE_PARENT = $Parent - $SavedPaths = @{} - foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP', 'COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE')) { - $SavedPaths[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process') - } - foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP')) { - [Environment]::SetEnvironmentVariable($Name, $Parent, 'Process') - } - $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE = if ($HostProfile -eq 'Release') { '1' } else { $null } if (-not $Development) { $StartupCases = @( @{ @@ -126,6 +113,28 @@ try { $StartupChecked[$Case.evidence] = $true } } + # Raw startup cases construct their CEF profile directly. Keep their parent + # short; only the runtime factory below splits long managed ancestry from a + # distinct short native engine allocation. + $FactoryParent = $Parent + if ($HostProfile -eq 'Release') { + $ManagedName = 'managed-' + [Guid]::NewGuid().ToString('N') + $Padding = [Math]::Max(1, 190 - $Parent.Length - 1 - $ManagedName.Length) + $ManagedName += '-' + ('r' * $Padding) + $ManagedParent = Join-Path $Parent $ManagedName + & python -B -c 'import sys; from pathlib import Path; sys.path.insert(0, "native/browser/scripts"); from pki_fixture_private import private_directory; private_directory(Path(sys.argv[1]))' $ManagedParent + if ($LASTEXITCODE -ne 0) { throw "Private managed fixture state allocation failed." } + $FactoryParent = $ManagedParent + } + $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_STATE_PARENT = $FactoryParent + $SavedPaths = @{} + foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP', 'COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE')) { + $SavedPaths[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process') + } + foreach ($Name in @('LOCALAPPDATA', 'TEMP', 'TMP')) { + [Environment]::SetEnvironmentVariable($Name, $FactoryParent, 'Process') + } + $env:COLOSSUS_BROWSER_WINDOWS_ACCEPTANCE_LONG_PROFILE = if ($HostProfile -eq 'Release') { '1' } else { $null } # A rolling categorical log stays bounded even on a noisy compiler failure. $Lines = [Collections.Generic.Queue[string]]::new() $Bytes = 0 @@ -173,6 +182,10 @@ try { } # The actual fixture removes its owned subtree only after the complete native # shutdown receipt. Nonrecursive removal refuses any unknown surviving state. + if ($null -ne $ManagedParent) { + [IO.Directory]::Delete($ManagedParent) + $ManagedParent = $null + } [IO.Directory]::Delete($Parent) $Parent = $null $Passed = $true From 02076cfd6ef9ada866525b14f118a072f3b6390e Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey <8761706+killswitch-GUI@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:13:58 -0400 Subject: [PATCH 3/3] fix(browser): apply terminal viewer revocation once --- native/browser/driver/src/presentation.rs | 17 ++- .../driver/src/presentation_wait_tests.rs | 118 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/native/browser/driver/src/presentation.rs b/native/browser/driver/src/presentation.rs index 5170b89a4..9fefeb121 100644 --- a/native/browser/driver/src/presentation.rs +++ b/native/browser/driver/src/presentation.rs @@ -31,6 +31,7 @@ pub struct Adapter { pub(crate) struct Lane { receiver: mpsc::Receiver, revoked: Arc, + revocation_applied: bool, #[cfg(all(windows, debug_assertions))] waiting_observation_diagnosed: bool, } @@ -49,13 +50,14 @@ impl Lane { Self { receiver, revoked, + revocation_applied: false, #[cfg(all(windows, debug_assertions))] waiting_observation_diagnosed: false, } } pub(crate) fn service(&mut self, host: &mut impl Target, waiting: bool) { if self.revoked.load(Ordering::Acquire) { - host.revoke(); + self.revoke_once(host); } // Each caller retains its own absolute operation deadline. Continuous // viewer traffic cannot hold this main-thread wait or starve CEF work. @@ -111,11 +113,22 @@ impl Lane { }; let _ = sender.send(result); } - Command::Revoke => host.revoke(), + Command::Revoke => self.revoke_once(host), _ => {} } } } + + fn revoke_once(&mut self, host: &mut impl Target) { + // Keep the terminal admission fence latched, including a queued revoke + // observed before its atomic notification. Later Core waits must not + // repeatedly hide or defocus a page activated by agent input. + self.revoked.store(true, Ordering::Release); + if !self.revocation_applied { + self.revocation_applied = true; + host.revoke(); + } + } } #[cfg(test)] diff --git a/native/browser/driver/src/presentation_wait_tests.rs b/native/browser/driver/src/presentation_wait_tests.rs index c8b31e739..8f3e2abdd 100644 --- a/native/browser/driver/src/presentation_wait_tests.rs +++ b/native/browser/driver/src/presentation_wait_tests.rs @@ -286,6 +286,124 @@ async fn exact_hide_and_revocation_settle_during_wait_and_revoked_lane_cannot_re assert!(host.revoked > 0); } +#[tokio::test] +async fn terminal_revocation_applies_once_while_future_pumps_and_commands_stay_fenced() { + let (sender, receiver) = mpsc::channel(32); + let revoked = Arc::new(AtomicBool::new(true)); + let mut lane = Lane::new(receiver, revoked.clone()); + let mut host = MemoryHost::default(); + let human = Lease { + control_generation: 0, + ..lease() + }; + // Adapter::revoke sets the atomic fence before queuing its notification. + // Neither duplicate notifications nor later CEF pumps may re-hide the page. + for waiting in [true, false, true, false] { + sender.send(Command::Revoke).await.unwrap(); + sender.send(Command::Revoke).await.unwrap(); + let mut replies = Vec::new(); + for command in [ + PresentationCommand::Configure(configuration()), + PresentationCommand::Renew { + lease: lease(), + lease_ms: 1000, + }, + PresentationCommand::Observe { lease: lease() }, + PresentationCommand::Observe { lease: human }, + PresentationCommand::Human { + lease: human, + command: colossus_browser_presentation::HumanCommand::Reload, + }, + PresentationCommand::Focus { + lease: human, + focused: true, + }, + PresentationCommand::FenceHuman { lease: human }, + PresentationCommand::Input { + lease: human, + input: colossus_browser_presentation::Input::MouseMove { x: 1, y: 1 }, + modifiers: 0, + }, + ] { + let (reply, result) = oneshot::channel(); + sender.send(Command::Control(command, reply)).await.unwrap(); + replies.push(result); + } + let mut frames = Vec::new(); + for lease in [lease(), human] { + let (reply, result) = oneshot::channel(); + sender.send(Command::Frame(lease, reply)).await.unwrap(); + frames.push(result); + } + let mut closing_replies = Vec::new(); + for command in [ + PresentationCommand::Hide { lease: lease() }, + PresentationCommand::Focus { + lease: human, + focused: false, + }, + ] { + let (reply, result) = oneshot::channel(); + sender.send(Command::Control(command, reply)).await.unwrap(); + closing_replies.push(result); + } + lane.service(&mut host, waiting); + for reply in replies { + assert!(matches!( + reply.await.unwrap(), + Err(PresentationError::Hidden) + )); + } + for frame in frames { + assert!(matches!( + frame.await.unwrap(), + Err(PresentationError::Hidden) + )); + } + for reply in closing_replies { + assert!(matches!(reply.await.unwrap(), Ok(PresentationReply::Ack))); + } + assert!(revoked.load(Ordering::Acquire)); + } + // Empty pumps must not defocus agent input after it activates the page. + lane.service(&mut host, true); + lane.service(&mut host, false); + assert_eq!(host.revoked, 1); + assert_eq!(host.frames, 0); + assert_eq!(host.controls.len(), 8); + assert!(host.controls.iter().all(|command| matches!( + command, + PresentationCommand::Hide { .. } | PresentationCommand::Focus { focused: false, .. } + ))); +} + +#[tokio::test] +async fn queued_revocation_latches_the_terminal_fence_before_later_commands() { + let (sender, receiver) = mpsc::channel(8); + let revoked = Arc::new(AtomicBool::new(false)); + let mut lane = Lane::new(receiver, revoked.clone()); + let mut host = MemoryHost::default(); + sender.send(Command::Revoke).await.unwrap(); + let (reply, observation) = oneshot::channel(); + sender + .send(Command::Control( + PresentationCommand::Observe { lease: lease() }, + reply, + )) + .await + .unwrap(); + sender.send(Command::Revoke).await.unwrap(); + lane.service(&mut host, false); + assert!(matches!( + observation.await.unwrap(), + Err(PresentationError::Hidden) + )); + assert!(revoked.load(Ordering::Acquire)); + lane.service(&mut host, true); + assert_eq!(host.revoked, 1); + assert!(host.controls.is_empty()); +} + #[tokio::test] async fn continuous_queue_traffic_has_a_fixed_per_pump_bound() { let (sender, receiver) = mpsc::channel(32);