From 624e1efcd5b1db6a8c11a853daa0e7301eddbf9f Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:13:19 -0700 Subject: [PATCH 01/36] feat(firefox): network control and emulation on Firefox Squashed during the stack rebase onto main. --- .github/workflows/ci.yml | 2 +- README.md | 6 +- cli/src/mcp.rs | 8 +- cli/src/native/actions.rs | 393 +++-- cli/src/native/downloads.rs | 54 +- cli/src/native/e2e_tests.rs | 232 ++- cli/src/native/firefox/actions.rs | 48 +- cli/src/native/firefox/bidi.rs | 29 +- cli/src/native/firefox/emulation.rs | 737 +++++++++ cli/src/native/firefox/mod.rs | 2 + cli/src/native/firefox/network.rs | 1422 +++++++++++++++++ cli/src/native/firefox/page.rs | 218 ++- cli/src/native/firefox/process.rs | 59 +- cli/src/native/firefox/session.rs | 321 +++- cli/src/native/firefox/storage.rs | 7 +- cli/src/native/firefox/tabs.rs | 14 +- cli/src/output.rs | 8 +- docs/content/docs/commands.mdx | 2 + docs/content/docs/security.mdx | 2 + .../@agent-browser/sandbox/mcp-tools.json | 318 ++-- skill-data/core/references/commands.md | 4 +- .../core/references/trust-boundaries.md | 2 +- 22 files changed, 3494 insertions(+), 394 deletions(-) create mode 100644 cli/src/native/firefox/emulation.rs create mode 100644 cli/src/native/firefox/network.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c57b0514d2..a2f4ca4e6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,7 +246,7 @@ jobs: run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1 --skip e2e_firefox - name: Run Firefox e2e tests - run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e_firefox -- --ignored --test-threads=1 --skip e2e_firefox_route_mock --skip e2e_firefox_allowed_domains --skip e2e_firefox_download --skip e2e_firefox_emulation + run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e_firefox -- --ignored --test-threads=1 env: RUST_MIN_STACK: "67108864" diff --git a/README.md b/README.md index 4a9e6f8766..d84bd91968 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,8 @@ agent-browser set credentials

# HTTP basic auth for current and future t agent-browser set media [dark|light] # Emulate color scheme ``` +Firefox does not support mobile viewport mode, `set device`, or `reduced-motion` media emulation. Use `set viewport` plus the `--user-agent` launch flag (or `AGENT_BROWSER_USER_AGENT`) instead of `set device`. + `set credentials` applies HTTP Basic Authentication to the current tab and tabs opened later. `set offline off` and `set headers '{}'` restore the default setup for future tabs. ### Cookies & Storage @@ -886,7 +888,7 @@ agent-browser includes security features for safe AI agent deployments. All feat - **Authentication Vault**: Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. `auth login` navigates with `load` and then waits for login form selectors to appear (SPA-friendly, timeout follows the default action timeout). Use `auth login --no-navigate` to preserve an already prepared active page after its origin is checked against the credential URL. A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github` - **Plugin System**: Extend agent-browser with external executable plugins. Plugins run out-of-process over the `agent-browser.plugin.v1` stdio JSON protocol and declare capabilities such as `credential.read`, `browser.provider`, `launch.mutate`, or `command.run`. - **Content Boundary Markers**: Wrap page output in delimiters so LLMs can distinguish tool output from untrusted content: `--content-boundaries` -- **Domain Allowlist**: Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain): `--allowed-domains "example.com,*.example.com"`. Sub-resource requests (scripts, images, fetch), WebSocket/EventSource connections, and `sendBeacon` calls to non-allowed domains are blocked. WebRTC peer connections are disabled in supported Chromium sessions while the allowlist is active to prevent STUN, TURN, and DNS traffic from bypassing HTTP interception. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Include any CDN domains your target pages depend on (e.g., `*.cdn.example.com`). +- **Domain Allowlist**: Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain): `--allowed-domains "example.com,*.example.com"`. Firefox rejects non-HTTP document navigations other than `about:blank`, while non-HTTP sub-resources remain allowed. Sub-resource requests (scripts, images, fetch), WebSocket/EventSource connections, and `sendBeacon` calls to non-allowed domains are blocked. WebRTC peer connections are disabled in supported Chromium sessions while the allowlist is active to prevent STUN, TURN, and DNS traffic from bypassing HTTP interception. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Include any CDN domains your target pages depend on (e.g., `*.cdn.example.com`). - **Action Policy**: Gate destructive actions with a static policy file: `--action-policy ./policy.json`; a missing or invalid file blocks every command except `close` - **Action Confirmation**: Require explicit approval for sensitive action categories: `--confirm-actions eval,download` - **Output Length Limits**: Prevent context flooding: `--max-output 50000` @@ -1093,7 +1095,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--download-path ` | Default download directory (or `AGENT_BROWSER_DOWNLOAD_PATH` env) | | `--content-boundaries` | Wrap page output in boundary markers for LLM safety (or `AGENT_BROWSER_CONTENT_BOUNDARIES` env) | | `--max-output ` | Truncate page output to N characters (or `AGENT_BROWSER_MAX_OUTPUT` env) | -| `--allowed-domains ` | Comma-separated allowed domain patterns; also disables WebRTC peer connections in supported Chromium sessions and rejects CDP, auto-connect, Chrome profiles, restore/state replay, direct-page provider plugins, unsafe startup `--args`, iOS, and Safari (or `AGENT_BROWSER_ALLOWED_DOMAINS` env) | +| `--allowed-domains ` | Comma-separated allowed domain patterns; rejects non-HTTP document navigations in Firefox, disables WebRTC peer connections in supported Chromium sessions, and rejects CDP, auto-connect, Chrome profiles, restore/state replay, direct-page provider plugins, unsafe startup `--args`, iOS, and Safari (or `AGENT_BROWSER_ALLOWED_DOMAINS` env) | | `--action-policy ` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) | | `--confirm-actions ` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) | | `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) | diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index 5010e24ae4..9fe3b7fb03 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -1221,14 +1221,14 @@ fn parity_tools() -> Vec { tool( TOOL_SET_VIEWPORT, "Set viewport", - "Set viewport size.", + "Set viewport size. Firefox does not support mobile mode.", json!({ "width": int_schema(), "height": int_schema(), "scale": number_schema() }), &["width", "height"], ), tool( TOOL_SET_DEVICE, "Set device", - "Emulate a device by name.", + "Emulate a device by name. Firefox does not support set device; use set viewport plus the --user-agent launch flag or AGENT_BROWSER_USER_AGENT instead of set device.", json!({ "device": { "type": "string" } }), &["device"], ), @@ -1263,7 +1263,7 @@ fn parity_tools() -> Vec { tool( TOOL_SET_MEDIA, "Set media", - "Set media emulation.", + "Set media emulation. Firefox does not support reduced motion.", json!({ "colorScheme": { "type": "string", "enum": ["dark", "light", "no-preference"] }, "reducedMotion": { "type": "string", "enum": ["reduce", "no-preference"] } }), &[], ), @@ -2084,7 +2084,7 @@ fn tool(name: &str, title: &str, description: &str, properties: Value, required: json!({ "type": "array", "items": { "type": "string" }, - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active." + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active." }), ); props.insert( diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 0bc0eb2d9d..df6f326ac5 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -147,9 +147,9 @@ impl HarContentMode { /// Bodies larger than this are not embedded in the HAR (the entry keeps its /// size/MIME metadata either way). -const HAR_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +pub(crate) const HAR_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; /// Total budget for embedded bodies across one recording session. -const HAR_MAX_TOTAL_BODY_BYTES: usize = 64 * 1024 * 1024; +pub(crate) const HAR_MAX_TOTAL_BODY_BYTES: usize = 64 * 1024 * 1024; pub struct RouteEntry { pub url_pattern: String, @@ -170,6 +170,18 @@ pub struct RouteResponse { pub allow_origin: Option, } +impl RouteEntry { + /// Returns whether this route applies to the given URL and resource type. + pub(crate) fn matches(&self, url: &str, resource_type: &str) -> bool { + route_url_matches(&self.url_pattern, url) + && (self.resource_types.is_empty() + || self + .resource_types + .iter() + .any(|rt| rt.eq_ignore_ascii_case(resource_type))) + } +} + #[derive(Clone, serde::Serialize)] pub struct TrackedRequest { pub url: String, @@ -1344,8 +1356,8 @@ impl DaemonState { } pub async fn drain_cdp_events_background(&mut self) -> Result<(), String> { + firefox::network::drain(self).await; if let Some(ff) = self.firefox.as_mut() { - ff.drain_events(&mut self.pending_dialog); if ff.top_context.is_empty() { let tree = ff .bidi @@ -3766,7 +3778,10 @@ fn should_blank_existing_url(url: &str, filter: &DomainFilter) -> bool { .unwrap_or(false) } -fn check_url_allowed_by_filter(filter: Option<&DomainFilter>, url: &str) -> Result<(), String> { +pub(super) fn check_url_allowed_by_filter( + filter: Option<&DomainFilter>, + url: &str, +) -> Result<(), String> { if let Some(filter) = filter { if url != "about:blank" { filter.check_url(url)?; @@ -3885,7 +3900,7 @@ fn direct_page_allowed_domains_error() -> String { .to_string() } -async fn ensure_state_replay_supported_by_active_domain_filter( +pub(super) async fn ensure_state_replay_supported_by_active_domain_filter( state: &DaemonState, source: &str, ) -> Result<(), String> { @@ -4359,6 +4374,14 @@ async fn apply_session_setup(state: &mut DaemonState, session_id: &str) -> Resul Ok(()) } +/// Replaces the stored proxy credentials with the launch options, clearing them when absent. +async fn store_proxy_credentials(state: &DaemonState, options: &LaunchOptions) { + *state.proxy_credentials.write().await = options + .proxy_username + .clone() + .map(|user| (user, options.proxy_password.clone().unwrap_or_default())); +} + async fn auto_launch( state: &mut DaemonState, plugins: Vec, @@ -4427,7 +4450,7 @@ async fn auto_launch_inner( &launch_init_script_paths_from_env(), &plugins, )?; - let allowed = !current_allowed_domains(state).await.is_empty(); + let allowed = current_allowed_domains(state).await; return launch_firefox(state, options, cdp, auto_connect, provider, allowed) .await .map(|_| ()); @@ -4448,13 +4471,7 @@ async fn auto_launch_inner( // Store proxy credentials for Fetch.authRequired handling let has_proxy_auth = options.proxy_username.is_some(); - if has_proxy_auth { - let mut creds = state.proxy_credentials.write().await; - *creds = Some(( - options.proxy_username.clone().unwrap_or_default(), - options.proxy_password.clone().unwrap_or_default(), - )); - } + store_proxy_credentials(state, &options).await; state.engine = engine.as_deref().unwrap_or("chrome").to_string(); write_engine_file(&state.session_id, &state.engine); @@ -5303,25 +5320,45 @@ fn reject_firefox_launch_extras( Ok(()) } +async fn set_domain_filter(state: &DaemonState, domains: &[String]) { + *state.domain_filter.write().await = if domains.is_empty() { + None + } else { + Some(DomainFilter::new(&domains.join(","))) + }; +} + /// Validates the options and launches (or reuses) an owned Firefox for both launch paths. async fn launch_firefox( state: &mut DaemonState, - options: LaunchOptions, + mut options: LaunchOptions, cdp: Option, auto_connect: bool, provider: Option, - allowed_domains: bool, + allowed_domains: Vec, ) -> Result { firefox::process::validate_firefox_options( &options, cdp.as_deref(), auto_connect, provider.as_deref(), - allowed_domains, )?; + ensure_allowed_domains_supported_for_launch(AllowedDomainsLaunchSupport { + allowed_domains: &allowed_domains, + cdp_url: None, + cdp_port: None, + auto_connect: false, + profile: None, + provider_name: None, + args: &[], + restore_key: state.session_name.as_deref(), + storage_state: options.storage_state.as_deref(), + })?; + options.restrict_webrtc = !allowed_domains.is_empty(); + store_proxy_credentials(state, &options).await; let hash = launch_hash( &options, - &[], + &allowed_domains, &[], &[], &[], @@ -5341,7 +5378,16 @@ async fn launch_firefox( } state.ref_map.invalidate_all_documents(); state.reset_input_state(); - let session = firefox::session::FirefoxSession::launch(&options, state.auto_dialog).await?; + let net = firefox::network::NetContext::from_state(state); + let mut session = + firefox::session::FirefoxSession::launch(&options, net, state.auto_dialog).await?; + if !allowed_domains.is_empty() + || state.proxy_credentials.read().await.is_some() + || !state.routes.read().await.is_empty() + || !state.origin_headers.read().await.is_empty() + { + firefox::network::ensure_intercept(&mut session).await?; + } state.engine = "firefox".to_string(); write_engine_file(&state.session_id, &state.engine); state.launch_hash = Some(hash); @@ -5351,6 +5397,18 @@ async fn launch_firefox( Ok(json!({ "launched": true, "relaunchedBrowser": had_browser })) } +fn should_use_env_proxy_credentials( + command_proxy: Option<&Value>, + env_proxy: Option<&str>, +) -> bool { + command_proxy.is_none_or(|proxy| { + proxy + .as_str() + .or_else(|| proxy.get("server").and_then(Value::as_str)) + .is_some_and(|server| Some(server) == env_proxy) + }) +} + async fn handle_launch_inner(cmd: &Value, state: &mut DaemonState) -> Result { let effective_ca_cert = resolve_effective_ca_cert(cmd, state)?; // Absent field falls back to the daemon's spawn-time env (mirrors @@ -5424,6 +5482,9 @@ async fn handle_launch_inner(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result Result Result Result { +pub(crate) async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result { let clear = cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false); if clear { state.event_tracker.clear_console(); @@ -7726,7 +7798,7 @@ async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result Result { +pub(crate) async fn handle_errors(cmd: &Value, state: &mut DaemonState) -> Result { let clear = cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false); if clear { state.event_tracker.clear_errors(); @@ -8311,9 +8383,9 @@ async fn handle_set_media(cmd: &Value, state: &mut DaemonState) -> Result bool { +pub(crate) async fn wait_for_downloaded_file(path: &std::path::Path) -> bool { for _ in 0..10 { if path.exists() { return true; @@ -8335,16 +8407,8 @@ async fn handle_download(cmd: &Value, state: &mut DaemonState) -> Result Result { - let selector = cmd - .get("selector") - .and_then(|v| v.as_str()) - .ok_or("Missing 'selector' parameter")?; - let path_str = cmd - .get("path") - .and_then(|v| v.as_str()) - .ok_or("Missing 'path' parameter")?; - +/// Resolves a download target to an absolute `(directory, file)` pair, creating the directory. +pub(crate) fn resolve_download_dest(path_str: &str) -> Result<(PathBuf, PathBuf), String> { // Resolve to absolute path and canonicalize to prevent path traversal let raw_dest = if std::path::Path::new(path_str).is_absolute() { PathBuf::from(path_str) @@ -8373,6 +8437,20 @@ async fn download_to_path(cmd: &Value, state: &mut DaemonState) -> Result Result { + let selector = cmd + .get("selector") + .and_then(|v| v.as_str()) + .ok_or("Missing 'selector' parameter")?; + let path_str = cmd + .get("path") + .and_then(|v| v.as_str()) + .ok_or("Missing 'path' parameter")?; + + let (download_dir, dest) = resolve_download_dest(path_str)?; let download_dir_str = download_dir .to_str() .ok_or("Download directory path is not valid UTF-8")?; @@ -9713,6 +9791,42 @@ async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y })) } +/// Viewport, scale, touch and user agent for a named device preset. +pub(crate) struct DevicePreset { + pub width: i32, + pub height: i32, + pub device_scale_factor: f64, + pub mobile: bool, + pub user_agent: &'static str, +} + +pub(crate) fn device_preset(name: &str) -> Result { + let (width, height, device_scale_factor, mobile, user_agent) = + match name.to_lowercase().as_str() { + "iphone 15" | "iphone15" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"), + "iphone 16" | "iphone16" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), + "iphone 16 pro" | "iphone16pro" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), + "iphone 17" | "iphone17" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1"), + "ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), + "ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), + "pixel 9" | "pixel9" => (412, 923, 2.625, true, "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), + "galaxy s25" | "galaxys25" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 15; SM-S931B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), + "iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"), + "iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"), + "pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), + "pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"), + "galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), + _ => return Err(format!("Unknown device: {}. Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro, Pixel 9, Galaxy S25", name)), + }; + Ok(DevicePreset { + width, + height, + device_scale_factor, + mobile, + user_agent, + }) +} + async fn handle_device(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let name = cmd @@ -9721,23 +9835,14 @@ async fn handle_device(cmd: &Value, state: &mut DaemonState) -> Result (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"), - "iphone 16" | "iphone16" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), - "iphone 16 pro" | "iphone16pro" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), - "iphone 17" | "iphone17" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1"), - "ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), - "ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), - "pixel 9" | "pixel9" => (412, 923, 2.625, true, "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), - "galaxy s25" | "galaxys25" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 15; SM-S931B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), - // Legacy aliases - "iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"), - "iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"), - "pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), - "pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"), - "galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), - _ => return Err(format!("Unknown device: {}. Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro, Pixel 9, Galaxy S25", name)), - }; + let preset = device_preset(name)?; + let (width, height, scale, mobile, ua) = ( + preset.width, + preset.height, + preset.device_scale_factor, + preset.mobile, + preset.user_agent, + ); mgr.set_viewport(width, height, scale, mobile).await?; mgr.set_user_agent(ua).await?; @@ -11698,10 +11803,10 @@ async fn handle_waitfordownload(cmd: &Value, state: &mut DaemonState) -> Result< move_download(&saved, path_str).inspect_err(|_| state.downloads.release(&record.guid)) } -/// The saved path comes from a CDP event, so only a browser this daemon +/// The saved path comes from a browser event, so only a browser this daemon /// launched is trusted to name a local file. A remote endpoint could otherwise /// point the move at any file the daemon can read. -fn may_move_download( +pub(crate) fn may_move_download( saved: &std::path::Path, launched_locally: bool, download_dir: Option<&str>, @@ -11718,7 +11823,7 @@ fn may_move_download( } } -fn move_download(saved: &std::path::Path, path_str: &str) -> Result { +pub(crate) fn move_download(saved: &std::path::Path, path_str: &str) -> Result { let dest = if std::path::Path::new(path_str).is_absolute() { PathBuf::from(path_str) } else { @@ -11726,6 +11831,9 @@ fn move_download(saved: &std::path::Path, path_str: &str) -> Result Result Result { +pub(crate) async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result { let path = har_output_path(cmd.get("path").and_then(|v| v.as_str())); state.har_recording = false; @@ -12129,7 +12237,7 @@ fn har_extract_headers(headers_val: Option<&Value>) -> Vec<(String, String)> { /// Whether a MIME type is text-like enough to embed as HAR `content.text` /// under [`HarContentMode::Text`]. -fn har_mime_is_text(mime: &str) -> bool { +pub(crate) fn har_mime_is_text(mime: &str) -> bool { let mime = mime .split(';') .next() @@ -12153,7 +12261,7 @@ fn har_mime_is_text(mime: &str) -> bool { /// Map a CDP `response.protocol` value to an HTTP-version string as required /// by the HAR spec (e.g. `"h2"` → `"HTTP/2.0"`). -fn har_cdp_protocol_to_http_version(protocol: &str) -> String { +pub(crate) fn har_cdp_protocol_to_http_version(protocol: &str) -> String { match protocol.to_ascii_lowercase().as_str() { "h2" => "HTTP/2.0".to_string(), "h3" => "HTTP/3.0".to_string(), @@ -12383,7 +12491,7 @@ fn collapse_wildcards(pattern: &str) -> String { /// Status, headers and base64 body for a mocked route response. A mock gets /// CORS headers only for the origin the route allows (`--allow-origin`), so a /// page from any other origin cannot read it. -fn mocked_response( +pub(crate) fn mocked_response( resp: &RouteResponse, request_headers: Option<&serde_json::Map>, ) -> (u16, Vec, String) { @@ -12595,17 +12703,7 @@ async fn resolve_fetch_paused( // Route matching for route in routes { - let url_matches = route_url_matches(&route.url_pattern, &paused.url); - - let resource_type_matches = route.resource_types.is_empty() - || route - .resource_types - .iter() - .any(|rt| rt.eq_ignore_ascii_case(&paused.resource_type)); - - let matches = url_matches && resource_type_matches; - - if matches { + if route.matches(&paused.url, &paused.resource_type) { if route.abort { let _ = client .send_command( @@ -12758,8 +12856,25 @@ fn parse_route_response(cmd: &Value) -> Option { } async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result { + let session_id = state + .browser + .as_ref() + .ok_or("Browser not launched")? + .active_session_id()? + .to_string(); + let url_pattern = add_route(cmd, state).await?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?; - let session_id = mgr.active_session_id()?.to_string(); + let patterns = build_fetch_patterns(state).await; + let params = build_fetch_enable_params(state, patterns).await; + mgr.client + .send_command("Fetch.enable", Some(params), Some(&session_id)) + .await?; + + Ok(json!({ "routed": url_pattern })) +} + +/// Parses a `route` command and appends it to the shared route list. Returns the URL pattern. +pub(crate) async fn add_route(cmd: &Value, state: &DaemonState) -> Result { let url_pattern = cmd .get("url") .and_then(|v| v.as_str()) @@ -12800,33 +12915,14 @@ async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); - let url = cmd.get("url").and_then(|v| v.as_str()); - - { - let mut routes = state.routes.write().await; - match url { - Some(pattern) => { - routes.retain(|r| r.url_pattern != pattern); - } - None => { - routes.clear(); - } - } - } + let label = remove_routes(cmd, state).await; let patterns = build_fetch_patterns(state).await; if patterns.is_empty() { @@ -12840,10 +12936,20 @@ async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result String { + let url = cmd.get("url").and_then(|v| v.as_str()); + let mut routes = state.routes.write().await; + match url { + Some(pattern) => routes.retain(|r| r.url_pattern != pattern), + None => routes.clear(), + } + url.unwrap_or("all").to_string() +} + pub fn matches_status_filter(status: Option, filter: &str) -> bool { let Some(code) = status else { return false }; let f = filter.to_lowercase(); @@ -12863,7 +12969,7 @@ pub fn matches_status_filter(status: Option, filter: &str) -> bool { false } -async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result { +pub(crate) async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result { if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) { state.tracked_requests.clear(); return Ok(json!({ "cleared": true })); @@ -12920,7 +13026,10 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result Result { +pub(crate) async fn handle_request_detail( + cmd: &Value, + state: &mut DaemonState, +) -> Result { let request_id = cmd .get("requestId") .and_then(|v| v.as_str()) @@ -17607,6 +17716,20 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"data":{}}' assert!(!opts.restrict_webrtc); } + #[test] + fn launch_proxy_uses_env_credentials_only_for_the_same_server() { + let proxy = json!({ "server": "http://proxy.example:8080" }); + assert!(super::should_use_env_proxy_credentials( + Some(&proxy), + Some("http://proxy.example:8080") + )); + assert!(!super::should_use_env_proxy_credentials( + Some(&proxy), + Some("http://other-proxy.example:8080") + )); + assert!(super::should_use_env_proxy_credentials(None, None)); + } + #[test] fn test_launch_options_from_env_headed_flag() { let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]); @@ -18201,7 +18324,7 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"data":{}}' let guard = EnvGuard::new(&["AGENT_BROWSER_ALLOWED_DOMAINS"]); guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS"); - let state = DaemonState::new(); + let mut state = DaemonState::new(); { let mut df = state.domain_filter.write().await; *df = Some(DomainFilter::new("example.com")); @@ -18213,6 +18336,15 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"data":{}}' assert!(error.contains("state load"), "got: {}", error); assert!(error.contains("replays saved origins"), "got: {}", error); + + let firefox_error = firefox::storage::state_load(&json!({"path":"unused"}), &mut state) + .await + .unwrap_err(); + assert!( + firefox_error.contains("state load"), + "got: {}", + firefox_error + ); } #[test] @@ -19788,10 +19920,16 @@ mod firefox_routing_tests { .await .unwrap_err(); assert!(e.contains("--args"), "{e}"); - let e = handle_launch(&launch(json!({"allowedDomains":"a.test"})), &mut state) - .await - .unwrap_err(); - assert!(e.contains("allowed-domains"), "{e}"); + let e = handle_launch( + &launch(json!({"allowedDomains":"a.test","restoreKey":"k"})), + &mut state, + ) + .await + .unwrap_err(); + assert!( + e.contains("--allowed-domains") && e.contains("--restore"), + "{e}" + ); assert!(state.firefox.is_none()); } @@ -19828,17 +19966,38 @@ mod firefox_routing_tests { None, ); state.launch_hash = Some(hash); - let r = launch_firefox(&mut state, options.clone(), None, false, None, false) + let r = launch_firefox(&mut state, options.clone(), None, false, None, Vec::new()) .await .unwrap(); assert_eq!(r["reused"], true); options.storage_state = Some("{}".into()); - let error = launch_firefox(&mut state, options, None, false, None, false) + let error = launch_firefox(&mut state, options, None, false, None, Vec::new()) .await .unwrap_err(); assert!(error.contains("Firefox executable not found"), "{error}"); } + #[tokio::test] + async fn implicit_launch_rejects_allowed_domains_with_a_session_name() { + let _guard = clean_env(); + let (mut state, _calls) = firefox_state().await; + state.session_name = Some("k".into()); + let e = launch_firefox( + &mut state, + launch_options_from_env(), + None, + false, + None, + vec!["a.test".into()], + ) + .await + .unwrap_err(); + assert!( + e.contains("--allowed-domains") && e.contains("--restore"), + "{e}" + ); + } + #[tokio::test] async fn chrome_only_action_errors_without_touching_the_browser_and_before_other_gates() { let _guard = clean_env(); diff --git a/cli/src/native/downloads.rs b/cli/src/native/downloads.rs index e099dd5908..88dfd03380 100644 --- a/cli/src/native/downloads.rs +++ b/cli/src/native/downloads.rs @@ -17,6 +17,8 @@ pub struct DownloadRecord { /// Where the browser saved the file. Chrome only reports it on the /// completed `Browser.downloadProgress` event when `eventsEnabled` is set. pub file_path: Option, + pub context: Option, + pub suggested_filename: Option, /// Set once any `Browser.*` event arrives for this download. From then on /// only `Browser.downloadProgress` finishes it: `Page.downloadProgress` /// reports completion first, before the path is known. @@ -39,16 +41,26 @@ impl DownloadLedger { /// Record a download event. `Page.*` and `Browser.*` events for one /// download share a guid; only `Browser.downloadProgress` carries the file /// path, and only when `eventsEnabled` is set. Provider connections that - /// never enable it still report completion through `Page.*`. + /// never enable it still report completion through `Page.*`. Firefox BiDi + /// events use `download` (falling back to `navigation`) in place of `guid`. /// Returns false for any other method. pub fn observe(&mut self, method: &str, params: &Value) -> bool { - let Some(guid) = params.get("guid").and_then(|v| v.as_str()) else { + let Some(guid) = params + .get("guid") + .or_else(|| params.get("download")) + .or_else(|| params.get("navigation")) + .and_then(|v| v.as_str()) + else { return false; }; match method { - "Browser.downloadWillBegin" | "Page.downloadWillBegin" => { + "Browser.downloadWillBegin" + | "Page.downloadWillBegin" + | "browsingContext.downloadWillBegin" => { let record = self.record_mut(guid); record.browser_events |= method.starts_with("Browser."); + record.context = params["context"].as_str().map(String::from); + record.suggested_filename = params["suggestedFilename"].as_str().map(String::from); true } "Browser.downloadProgress" | "Page.downloadProgress" => { @@ -70,16 +82,42 @@ impl DownloadLedger { } true } + "browsingContext.downloadEnd" => { + let record = self.record_mut(guid); + match params.get("status").and_then(|v| v.as_str()) { + Some("complete") => record.state = DownloadState::Completed, + Some("canceled") => record.state = DownloadState::Canceled, + _ => {} + } + if let Some(path) = params.get("filepath").and_then(|v| v.as_str()) { + record.file_path = Some(path.to_string()); + } + true + } _ => false, } } /// Claim the oldest finished download that no earlier wait has claimed. pub fn claim_finished(&mut self) -> Option { - let record = self - .records - .iter_mut() - .find(|r| !r.claimed && r.state != DownloadState::InProgress)?; + self.claim_finished_since(0, &[]) + } + + pub fn cursor(&self) -> usize { + self.records.len() + } + + pub fn claim_finished_since( + &mut self, + cursor: usize, + contexts: &[&str], + ) -> Option { + let record = self.records.iter_mut().skip(cursor).find(|record| { + !record.claimed + && record.state != DownloadState::InProgress + && (contexts.is_empty() + || contexts.contains(&record.context.as_deref().unwrap_or_default())) + })?; record.claimed = true; Some(record.clone()) } @@ -106,6 +144,8 @@ impl DownloadLedger { guid: guid.to_string(), state: DownloadState::InProgress, file_path: None, + context: None, + suggested_filename: None, browser_events: false, claimed: false, }); diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 500b0cf22e..717d209d29 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -14398,6 +14398,16 @@ fn firefox_serve( let head = String::from_utf8_lossy(&request[..n]); let path = head.split_whitespace().nth(1).unwrap_or("/").to_string(); hits.lock().unwrap().push(format!("{port}{path}")); + if head.to_ascii_lowercase().contains("\r\nauthorization:") { + hits.lock() + .unwrap() + .push(format!("{port}{path}#authorization")); + } + let status = if path == "/auth" { + "401 Unauthorized" + } else { + "200 OK" + }; let (content_type, extra, body) = match path.as_str() { "/" => ( "text/html; charset=utf-8", @@ -14420,6 +14430,11 @@ fn firefox_serve( include_str!("test_fixtures/firefox_forms_frame.html").to_string(), ), "/api" => ("text/plain", "", "real".to_string()), + "/auth" => ( + "text/plain", + "WWW-Authenticate: Basic realm=\"origin\"\r\n", + "denied".to_string(), + ), "/download.txt" => ( "text/plain", "Content-Disposition: attachment; filename=\"download.txt\"\r\n", @@ -14428,7 +14443,7 @@ fn firefox_serve( _ => ("text/plain", "", "ok".to_string()), }; let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); let _ = stream.write_all(response.as_bytes()).await; @@ -14481,7 +14496,7 @@ fn has_cookie(cookies: &Value, name: &str) -> bool { } fn firefox_test_environment(tmp: &std::path::Path) -> EnvGuard<'static> { - const KEYS: [&str; 15] = [ + const KEYS: [&str; 17] = [ "AGENT_BROWSER_CDP", "AGENT_BROWSER_AUTO_CONNECT", "AGENT_BROWSER_PROVIDER", @@ -14489,6 +14504,8 @@ fn firefox_test_environment(tmp: &std::path::Path) -> EnvGuard<'static> { "AGENT_BROWSER_EXECUTABLE_PATH", "AGENT_BROWSER_PROXY", "AGENT_BROWSER_PROXY_BYPASS", + "AGENT_BROWSER_PROXY_USERNAME", + "AGENT_BROWSER_PROXY_PASSWORD", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", @@ -14792,7 +14809,7 @@ async fn e2e_firefox_smoke() { _env.remove("AGENT_BROWSER_ENCRYPTION_KEY"); let fixture = firefox_fixture().await; let mut state = DaemonState::new(); - launch_firefox(&mut state, json!({})).await; + launch_firefox(&mut state, json!({ "userAgent": "AgentBrowserTest/2.0" })).await; assert!( firefox_processes_using(tmp.path()), "Firefox verification must own a Firefox process with a profile under the temp dir" @@ -14800,6 +14817,10 @@ async fn e2e_firefox_smoke() { firefox_open(&mut state, &fixture.url).await; assert_eq!(firefox_title(&mut state).await, "Firefox adapter probe"); assert_eq!(firefox_eval(&mut state, "1 + 1").await, json!(2)); + assert_eq!( + firefox_eval(&mut state, "navigator.userAgent").await, + json!("AgentBrowserTest/2.0") + ); let resp = firefox_command(&mut state, json!({ "id": "t", "action": "trace_start" })).await; assert_eq!(resp["success"], false, "{resp}"); assert!( @@ -15402,6 +15423,14 @@ async fn e2e_firefox_allowed_domains_block_worker_and_websocket() { let fixture = firefox_fixture().await; let mut state = DaemonState::new(); launch_firefox(&mut state, json!({ "allowedDomains": ["localhost"] })).await; + let file = firefox_command( + &mut state, + json!({ "id": "file", "action": "navigate", "url": "file:///etc/hosts" }), + ) + .await; + assert_eq!(file["success"], false, "{file}"); + let target = json!({ "id": "tab", "action": "tab_new", "url": &fixture.other }); + assert_eq!(firefox_command(&mut state, target).await["success"], false); firefox_open(&mut state, &fixture.url).await; let own = format!("runWorker('{}hit')", fixture.url); @@ -15428,6 +15457,173 @@ async fn e2e_firefox_allowed_domains_block_worker_and_websocket() { firefox_close(&mut state).await; } +/// PR3: proxy credentials are never sent to an origin that answers 401. +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_proxy_credentials_not_sent_to_origin() { + required_firefox_binary(); + let tmp = tempfile::tempdir().unwrap(); + let _env = firefox_test_environment(tmp.path()); + let fixture = firefox_fixture().await; + let mut state = DaemonState::new(); + launch_firefox( + &mut state, + json!({ "proxy": { + "server": "http://127.0.0.1:9", + "bypass": "localhost,127.0.0.1", + "username": "proxy-user", + "password": "proxy-pass", + } }), + ) + .await; + firefox_open(&mut state, &format!("{}auth", fixture.url)).await; + assert!(fixture.hit_count("/auth") > 0, "origin must be reached"); + assert_eq!( + fixture.hit_count("#authorization"), + 0, + "proxy credentials must not reach the origin" + ); + firefox_close(&mut state).await; +} + +async fn firefox_auth_proxy(accept_credentials: bool) -> (String, Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let attempts = Arc::new(AtomicUsize::new(0)); + let count = attempts.clone(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let mut request = [0u8; 4096]; + let n = stream.read(&mut request).await.unwrap_or(0); + let text = String::from_utf8_lossy(&request[..n]).to_string(); + if text.contains("http://example.com/") { + count.fetch_add(1, Ordering::Relaxed); + } + let authorized = + accept_credentials && text.to_ascii_lowercase().contains("proxy-authorization"); + let response = if authorized { + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok" + } else { + "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"proxy\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + }; + let _ = stream.write_all(response.as_bytes()).await; + } + }); + (url, attempts) +} + +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_proxy_credentials() { + required_firefox_binary(); + let tmp = tempfile::tempdir_in(".").unwrap(); + let _env = firefox_test_environment(tmp.path()); + let mut state = DaemonState::new(); + let (rejected_proxy, rejected_attempts) = firefox_auth_proxy(false).await; + launch_firefox( + &mut state, + json!({ "proxy": { "server": rejected_proxy, "username": "proxy-user", "password": "wrong" } }), + ) + .await; + let started = std::time::Instant::now(); + let response = firefox_command( + &mut state, + json!({ "id": "nav", "action": "navigate", "url": "http://example.com/" }), + ) + .await; + let elapsed = started.elapsed(); + firefox_close(&mut state).await; + + assert_eq!(response["success"], false, "{response}"); + assert!( + response["error"] + .as_str() + .is_some_and(|error| error.starts_with("Navigation failed:")), + "navigation should report a clear error: {response}" + ); + assert!(elapsed < std::time::Duration::from_secs(5), "{elapsed:?}"); + assert_eq!(rejected_attempts.load(Ordering::Relaxed), 3); + let (accepted_proxy, _) = firefox_auth_proxy(true).await; + launch_firefox( + &mut state, + json!({ "proxy": { "server": accepted_proxy, "username": "proxy-user", "password": "proxy-pass" } }), + ) + .await; + let response = firefox_command( + &mut state, + json!({ "id": "nav", "action": "navigate", "url": "http://example.com/" }), + ) + .await; + firefox_close(&mut state).await; + + assert_success(&response); +} + +/// Relaunching through a different proxy must not offer the previous proxy's credentials. +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_stale_proxy_credentials_not_reused() { + required_firefox_binary(); + let tmp = tempfile::tempdir_in(".").unwrap(); + let env = firefox_test_environment(tmp.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("http://{}", listener.local_addr().unwrap()); + let requests: Arc>> = Arc::default(); + let seen = requests.clone(); + let proxy_server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let mut request = [0u8; 4096]; + let n = stream.read(&mut request).await.unwrap_or(0); + seen.lock() + .unwrap() + .push(String::from_utf8_lossy(&request[..n]).to_string()); + let _ = stream + .write_all( + b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"proxy\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + } + }); + let mut state = DaemonState::new(); + launch_firefox( + &mut state, + json!({ "proxy": { + "server": "http://127.0.0.1:9", + "username": "proxy-a-user", + "password": "proxy-a-pass", + } }), + ) + .await; + env.set("AGENT_BROWSER_PROXY_USERNAME", "proxy-a-user"); + env.set("AGENT_BROWSER_PROXY_PASSWORD", "proxy-a-pass"); + launch_firefox(&mut state, json!({ "proxy": { "server": proxy } })).await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + firefox_command( + &mut state, + json!({ "id": "nav", "action": "navigate", "url": "http://example.com/" }), + ), + ) + .await; + firefox_close(&mut state).await; + proxy_server.abort(); + let requests = requests.lock().unwrap().join("\n"); + + assert!(!requests.is_empty(), "the second proxy must be used"); + assert!( + !requests + .to_ascii_lowercase() + .contains("proxy-authorization"), + "the second proxy received the first proxy's credentials: {requests}" + ); +} + /// PR3: clicking a download link saves the file to the requested path. #[tokio::test] #[ignore = "requires FIREFOX_BIN; run serially"] @@ -15451,6 +15647,36 @@ async fn e2e_firefox_download() { .await; assert_success(&resp); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "downloaded"); + + let wait_dest = out.path().join("waited.txt"); + let ff = state.firefox.as_ref().unwrap(); + let bidi = ff.bidi.clone(); + let context = ff.top_context.clone(); + let trigger = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + bidi.call_function( + &context, + "function() { document.querySelector('#dl').click(); return true; }", + None, + vec![], + ) + .await + .unwrap(); + }); + let resp = firefox_command( + &mut state, + json!({ + "id": "waitfordownload", + "action": "waitfordownload", + "path": wait_dest.to_str().unwrap(), + "timeout": 5000, + }), + ) + .await; + trigger.await.unwrap(); + assert_success(&resp); + assert_eq!(get_data(&resp)["path"], json!(wait_dest.to_string_lossy())); + assert_eq!(std::fs::read_to_string(&wait_dest).unwrap(), "downloaded"); firefox_close(&mut state).await; } diff --git a/cli/src/native/firefox/actions.rs b/cli/src/native/firefox/actions.rs index a6f4fbc37b..c34ba88576 100644 --- a/cli/src/native/firefox/actions.rs +++ b/cli/src/native/firefox/actions.rs @@ -3,7 +3,7 @@ use serde_json::Value; use super::session::FirefoxSession; -use super::{capture, forms, interact, page, query, snapshot, storage, tabs}; +use super::{capture, emulation, forms, interact, network, page, query, snapshot, storage, tabs}; use crate::native::actions::{self as chrome, DaemonState}; /// Actions that run on Firefox. @@ -94,6 +94,30 @@ pub const SUPPORTED_ACTIONS: &[&str] = &[ "getbytestid", "nth", "evalhandle", + "route", + "unroute", + "headers", + "credentials", + "requests", + "request_detail", + "responsebody", + "har_start", + "har_stop", + "console", + "errors", + "download", + "waitfordownload", + "viewport", + "device", + "useragent", + "user_agent", + "locale", + "timezone", + "geolocation", + "permissions", + "offline", + "set_media", + "emulatemedia", "close", "confirm", "deny", @@ -264,6 +288,28 @@ pub async fn execute(action: &str, cmd: &Value, state: &mut DaemonState) -> Resu "getbytestid" => query::getbytestid(cmd, state).await, "nth" => query::nth(cmd, state).await, "evalhandle" => query::evalhandle(cmd, state).await, + "route" => network::route(cmd, state).await, + "unroute" => network::unroute(cmd, state).await, + "headers" => network::headers(cmd, state).await, + "credentials" => network::credentials(cmd, state).await, + "requests" => chrome::handle_requests(cmd, state).await, + "request_detail" => network::request_detail(cmd, state).await, + "responsebody" => network::responsebody(cmd, state).await, + "har_start" => network::har_start(cmd, state).await, + "har_stop" => chrome::handle_har_stop(cmd, state).await, + "console" => chrome::handle_console(cmd, state).await, + "errors" => chrome::handle_errors(cmd, state).await, + "download" => network::download(cmd, state).await, + "waitfordownload" => network::waitfordownload(cmd, state).await, + "viewport" => emulation::set_viewport(cmd, state).await, + "device" => emulation::set_device(cmd, state).await, + "useragent" | "user_agent" => emulation::set_user_agent(cmd, state).await, + "locale" => emulation::set_locale(cmd, state).await, + "timezone" => emulation::set_timezone(cmd, state).await, + "geolocation" => emulation::set_geolocation(cmd, state).await, + "permissions" => emulation::set_permissions(cmd, state).await, + "offline" => emulation::set_offline(cmd, state).await, + "set_media" | "emulatemedia" => emulation::set_media(cmd, state).await, "close" => chrome::handle_close(state).await, _ => chrome::handle_deny(cmd, state).await, } diff --git a/cli/src/native/firefox/bidi.rs b/cli/src/native/firefox/bidi.rs index 1a8590d00c..bc5abf8764 100644 --- a/cli/src/native/firefox/bidi.rs +++ b/cli/src/native/firefox/bidi.rs @@ -6,7 +6,9 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::sync::{broadcast, oneshot}; +use tokio::sync::{mpsc, oneshot}; + +const BUFFERED_EVENT_CAPACITY: usize = 65_536; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::tungstenite::Message; @@ -22,7 +24,7 @@ type WsSink = futures_util::stream::SplitSink< pub struct BidiClient { sink: tokio::sync::Mutex, pending: Pending, - events: broadcast::Sender, + buffered_events: Arc>>>, next_id: AtomicU64, pub(crate) reader: tokio::task::JoinHandle<()>, } @@ -46,8 +48,8 @@ impl BidiClient { .map_err(|e| format!("BiDi connect failed: {e}"))?; let (sink, mut stream) = ws.split(); let pending: Pending = Arc::new(Mutex::new(HashMap::new())); - let (events, _) = broadcast::channel(1024); - let (p, ev) = (pending.clone(), events.clone()); + let buffered_events = Arc::new(Mutex::new(Vec::>::new())); + let (p, be) = (pending.clone(), buffered_events.clone()); let reader = tokio::spawn(async move { while let Some(Ok(msg)) = stream.next().await { let Message::Text(text) = msg else { continue }; @@ -56,7 +58,12 @@ impl BidiClient { }; match v["type"].as_str() { Some("event") => { - let _ = ev.send(v); + be.lock().unwrap().retain(|tx| { + !matches!( + tx.try_send(v.clone()), + Err(mpsc::error::TrySendError::Closed(_)) + ) + }); } Some(kind @ ("success" | "error")) => { let Some(tx) = v["id"] @@ -83,15 +90,17 @@ impl BidiClient { Ok(Self { sink: tokio::sync::Mutex::new(sink), pending, - events, + buffered_events, next_id: AtomicU64::new(1), reader, }) } - /// Subscribes to BiDi events (`type: event` messages). - pub fn subscribe(&self) -> broadcast::Receiver { - self.events.subscribe() + /// Queues events without blocking the reader; events past the 65,536-item capacity are dropped. + pub fn subscribe_buffered(&self) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(BUFFERED_EVENT_CAPACITY); + self.buffered_events.lock().unwrap().push(tx); + rx } /// Sends a command with the default 30s timeout and returns its `result`. @@ -293,7 +302,7 @@ mod tests { .unwrap(); }); let c = BidiClient::connect(&url).await.unwrap(); - let mut ev = c.subscribe(); + let mut ev = c.subscribe_buffered(); let r = c.send("a.b", json!({})).await.unwrap(); assert_eq!(r, json!({})); assert_eq!(ev.recv().await.unwrap()["method"], "x.y"); diff --git a/cli/src/native/firefox/emulation.rs b/cli/src/native/firefox/emulation.rs new file mode 100644 index 0000000000..ed0f1c701a --- /dev/null +++ b/cli/src/native/firefox/emulation.rs @@ -0,0 +1,737 @@ +use serde_json::{json, Map, Value}; + +use super::actions::session; +use super::bidi::BidiClient; +use super::rdp::RdpClient; +use super::session::FirefoxSession; +use crate::native::actions::{DaemonState, EmulatedMedia}; + +pub(crate) type Command = (&'static str, Value); + +/// Emulation applied to the active tab and replayed onto tabs the daemon opens. +#[derive(Debug, Default, Clone)] +pub struct Settings { + pub viewport: Option<(i64, i64, f64)>, + pub user_agent: Option, + pub locale: Option, + pub timezone: Option, + pub geolocation: Option<(f64, f64, Option)>, + pub offline: Option, + pub media: Option, +} + +/// Maps a viewport size and device pixel ratio to `browsingContext.setViewport`. +pub(crate) fn viewport(width: i64, height: i64, device_pixel_ratio: f64) -> Command { + ( + "browsingContext.setViewport", + json!({ + "viewport": { "width": width, "height": height }, + "devicePixelRatio": device_pixel_ratio, + }), + ) +} + +/// Maps a user agent string to `emulation.setUserAgentOverride`. +pub(crate) fn user_agent(user_agent: &str) -> Command { + ( + "emulation.setUserAgentOverride", + json!({ "userAgent": user_agent }), + ) +} + +/// Maps a locale to `emulation.setLocaleOverride`. +pub(crate) fn locale(locale: &str) -> Command { + ("emulation.setLocaleOverride", json!({ "locale": locale })) +} + +/// Maps a timezone id to `emulation.setTimezoneOverride`. +pub(crate) fn timezone(timezone: &str) -> Command { + ( + "emulation.setTimezoneOverride", + json!({ "timezone": timezone }), + ) +} + +/// Maps geolocation coordinates to `emulation.setGeolocationOverride`. +pub(crate) fn geolocation(latitude: f64, longitude: f64, accuracy: Option) -> Command { + ( + "emulation.setGeolocationOverride", + json!({ + "coordinates": { + "latitude": latitude, + "longitude": longitude, + "accuracy": accuracy.unwrap_or(1.0), + }, + }), + ) +} + +/// Maps offline mode to `emulation.setNetworkConditions`. +pub(crate) fn offline(enabled: bool) -> Command { + ( + "emulation.setNetworkConditions", + json!({ + "networkConditions": if enabled { + json!({ "type": "offline" }) + } else { + Value::Null + }, + }), + ) +} + +/// Maps a permission and its origin to `permissions.setPermission`. +pub(crate) fn permission(name: &str, state: &str, origin: &str) -> Command { + ( + "permissions.setPermission", + json!({ + "descriptor": { "name": name }, + "state": state, + "origin": origin, + }), + ) +} + +/// Validates media emulation and maps it to DevTools `targetConfiguration` values. +pub(crate) fn validate_media(media: &EmulatedMedia) -> Result { + let mut config = Map::new(); + if let Some(media_type) = &media.media { + match media_type.to_lowercase().as_str() { + "print" => { + config.insert("printSimulationEnabled".to_string(), Value::Bool(true)); + } + "screen" => { + config.insert("printSimulationEnabled".to_string(), Value::Bool(false)); + } + other => { + return Err(format!( + "Unsupported media type '{other}'. Supported media types: print, screen" + )); + } + } + } + for (name, value) in &media.features { + if value.trim().is_empty() || value.eq_ignore_ascii_case("no-preference") { + continue; + } + match name.as_str() { + "prefers-color-scheme" => { + config.insert( + "colorSchemeSimulation".to_string(), + Value::String(value.clone()), + ); + } + other => { + return Err(format!( + "Unsupported media feature '{other}'. Supported media features: prefers-color-scheme" + )); + } + } + } + Ok(Value::Object(config)) +} + +/// Adds the reset values so one call replaces the previous media emulation completely. +fn media_config(media: &EmulatedMedia) -> Result { + let mut config = validate_media(media)?; + let object = config + .as_object_mut() + .ok_or("Media configuration must be an object")?; + object.entry("colorSchemeSimulation").or_insert(Value::Null); + object + .entry("printSimulationEnabled") + .or_insert(Value::Bool(false)); + Ok(config) +} + +/// Sends an emulation command scoped to one browsing context. +async fn send_scoped(ff: &FirefoxSession, context: &str, command: Command) -> Result<(), String> { + send_scoped_bidi(&ff.bidi, context, command).await +} + +async fn send_scoped_bidi( + bidi: &BidiClient, + context: &str, + (method, mut params): Command, +) -> Result<(), String> { + if method == "browsingContext.setViewport" { + params["context"] = json!(context); + } else { + params["contexts"] = json!([context]); + } + bidi.send(method, params).await.map(|_| ()) +} + +/// Applies color scheme and print media to the selected tab through its DevTools configuration actor. +pub(crate) async fn update_tab_configuration( + bidi: &BidiClient, + rdp: &RdpClient, + context: &str, + config: &Value, +) -> Result<(), String> { + bidi.send("browsingContext.activate", json!({"context": context})) + .await?; + let tabs = rdp.request("root", "listTabs", json!({})).await?; + let tab = tabs["tabs"] + .as_array() + .and_then(|tabs| { + tabs.iter() + .find(|tab| tab["selected"].as_bool() == Some(true)) + .or_else(|| tabs.first()) + }) + .ok_or("Firefox reported no tabs for target configuration")?; + let descriptor = tab["actor"] + .as_str() + .ok_or("Firefox reported a tab without a descriptor actor")?; + let watcher = rdp.request(descriptor, "getWatcher", json!({})).await?; + let watcher_actor = watcher["actor"] + .as_str() + .ok_or("Firefox did not return a watcher actor")?; + let configuration = rdp + .request(watcher_actor, "getTargetConfigurationActor", json!({})) + .await?; + let actor = configuration["configuration"]["actor"] + .as_str() + .ok_or("Firefox did not return the target configuration actor")?; + rdp.request( + actor, + "updateConfiguration", + json!({"configuration": config}), + ) + .await?; + Ok(()) +} + +/// Replays every stored setting onto `context`, used for tabs the daemon creates. +pub async fn apply_to_context(ff: &FirefoxSession, context: &str) -> Result<(), String> { + let settings = ff.emulation.read().unwrap().clone(); + apply_settings_to_context(&ff.bidi, &ff.rdp, &settings, context).await +} + +pub(crate) async fn apply_settings_to_context( + bidi: &BidiClient, + rdp: &RdpClient, + settings: &Settings, + context: &str, +) -> Result<(), String> { + if let Some((width, height, ratio)) = settings.viewport { + send_scoped_bidi(bidi, context, viewport(width, height, ratio)).await?; + } + if let Some(ref user_agent) = settings.user_agent { + send_scoped_bidi(bidi, context, self::user_agent(user_agent)).await?; + } + if let Some(ref locale) = settings.locale { + send_scoped_bidi(bidi, context, self::locale(locale)).await?; + } + if let Some(ref timezone) = settings.timezone { + send_scoped_bidi(bidi, context, self::timezone(timezone)).await?; + } + if let Some((latitude, longitude, accuracy)) = settings.geolocation { + send_scoped_bidi(bidi, context, geolocation(latitude, longitude, accuracy)).await?; + } + if let Some(offline) = settings.offline { + send_scoped_bidi(bidi, context, self::offline(offline)).await?; + } + if let Some(ref media) = settings.media { + update_tab_configuration(bidi, rdp, context, &media_config(media)?).await?; + } + Ok(()) +} + +/// Sets a desktop viewport and returns Chrome's `viewport` result shape. +pub async fn set_viewport(cmd: &Value, state: &mut DaemonState) -> Result { + let width = cmd.get("width").and_then(Value::as_i64).unwrap_or(1280); + let height = cmd.get("height").and_then(Value::as_i64).unwrap_or(720); + let scale = cmd + .get("deviceScaleFactor") + .and_then(Value::as_f64) + .unwrap_or(1.0); + if cmd.get("mobile").and_then(Value::as_bool).unwrap_or(false) { + return Err("Firefox does not support mobile viewport emulation".to_string()); + } + let context = session(state)?.top_context.clone(); + send_scoped(session(state)?, &context, viewport(width, height, scale)).await?; + session(state)?.emulation.write().unwrap().viewport = Some((width, height, scale)); + state.viewport = Some((width as i32, height as i32, scale, false)); + if let Some(ref server) = state.stream_server { + server.set_viewport(width as u32, height as u32).await; + } + Ok(json!({ + "width": width, + "height": height, + "deviceScaleFactor": scale, + "mobile": false, + })) +} + +pub async fn set_device(_cmd: &Value, _state: &mut DaemonState) -> Result { + Err("Firefox does not support `set device`; use `set viewport` plus the `--user-agent` launch flag (or `AGENT_BROWSER_USER_AGENT`) instead of `set device`".to_string()) +} + +/// Overrides the user agent for the active tab and any tab opened later. +pub async fn set_user_agent(cmd: &Value, state: &mut DaemonState) -> Result { + let user_agent = cmd + .get("userAgent") + .and_then(Value::as_str) + .ok_or("Missing 'userAgent' parameter")?; + let context = session(state)?.top_context.clone(); + send_scoped(session(state)?, &context, self::user_agent(user_agent)).await?; + session(state)?.emulation.write().unwrap().user_agent = Some(user_agent.to_string()); + Ok(json!({ "userAgent": user_agent })) +} + +/// Overrides the locale for the active tab and any tab opened later. +pub async fn set_locale(cmd: &Value, state: &mut DaemonState) -> Result { + let locale = cmd + .get("locale") + .and_then(Value::as_str) + .ok_or("Missing 'locale' parameter")?; + let context = session(state)?.top_context.clone(); + send_scoped(session(state)?, &context, self::locale(locale)).await?; + session(state)?.emulation.write().unwrap().locale = Some(locale.to_string()); + Ok(json!({ "locale": locale })) +} + +/// Overrides the timezone for the active tab and any tab opened later. +pub async fn set_timezone(cmd: &Value, state: &mut DaemonState) -> Result { + let timezone = cmd + .get("timezoneId") + .or_else(|| cmd.get("timezone")) + .and_then(Value::as_str) + .ok_or("Missing 'timezoneId' parameter")?; + let context = session(state)?.top_context.clone(); + send_scoped(session(state)?, &context, self::timezone(timezone)).await?; + session(state)?.emulation.write().unwrap().timezone = Some(timezone.to_string()); + Ok(json!({ "timezoneId": timezone })) +} + +/// Overrides geolocation for the active tab and any tab opened later. +pub async fn set_geolocation(cmd: &Value, state: &mut DaemonState) -> Result { + let latitude = cmd + .get("latitude") + .and_then(Value::as_f64) + .ok_or("Missing 'latitude' parameter")?; + let longitude = cmd + .get("longitude") + .and_then(Value::as_f64) + .ok_or("Missing 'longitude' parameter")?; + let accuracy = cmd.get("accuracy").and_then(Value::as_f64); + let context = session(state)?.top_context.clone(); + send_scoped( + session(state)?, + &context, + geolocation(latitude, longitude, accuracy), + ) + .await?; + session(state)?.emulation.write().unwrap().geolocation = Some((latitude, longitude, accuracy)); + Ok(json!({ "latitude": latitude, "longitude": longitude })) +} + +/// Grants each permission for the active page's origin. +pub async fn set_permissions(cmd: &Value, state: &mut DaemonState) -> Result { + let permissions: Vec = cmd + .get("permissions") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + if permissions.is_empty() { + return Ok(json!({ "granted": permissions })); + } + let url = session(state)?.url().await?; + let origin = page_origin(&url)?; + for name in &permissions { + session(state)? + .bidi + .send( + "permissions.setPermission", + permission(name, "granted", &origin).1, + ) + .await?; + } + Ok(json!({ "granted": permissions })) +} + +fn page_origin(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|_| { + "Cannot set permissions without a page origin; navigate to a page first".to_string() + })?; + let origin = parsed.origin(); + if origin.is_tuple() { + Ok(origin.ascii_serialization()) + } else { + Err("Cannot set permissions without a page origin; navigate to a page first".to_string()) + } +} + +/// Toggles offline mode for the active tab and any tab opened later. +pub async fn set_offline(cmd: &Value, state: &mut DaemonState) -> Result { + let offline = cmd.get("offline").and_then(Value::as_bool).unwrap_or(true); + let context = session(state)?.top_context.clone(); + send_scoped(session(state)?, &context, self::offline(offline)).await?; + session(state)?.emulation.write().unwrap().offline = Some(offline); + Ok(json!({ "offline": offline })) +} + +/// Applies color scheme and print media to the active tab. +pub async fn set_media(cmd: &Value, state: &mut DaemonState) -> Result { + let media = parse_media(cmd); + let config = media_config(&media)?; + { + let ff = session(state)?; + let context = ff.top_context.clone(); + update_tab_configuration(&ff.bidi, &ff.rdp, &context, &config).await?; + } + session(state)?.emulation.write().unwrap().media = Some(media); + Ok(json!({ "set": true })) +} + +fn parse_media(cmd: &Value) -> EmulatedMedia { + let media = cmd.get("media").and_then(Value::as_str); + let mut features = Vec::new(); + if let Some(scheme) = cmd.get("colorScheme").and_then(Value::as_str) { + features.push(("prefers-color-scheme".to_string(), scheme.to_string())); + } + if let Some(motion) = cmd.get("reducedMotion").and_then(Value::as_str) { + features.push(("prefers-reduced-motion".to_string(), motion.to_string())); + } + if let Some(object) = cmd.get("features").and_then(Value::as_object) { + for (name, value) in object { + features.push((name.clone(), value.as_str().unwrap_or("").to_string())); + } + } + EmulatedMedia { + media: media.map(String::from), + features, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::native::firefox::session::tests::{fake_session, fake_session_with_rdp}; + use crate::native::firefox::tabs; + + fn media_with_feature(name: &str, value: &str) -> EmulatedMedia { + EmulatedMedia { + media: None, + features: vec![(name.to_string(), value.to_string())], + } + } + + #[test] + fn color_scheme_dark_maps_to_color_scheme_simulation() { + let config = validate_media(&media_with_feature("prefers-color-scheme", "dark")).unwrap(); + assert_eq!(config["colorSchemeSimulation"], "dark"); + } + + #[test] + fn reduced_motion_lists_supported_features() { + let error = validate_media(&media_with_feature("prefers-reduced-motion", "reduce")) + .expect_err("reduced motion is not supported"); + assert!(error.contains("prefers-reduced-motion")); + assert!(error.contains("prefers-color-scheme")); + } + + #[test] + fn offline_toggles_network_conditions() { + let (method, params) = offline(true); + assert_eq!(method, "emulation.setNetworkConditions"); + assert_eq!(params["networkConditions"]["type"], "offline"); + + let (method, params) = offline(false); + assert_eq!(method, "emulation.setNetworkConditions"); + assert!(params["networkConditions"].is_null()); + } + + async fn session_state() -> (DaemonState, crate::native::firefox::session::tests::Calls) { + let (ff, _, calls) = fake_session().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + (state, calls) + } + + #[tokio::test] + async fn viewport_routes_to_bidi_and_returns_the_chrome_shape() { + let (mut state, calls) = session_state().await; + let result = set_viewport( + &json!({"width": 800, "height": 600, "deviceScaleFactor": 2.0}), + &mut state, + ) + .await + .unwrap(); + + assert_eq!( + result, + json!({"width": 800, "height": 600, "deviceScaleFactor": 2.0, "mobile": false}) + ); + assert_eq!( + calls.lock().unwrap().clone(), + vec!["browsingContext.setViewport"] + ); + assert_eq!( + state + .firefox + .as_ref() + .unwrap() + .emulation + .read() + .unwrap() + .viewport, + Some((800, 600, 2.0)) + ); + } + + #[tokio::test] + async fn mobile_emulation_is_rejected_without_commands_or_state_changes() { + let (mut state, calls) = session_state().await; + state.viewport = Some((800, 600, 1.5, false)); + let ff = state.firefox.as_mut().unwrap(); + ff.emulation.write().unwrap().viewport = Some((800, 600, 1.5)); + ff.emulation.write().unwrap().user_agent = Some("Desktop Agent".to_string()); + + let error = set_viewport( + &json!({"width": 375, "height": 812, "deviceScaleFactor": 3.0, "mobile": true}), + &mut state, + ) + .await + .unwrap_err(); + assert!(error.contains("mobile viewport emulation")); + assert_eq!(state.viewport, Some((800, 600, 1.5, false))); + assert_eq!( + state + .firefox + .as_ref() + .unwrap() + .emulation + .read() + .unwrap() + .viewport, + Some((800, 600, 1.5)) + ); + + let error = set_device(&json!({"device": "iPhone 15"}), &mut state) + .await + .unwrap_err(); + assert!(error.contains("set device")); + assert!(error.contains("set viewport")); + assert!(error.contains("--user-agent") && error.contains("AGENT_BROWSER_USER_AGENT")); + assert_eq!(state.viewport, Some((800, 600, 1.5, false))); + assert_eq!( + state + .firefox + .as_ref() + .unwrap() + .emulation + .read() + .unwrap() + .viewport, + Some((800, 600, 1.5)) + ); + assert_eq!( + state + .firefox + .as_ref() + .unwrap() + .emulation + .read() + .unwrap() + .user_agent + .as_deref(), + Some("Desktop Agent") + ); + assert!(calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn media_dark_accepts_reduced_motion_no_preference() { + let (ff, _, _, _, rdp) = fake_session_with_rdp().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + + set_media( + &json!({"colorScheme": "dark", "reducedMotion": "no-preference"}), + &mut state, + ) + .await + .unwrap(); + + let messages = rdp.lock().unwrap().clone(); + let update = messages + .iter() + .find(|m| m["type"] == "updateConfiguration") + .expect("no updateConfiguration message"); + assert_eq!(update["configuration"]["colorSchemeSimulation"], "dark"); + } + + #[tokio::test] + async fn timezone_locale_and_offline_are_scoped_to_the_active_context() { + let (ff, _, _, bidi, _) = fake_session_with_rdp().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + + set_timezone(&json!({"timezoneId": "Asia/Tokyo"}), &mut state) + .await + .unwrap(); + set_locale(&json!({"locale": "fr-FR"}), &mut state) + .await + .unwrap(); + set_offline(&json!({"offline": true}), &mut state) + .await + .unwrap(); + + let messages = bidi.lock().unwrap().clone(); + let find = |method: &str| { + messages + .iter() + .find(|m| m["method"] == method) + .unwrap_or_else(|| panic!("no {method} message")) + }; + assert_eq!( + find("emulation.setTimezoneOverride")["params"]["timezone"], + "Asia/Tokyo" + ); + assert_eq!( + find("emulation.setTimezoneOverride")["params"]["contexts"], + json!(["ctx-active"]) + ); + assert_eq!( + find("emulation.setLocaleOverride")["params"]["locale"], + "fr-FR" + ); + assert_eq!( + find("emulation.setNetworkConditions")["params"]["networkConditions"]["type"], + "offline" + ); + } + + #[tokio::test] + async fn permissions_are_granted_for_the_page_origin() { + let (ff, _, _, bidi, _) = fake_session_with_rdp().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + + let result = set_permissions( + &json!({"permissions": ["geolocation", "notifications"]}), + &mut state, + ) + .await + .unwrap(); + assert_eq!(result, json!({"granted": ["geolocation", "notifications"]})); + + let messages = bidi.lock().unwrap().clone(); + let grants: Vec<&Value> = messages + .iter() + .filter(|m| m["method"] == "permissions.setPermission") + .collect(); + assert_eq!(grants.len(), 2); + assert_eq!(grants[0]["params"]["descriptor"]["name"], "geolocation"); + assert_eq!(grants[0]["params"]["origin"], "https://example.com"); + assert_eq!(grants[0]["params"]["state"], "granted"); + } + + #[tokio::test] + async fn permissions_without_a_page_origin_fail_before_granting() { + let (mut state, calls) = session_state().await; + let error = set_permissions(&json!({"permissions": ["geolocation"]}), &mut state) + .await + .unwrap_err(); + assert!(error.contains("page origin"), "{error}"); + assert!(!calls + .lock() + .unwrap() + .iter() + .any(|call| call == "permissions.setPermission")); + } + + #[tokio::test] + async fn unsupported_media_feature_fails_before_touching_the_page() { + let (mut state, calls) = session_state().await; + let error = set_media(&json!({"reducedMotion": "reduce"}), &mut state) + .await + .unwrap_err(); + assert!(error.contains("prefers-reduced-motion"), "{error}"); + assert!(calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn media_updates_the_selected_tab_configuration_actor() { + let (ff, _, _, _, rdp) = fake_session_with_rdp().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + + let result = set_media( + &json!({"colorScheme": "dark", "media": "screen"}), + &mut state, + ) + .await + .unwrap(); + assert_eq!(result, json!({"set": true})); + + let messages = rdp.lock().unwrap().clone(); + let kinds: Vec<&str> = messages + .iter() + .map(|m| m["type"].as_str().unwrap()) + .collect(); + assert_eq!( + kinds, + vec![ + "listTabs", + "getWatcher", + "getTargetConfigurationActor", + "updateConfiguration", + ] + ); + assert_eq!(messages[0]["to"], "root"); + assert_eq!(messages[1]["to"], "tab1"); + assert_eq!(messages[2]["to"], "watcher1"); + assert_eq!(messages[3]["to"], "cfg1"); + assert_eq!( + messages[3]["configuration"]["colorSchemeSimulation"], + "dark" + ); + assert_eq!( + messages[3]["configuration"]["printSimulationEnabled"], + false + ); + assert_eq!( + state + .firefox + .as_ref() + .unwrap() + .emulation + .read() + .unwrap() + .media + .as_ref() + .unwrap() + .features, + vec![("prefers-color-scheme".to_string(), "dark".to_string())] + ); + } + + #[tokio::test] + async fn a_new_tab_replays_the_session_emulation() { + let (ff, _, _, bidi, _) = fake_session_with_rdp().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + + set_timezone(&json!({"timezoneId": "Asia/Tokyo"}), &mut state) + .await + .unwrap(); + tabs::tab_new(&json!({}), &mut state).await.unwrap(); + + let messages = bidi.lock().unwrap().clone(); + let timezones: Vec<&Value> = messages + .iter() + .filter(|m| m["method"] == "emulation.setTimezoneOverride") + .collect(); + assert_eq!(timezones.len(), 2); + assert_eq!(timezones[0]["params"]["contexts"], json!(["ctx-active"])); + assert_eq!(timezones[1]["params"]["contexts"], json!(["ctx-new"])); + } +} diff --git a/cli/src/native/firefox/mod.rs b/cli/src/native/firefox/mod.rs index 850a1f170d..633f0ae418 100644 --- a/cli/src/native/firefox/mod.rs +++ b/cli/src/native/firefox/mod.rs @@ -4,9 +4,11 @@ pub mod actions; pub mod bidi; pub mod capture; +pub mod emulation; pub mod forms; pub mod input; pub mod interact; +pub mod network; pub mod page; pub mod process; pub mod query; diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs new file mode 100644 index 0000000000..d00a587412 --- /dev/null +++ b/cli/src/native/firefox/network.rs @@ -0,0 +1,1422 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::sync::{Mutex, RwLock}; + +use super::actions::session; +use super::bidi::BidiClient; +use super::session::FirefoxSession; +use super::target::resolve_target; +use crate::native::actions::{ + self as chrome, DaemonState, HarContentMode, HarEntry, RouteEntry, TrackedRequest, +}; +use crate::native::downloads::{DownloadLedger, DownloadState}; +use crate::native::network::DomainFilter; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum NetworkDecision { + Fail, + Fulfill { + status: u16, + headers: Vec<(String, String)>, + body: String, + }, + Abort, + Continue { + headers: Option>, + }, +} + +fn is_blocked(url: &str, resource_type: &str, filter: Option<&DomainFilter>) -> bool { + let Some(filter) = filter else { + return false; + }; + if filter.allowed_domains.is_empty() || url == "about:blank" { + return false; + } + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + if resource_type == "Document" && !matches!(parsed.scheme(), "http" | "https") { + return true; + } + if !matches!(parsed.scheme(), "http" | "https" | "ws" | "wss") { + return false; + } + parsed + .host_str() + .is_some_and(|hostname| !filter.is_allowed(hostname)) +} + +/// Decides how one Firefox request is handled: fail, fulfill, abort or continue. +pub(crate) fn decide_request( + url: &str, + resource_type: &str, + headers: &HashMap, + domain_filter: Option<&DomainFilter>, + routes: &[RouteEntry], + origin_headers: &HashMap>, +) -> NetworkDecision { + if is_blocked(url, resource_type, domain_filter) { + return NetworkDecision::Fail; + } + + for route in routes { + if !route.matches(url, resource_type) { + continue; + } + if route.abort { + return NetworkDecision::Abort; + } + if let Some(response) = &route.response { + let request_headers = headers + .iter() + .map(|(name, value)| (name.clone(), Value::String(value.clone()))) + .collect(); + let (status, headers, body) = chrome::mocked_response(response, Some(&request_headers)); + return NetworkDecision::Fulfill { + status, + headers: headers + .into_iter() + .map(|header| { + ( + header["name"].as_str().unwrap_or_default().to_string(), + header["value"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect(), + body, + }; + } + } + + let extra_headers = url::Url::parse(url) + .ok() + .map(|u| u.origin().ascii_serialization()) + .and_then(|origin| origin_headers.get(&origin)); + + let Some(extra_headers) = extra_headers else { + return NetworkDecision::Continue { headers: None }; + }; + + let mut merged_headers: Vec<(String, String)> = headers + .iter() + .filter(|(name, _)| { + !extra_headers + .keys() + .any(|extra| extra.eq_ignore_ascii_case(name)) + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + merged_headers.extend(extra_headers.iter().map(|(k, v)| (k.clone(), v.clone()))); + + NetworkDecision::Continue { + headers: Some(merged_headers), + } +} + +/// BiDi events the session subscribes to for network, console and download handling. +pub const NETWORK_EVENTS: [&str; 8] = [ + "network.beforeRequestSent", + "network.responseStarted", + "network.responseCompleted", + "network.fetchError", + "network.authRequired", + "log.entryAdded", + "browsingContext.downloadWillBegin", + "browsingContext.downloadEnd", +]; + +const IDLE_WINDOW: Duration = Duration::from_millis(500); +const DOWNLOAD_TIMEOUT_MS: u64 = 30_000; + +/// Shared state the dispatcher reads for every request; most `Arc`s are the daemon's own. +#[derive(Clone, Default)] +pub struct NetContext { + pub routes: Arc>>, + pub domain_filter: Arc>>, + pub origin_headers: Arc>>>, + pub proxy_credentials: Arc>>, + /// Proxy credential attempts per request id; a request gets two before the challenge is cancelled. + pub proxy_auth_requests: Arc>>, + pub headers: Arc>>, + /// Downloads seen on this session, so `wait --download` can claim one that + /// finished before the command arrived. + pub downloads: Arc>, +} + +impl NetContext { + /// Shares the daemon's routes, domain filter, origin headers and proxy credentials. + pub fn from_state(state: &DaemonState) -> Self { + Self { + routes: state.routes.clone(), + domain_filter: state.domain_filter.clone(), + origin_headers: state.origin_headers.clone(), + proxy_credentials: state.proxy_credentials.clone(), + ..Self::default() + } + } +} + +/// A background task that is aborted when dropped. +pub struct AbortOnDrop(tokio::task::JoinHandle<()>); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +fn header_list(headers: &Value) -> Vec<(String, String)> { + headers + .as_array() + .into_iter() + .flatten() + .filter_map(|h| { + Some(( + h["name"].as_str()?.to_string(), + h["value"]["value"].as_str().unwrap_or("").to_string(), + )) + }) + .collect() +} + +fn header_object(headers: &Value) -> Value { + Value::Object( + header_list(headers) + .into_iter() + .map(|(k, v)| (k, Value::String(v))) + .collect(), + ) +} + +fn bidi_headers(headers: Vec<(String, String)>) -> Value { + headers + .into_iter() + .map(|(name, value)| json!({"name": name, "value": {"type": "string", "value": value}})) + .collect() +} + +/// Maps a BiDi request to the resource type names Chrome reports. +fn resource_type(request: &Value) -> &'static str { + match ( + request["initiatorType"].as_str(), + request["destination"].as_str(), + ) { + (Some("fetch"), _) => "Fetch", + (Some("xmlhttprequest"), _) => "XHR", + (Some("beacon"), _) => "Ping", + (_, Some("document" | "iframe" | "frame")) => "Document", + (_, Some("script")) => "Script", + (_, Some("style")) => "Stylesheet", + (_, Some("image")) => "Image", + (_, Some("font")) => "Font", + (_, Some("audio" | "video" | "track")) => "Media", + _ => "Other", + } +} + +fn is_websocket(url: &str) -> bool { + url.starts_with("ws:") || url.starts_with("wss:") +} + +/// Installs the single global intercept the first time it is needed. +pub async fn ensure_intercept(ff: &mut FirefoxSession) -> Result<(), String> { + if !ff.intercepting { + ff.bidi + .send( + "network.addIntercept", + json!({"phases": ["beforeRequestSent", "authRequired"]}), + ) + .await?; + ff.intercepting = true; + } + Ok(()) +} + +/// Starts the task that resolves every intercepted request exactly once. +pub fn spawn_dispatcher( + bidi: Arc, + rdp: super::rdp::RdpClient, + net: NetContext, + mut rx: tokio::sync::mpsc::Receiver, + emulation: Arc>, +) -> AbortOnDrop { + AbortOnDrop(tokio::spawn(async move { + let mut emulated_contexts = HashSet::new(); + while let Some(e) = rx.recv().await { + if e["method"] == "browsingContext.contextCreated" && e["params"]["parent"].is_null() { + if let Some(context) = e["params"]["context"].as_str() { + if emulated_contexts.insert(context.to_string()) { + let settings = emulation.read().unwrap().clone(); + if let Err(error) = super::emulation::apply_settings_to_context( + &bidi, &rdp, &settings, context, + ) + .await + { + eprintln!( + "Warning: failed to apply Firefox emulation to {context}: {error}" + ); + } + } + } + } else if e["method"] == "browsingContext.contextDestroyed" { + if let Some(context) = e["params"]["context"].as_str() { + emulated_contexts.remove(context); + } + } + let is_download = matches!( + e["method"].as_str(), + Some("browsingContext.downloadWillBegin" | "browsingContext.downloadEnd") + ); + if is_download { + net.downloads + .lock() + .await + .observe(e["method"].as_str().unwrap_or_default(), &e["params"]); + } + if e["params"]["isBlocked"] == true { + tokio::spawn(resolve(bidi.clone(), net.clone(), e)); + } else if matches!( + e["method"].as_str(), + Some("network.responseCompleted" | "network.fetchError") + ) { + if let Some(id) = e["params"]["request"]["request"].as_str() { + net.proxy_auth_requests.write().await.remove(id); + } + } + } + })) +} + +async fn resolve(bidi: Arc, net: NetContext, event: Value) { + let id = event["params"]["request"]["request"] + .as_str() + .unwrap_or("") + .to_string(); + let (result, fallback, fallback_params) = match event["method"].as_str() { + Some("network.beforeRequestSent") => ( + before_request(&bidi, &net, &event["params"]["request"]).await, + "network.failRequest", + json!({"request": id}), + ), + Some("network.authRequired") => ( + auth_required(&bidi, &net, &id, &event["params"]).await, + "network.continueWithAuth", + json!({"request": id, "action": "cancel"}), + ), + _ => return, + }; + if result.is_err() { + let _ = bidi.send(fallback, fallback_params).await; + } +} + +async fn before_request( + bidi: &BidiClient, + net: &NetContext, + request: &Value, +) -> Result<(), String> { + let id = request["request"].as_str().unwrap_or(""); + let url = request["url"].as_str().unwrap_or(""); + let decision = { + let mut extra = net.headers.read().await.clone(); + let origin = url::Url::parse(url) + .ok() + .map(|u| u.origin().ascii_serialization()) + .unwrap_or_default(); + if let Some(scoped) = net.origin_headers.read().await.get(&origin) { + extra.retain(|name, _| !scoped.keys().any(|k| k.eq_ignore_ascii_case(name))); + extra.extend(scoped.clone()); + } + let origin_headers = if extra.is_empty() { + HashMap::new() + } else { + HashMap::from([(origin, extra)]) + }; + let headers: HashMap = + header_list(&request["headers"]).into_iter().collect(); + let filter = net.domain_filter.read().await; + let routes = net.routes.read().await; + decide_request( + url, + resource_type(request), + &headers, + filter.as_ref(), + &routes, + &origin_headers, + ) + }; + match decision { + NetworkDecision::Fail | NetworkDecision::Abort => { + bidi.send("network.failRequest", json!({"request": id})) + .await?; + } + NetworkDecision::Fulfill { + status, + headers, + body, + } => { + bidi.send( + "network.provideResponse", + json!({ + "request": id, + "statusCode": status, + "headers": bidi_headers(headers), + "body": {"type": "base64", "value": body}, + }), + ) + .await?; + } + NetworkDecision::Continue { headers } => { + let mut params = json!({"request": id}); + if let Some(headers) = headers { + params["headers"] = bidi_headers(headers); + } + bidi.send("network.continueRequest", params).await?; + } + } + Ok(()) +} + +/// Answers an auth challenge; proxy credentials go only to proxy (407) challenges, never to origins. +async fn auth_required( + bidi: &BidiClient, + net: &NetContext, + id: &str, + event: &Value, +) -> Result<(), String> { + let credentials = if event["response"]["status"] == 407 { + net.proxy_credentials.read().await.clone() + } else { + None + }; + let params = match credentials { + Some((username, password)) + if { + let mut tries = net.proxy_auth_requests.write().await; + let n = tries.entry(id.to_string()).or_insert(0); + *n += 1; + *n <= 2 + } => + { + json!({ + "request": id, + "action": "provideCredentials", + "credentials": {"type": "password", "username": username, "password": password}, + }) + } + _ => json!({"request": id, "action": "cancel"}), + }; + bidi.send("network.continueWithAuth", params).await?; + Ok(()) +} + +/// Drains buffered BiDi events and applies them to the daemon's dialog, network and console state. +pub async fn drain(state: &mut DaemonState) { + if let Some(ff) = state.firefox.as_mut() { + let events = ff.drain_events(&mut state.pending_dialog); + apply_events(state, events).await; + } +} + +async fn apply_events(state: &mut DaemonState, events: Vec) { + for e in events { + let p = &e["params"]; + match e["method"].as_str() { + Some("network.beforeRequestSent") => request_started(state, p), + Some("network.responseStarted") => response_seen(state, p), + Some("network.responseCompleted") => { + response_seen(state, p); + if let Some(ff) = state.firefox.as_mut() { + ff.inflight.remove(request_id(p)); + } + har_body(state, request_id(p)).await; + } + Some("network.fetchError") => { + if let Some(ff) = state.firefox.as_mut() { + ff.inflight.remove(request_id(p)); + } + } + Some("log.entryAdded") => log_entry(state, p), + _ => {} + } + } +} + +fn request_id(p: &Value) -> &str { + p["request"]["request"].as_str().unwrap_or("") +} + +fn request_started(state: &mut DaemonState, p: &Value) { + let request = &p["request"]; + let id = request_id(p).to_string(); + let url = request["url"].as_str().unwrap_or("").to_string(); + let method = request["method"].as_str().unwrap_or("GET").to_string(); + if !is_websocket(&url) { + if let Some(ff) = state.firefox.as_mut() { + ff.inflight.insert(id.clone()); + } + } + let millis = p["timestamp"].as_u64().unwrap_or_default(); + let resource_type = resource_type(request).to_string(); + if state.har_recording { + state.har_entries.push(HarEntry { + request_id: id.clone(), + wall_time: millis as f64 / 1000.0, + method: method.clone(), + url: url.clone(), + request_headers: header_list(&request["headers"]), + post_data: None, + request_body_size: request["bodySize"].as_i64().unwrap_or(0), + resource_type: resource_type.clone(), + status: None, + status_text: String::new(), + http_version: "HTTP/1.1".to_string(), + response_headers: Vec::new(), + mime_type: String::new(), + redirect_url: String::new(), + response_body_size: -1, + cdp_timing: None, + loading_finished_timestamp: None, + response_body: None, + response_body_base64: false, + }); + } + if state.request_tracking { + state.tracked_requests.push(TrackedRequest { + url, + method, + headers: header_object(&request["headers"]), + timestamp: millis, + resource_type, + request_id: id, + post_data: None, + status: None, + response_headers: None, + mime_type: None, + }); + } +} + +fn response_seen(state: &mut DaemonState, p: &Value) { + let id = request_id(p); + let response = &p["response"]; + let status = response["status"].as_i64(); + let mime = response["mimeType"].as_str().unwrap_or("").to_string(); + if state.har_recording { + if let Some(entry) = state + .har_entries + .iter_mut() + .rev() + .find(|e| e.request_id == id) + { + let headers = header_list(&response["headers"]); + entry.status = status; + entry.status_text = response["statusText"].as_str().unwrap_or("").to_string(); + entry.mime_type = mime.clone(); + entry.http_version = chrome::har_cdp_protocol_to_http_version( + response["protocol"].as_str().unwrap_or(""), + ); + entry.redirect_url = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("location")) + .map(|(_, v)| v.clone()) + .unwrap_or_default(); + entry.response_headers = headers; + entry.response_body_size = response["bodySize"] + .as_i64() + .or(response["content"]["size"].as_i64()) + .unwrap_or(-1); + } + } + if state.request_tracking { + if let Some(entry) = state + .tracked_requests + .iter_mut() + .rev() + .find(|e| e.request_id == id) + { + entry.status = status; + entry.mime_type = Some(mime); + entry.response_headers = Some(header_object(&response["headers"])); + } + } +} + +/// Reads the body bytes of a collected response as `(text, is_base64)`. +async fn response_data(bidi: &BidiClient, id: &str) -> Result<(String, bool), String> { + let r = bidi + .send( + "network.getData", + json!({"dataType": "response", "request": id}), + ) + .await?; + Ok(( + r["bytes"]["value"].as_str().unwrap_or("").to_string(), + r["bytes"]["type"] == "base64", + )) +} + +async fn har_body(state: &mut DaemonState, id: &str) { + let wanted = state.har_recording + && state + .har_entries + .iter() + .rev() + .find(|e| e.request_id == id) + .is_some_and(|e| { + e.response_body.is_none() + && match state.har_content_mode { + HarContentMode::None => false, + HarContentMode::Text => chrome::har_mime_is_text(&e.mime_type), + HarContentMode::All => true, + } + }); + let Some(ff) = state.firefox.as_ref().filter(|_| wanted) else { + return; + }; + let Ok((body, base64)) = response_data(&ff.bidi, id).await else { + return; + }; + if body.is_empty() + || body.len() > chrome::HAR_MAX_BODY_BYTES + || state.har_body_total_bytes + body.len() > chrome::HAR_MAX_TOTAL_BODY_BYTES + { + return; + } + if let Some(entry) = state + .har_entries + .iter_mut() + .rev() + .find(|e| e.request_id == id) + { + state.har_body_total_bytes += body.len(); + entry.response_body = Some(body); + entry.response_body_base64 = base64; + } +} + +fn log_entry(state: &mut DaemonState, p: &Value) { + let text = p["text"].as_str().unwrap_or(""); + match p["type"].as_str() { + Some("console") => { + let level = match p["method"].as_str().unwrap_or("log") { + "warn" => "warning", + other => other, + }; + let args = p["args"].as_array().cloned().unwrap_or_default(); + state.event_tracker.add_console(level, text, args); + } + Some("javascript") => { + let frame = &p["stackTrace"]["callFrames"][0]; + state.event_tracker.add_error( + text, + frame["url"].as_str(), + frame["lineNumber"].as_i64(), + frame["columnNumber"].as_i64(), + ); + } + _ => {} + } +} + +/// Waits until no tracked request has been in flight for 500ms; `Err` on timeout. +pub async fn wait_idle(state: &mut DaemonState, timeout_ms: u64) -> Result<(), ()> { + let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut idle_since: Option = None; + loop { + drain(state).await; + let busy = state + .firefox + .as_ref() + .is_some_and(|ff| !ff.inflight.is_empty()); + let now = tokio::time::Instant::now(); + if busy { + idle_since = None; + } else if now.duration_since(*idle_since.get_or_insert(now)) >= IDLE_WINDOW { + return Ok(()); + } + if now >= deadline { + return Err(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// Stores origin-scoped headers from `open --headers` and makes sure requests are intercepted. +pub async fn set_origin_headers( + cmd: &Value, + url: &str, + state: &mut DaemonState, +) -> Result<(), String> { + let Some(map) = cmd + .get("headers") + .and_then(Value::as_object) + .filter(|m| !m.is_empty()) + else { + return Ok(()); + }; + let Ok(parsed) = url::Url::parse(url) else { + return Ok(()); + }; + let headers = map + .iter() + .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))) + .collect(); + let ff = session(state)?; + ff.net + .origin_headers + .write() + .await + .insert(parsed.origin().ascii_serialization(), headers); + ensure_intercept(ff).await +} + +/// `route`: adds a route to the shared list and installs the intercept if needed. +pub async fn route(cmd: &Value, state: &mut DaemonState) -> Result { + session(state)?; + let url = chrome::add_route(cmd, state).await?; + ensure_intercept(session(state)?).await?; + Ok(json!({ "routed": url })) +} + +/// `unroute`: removes one route or all of them. +pub async fn unroute(cmd: &Value, state: &mut DaemonState) -> Result { + session(state)?; + Ok(json!({ "unrouted": chrome::remove_routes(cmd, state).await })) +} + +async fn set_headers( + state: &mut DaemonState, + headers: HashMap, +) -> Result { + let ff = session(state)?; + let any = !headers.is_empty(); + *ff.net.headers.write().await = headers; + if any { + ensure_intercept(ff).await?; + } + Ok(json!({ "set": true })) +} + +/// `headers`: replaces the extra headers sent with every request. +pub async fn headers(cmd: &Value, state: &mut DaemonState) -> Result { + let headers = cmd + .get("headers") + .ok_or("Missing 'headers' parameter")? + .as_object() + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect() + }) + .unwrap_or_default(); + set_headers(state, headers).await +} + +/// `credentials`: sends HTTP basic auth with every request, as Chrome does. +pub async fn credentials(cmd: &Value, state: &mut DaemonState) -> Result { + let field = |key: &str| { + cmd.get(key) + .and_then(Value::as_str) + .ok_or(format!("Missing '{key}' parameter")) + }; + let encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{}:{}", field("username")?, field("password")?), + ); + set_headers( + state, + HashMap::from([("Authorization".to_string(), format!("Basic {encoded}"))]), + ) + .await +} + +/// `request_detail`: the tracked request plus its response body when one was collected. +pub async fn request_detail(cmd: &Value, state: &mut DaemonState) -> Result { + let mut result = chrome::handle_request_detail(cmd, state).await?; + if let (Some(ff), Some(id)) = ( + state.firefox.as_ref(), + cmd.get("requestId").and_then(Value::as_str), + ) { + if let Ok((body, base64)) = response_data(&ff.bidi, id).await { + result["responseBody"] = if base64 { + json!(format!("[base64, {} chars]", body.len())) + } else { + json!(body) + }; + } + } + Ok(result) +} + +/// True when `context` is the active tab's top-level context or one of its frames. +async fn in_active_tab(ff: &FirefoxSession, context: &str) -> bool { + fn contains(node: &Value, context: &str) -> bool { + node["context"] == context + || node["children"] + .as_array() + .is_some_and(|c| c.iter().any(|n| contains(n, context))) + } + if context == ff.top_context { + return true; + } + ff.bidi + .send("browsingContext.getTree", json!({"root": ff.top_context})) + .await + .is_ok_and(|tree| { + tree["contexts"] + .as_array() + .is_some_and(|c| c.iter().any(|n| contains(n, context))) + }) +} + +/// `responsebody`: waits for the next response whose URL contains the pattern and returns its body. +pub async fn responsebody(cmd: &Value, state: &mut DaemonState) -> Result { + let pattern = cmd + .get("url") + .and_then(Value::as_str) + .ok_or("Missing 'url' parameter")?; + let timeout_ms = cmd + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(state.default_timeout_ms); + let ff = session(state)?; + let mut rx = ff.bidi.subscribe_buffered(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + loop { + let event = match tokio::time::timeout_at(deadline, rx.recv()).await { + Ok(Some(event)) => event, + Ok(None) => return Err("Event stream closed".to_string()), + Err(_) => return Err(format!("Timeout waiting for response matching '{pattern}'")), + }; + let p = &event["params"]; + if event["method"] == "network.responseCompleted" + && p["response"]["url"] + .as_str() + .is_some_and(|u| u.contains(pattern)) + && in_active_tab(ff, p["context"].as_str().unwrap_or("")).await + { + let (body, _) = response_data(&ff.bidi, request_id(p)).await?; + return Ok(json!({ + "body": body, + "status": p["response"]["status"].as_i64().unwrap_or(0), + "headers": header_object(&p["response"]["headers"]), + })); + } + } +} + +/// `har_start`: starts recording requests, with bodies per the `content` mode. +pub async fn har_start(cmd: &Value, state: &mut DaemonState) -> Result { + let mode = match cmd.get("content").and_then(Value::as_str) { + Some(s) => HarContentMode::parse(s)?, + None => HarContentMode::default(), + }; + session(state)?; + state.har_recording = true; + state.har_entries.clear(); + state.har_content_mode = mode; + state.har_body_total_bytes = 0; + Ok(json!({ "started": true })) +} + +/// Waits for a finished download already observed by the dispatcher. +async fn wait_download( + downloads: &Arc>, + cursor: usize, + contexts: &[&str], + timeout_ms: u64, + timeout_error: &str, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + loop { + let record = { + downloads + .lock() + .await + .claim_finished_since(cursor, contexts) + }; + if let Some(record) = record { + return Ok(record); + } + if tokio::time::Instant::now() >= deadline { + return Err(timeout_error.to_string()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +/// `download`: clicks the ref or selector and saves the resulting file at `path`. +pub async fn download(cmd: &Value, state: &mut DaemonState) -> Result { + let result = download_to_path(cmd, state).await; + if let Some(ff) = state.firefox.as_ref() { + let dir = ff + .download_dir + .clone() + .or_else(|| dirs::download_dir().map(|p| p.to_string_lossy().into_owned())); + if let Some(dir) = dir { + let params = json!({"downloadBehavior": {"type": "allowed", "destinationFolder": dir}}); + let _ = ff.bidi.send("browser.setDownloadBehavior", params).await; + } + } + result +} + +async fn download_to_path(cmd: &Value, state: &mut DaemonState) -> Result { + let selector = cmd + .get("selector") + .and_then(Value::as_str) + .ok_or("Missing 'selector' parameter")?; + let path = cmd + .get("path") + .and_then(Value::as_str) + .ok_or("Missing 'path' parameter")?; + let (dir, _) = chrome::resolve_download_dest(path)?; + let download_dir = dir + .to_str() + .ok_or("Download directory path is not valid UTF-8")?; + let target = resolve_target(state, selector).await?; + let ff = session(state)?; + let downloads = ff.net.downloads.clone(); + let cursor = downloads.lock().await.cursor(); + ff.bidi + .send( + "browser.setDownloadBehavior", + json!({"downloadBehavior": {"type": "allowed", "destinationFolder": download_dir}}), + ) + .await?; + let this = json!({"sharedId": target.shared_id}); + let clicked = ff + .race_prompt( + &target.context, + ff.bidi.call_function( + &target.context, + "function() { this.click(); return true; }", + Some(this), + vec![], + ), + ) + .await?; + if clicked.is_none() { + return Ok(json!({ "clicked": selector, "dialogOpened": true })); + } + let contexts = [target.context.as_str(), ff.top_context.as_str()]; + let record = wait_download( + &downloads, + cursor, + &contexts, + DOWNLOAD_TIMEOUT_MS, + "Timeout waiting for download to complete", + ) + .await?; + if record.state == DownloadState::Canceled { + downloads.lock().await.release(&record.guid); + return Err("Download was canceled".to_string()); + } + let saved = record + .file_path + .or_else(|| { + record + .suggested_filename + .map(|name| dir.join(name).to_string_lossy().into_owned()) + }) + .ok_or("Download completed but could not determine the downloaded file name")?; + let saved = std::path::Path::new(&saved); + if !chrome::wait_for_downloaded_file(saved).await { + return Err(format!( + "Download completed but no file is at {}", + saved.display() + )); + } + if !chrome::may_move_download(saved, true, Some(download_dir)) { + return Err(format!( + "Refusing to move downloaded file from {}", + saved.display() + )); + } + chrome::move_download(saved, path) +} + +/// `waitfordownload`: waits for the next finished download. +pub async fn waitfordownload(cmd: &Value, state: &mut DaemonState) -> Result { + let timeout_ms = cmd + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(state.default_timeout_ms); + let ff = session(state)?; + let downloads = ff.net.downloads.clone(); + let download_dir = ff.download_dir.clone(); + let record = wait_download( + &downloads, + 0, + &[], + timeout_ms, + "Timeout waiting for download", + ) + .await?; + if record.state == DownloadState::Canceled { + return Err("Download was canceled".to_string()); + } + let Some(saved) = record.file_path else { + return if cmd.get("path").and_then(Value::as_str).is_some() { + Err( + "Download completed but the browser did not report where it saved the file" + .to_string(), + ) + } else { + Ok(json!({"completed":true})) + }; + }; + let saved = std::path::Path::new(&saved); + if !chrome::wait_for_downloaded_file(saved).await { + return Err(format!( + "Download completed but no file is at {}", + saved.display() + )); + } + let Some(path) = cmd.get("path").and_then(Value::as_str) else { + return Ok(json!({"path": saved.to_string_lossy()})); + }; + if !chrome::may_move_download(saved, true, download_dir.as_deref()) { + downloads.lock().await.release(&record.guid); + return Err(format!( + "Refusing to move downloaded file from {}", + saved.display() + )); + } + let moved = chrome::move_download(saved, path); + if moved.is_err() { + downloads.lock().await.release(&record.guid); + } + moved +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::native::actions::RouteResponse; + + fn mock_route(pattern: &str, body: &str, status: u16) -> RouteEntry { + RouteEntry { + url_pattern: pattern.to_string(), + response: Some(RouteResponse { + status: Some(status), + body: Some(body.to_string()), + content_type: None, + headers: None, + allow_origin: None, + }), + abort: false, + resource_types: Vec::new(), + } + } + + #[tokio::test] + async fn wait_download_claims_only_new_downloads_from_target_context() { + let mut downloads = DownloadLedger::default(); + downloads.observe( + "browsingContext.downloadWillBegin", + &json!({"context": "mine", "download": "old", "suggestedFilename": "old.txt"}), + ); + let cursor = downloads.cursor(); + downloads.observe( + "browsingContext.downloadWillBegin", + &json!({"context": "other", "download": "d1", "suggestedFilename": "a.txt"}), + ); + downloads.observe( + "browsingContext.downloadWillBegin", + &json!({"context": "mine", "download": "d2", "suggestedFilename": "b.txt"}), + ); + downloads.observe( + "browsingContext.downloadEnd", + &json!({"download": "d1", "status": "canceled"}), + ); + downloads.observe( + "browsingContext.downloadEnd", + &json!({"download": "d2", "status": "complete", "filepath": "downloads/b.txt"}), + ); + let saved = downloads.claim_finished_since(cursor, &["mine"]).unwrap(); + assert_eq!(saved.file_path.as_deref(), Some("downloads/b.txt")); + assert_eq!(saved.guid, "d2"); + } + + #[tokio::test] + async fn waitfordownload_claims_a_download_that_finished_before_it_started() { + let (ff, _tx, _) = fake_session().await; + let directory = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let saved = directory.path().join("b.txt"); + let saved_path = saved.to_string_lossy().into_owned(); + { + let mut downloads = ff.net.downloads.lock().await; + downloads.observe( + "browsingContext.downloadWillBegin", + &json!({"navigation": "n1", "download": "d1", "suggestedFilename": "a.txt"}), + ); + downloads.observe( + "browsingContext.downloadEnd", + &json!({"navigation": "n1", "download": "d1", "status": "complete", "filepath": "downloads/a.txt"}), + ); + downloads.mark_claimed("d1"); + downloads.observe( + "browsingContext.downloadWillBegin", + &json!({"navigation": "n1", "download": "d2", "suggestedFilename": "b.txt"}), + ); + downloads.observe( + "browsingContext.downloadEnd", + &json!({"navigation": "n1", "download": "d2", "status": "complete", "filepath": saved_path}), + ); + } + let writer_path = saved.clone(); + let writer = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + std::fs::write(writer_path, b"download").unwrap(); + }); + let mut state = DaemonState::new(); + state.firefox = Some(ff); + let started = tokio::time::Instant::now(); + let result = waitfordownload(&json!({"timeout": 1000}), &mut state) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(80)); + assert_eq!(result["path"], json!(saved.to_string_lossy())); + writer.await.unwrap(); + } + + #[test] + fn blocked_domain_fails_even_when_route_matches() { + let filter = DomainFilter::new("allowed.example"); + let routes = vec![mock_route("*", "mocked", 200)]; + + let decision = decide_request( + "https://blocked.example/page", + "document", + &HashMap::new(), + Some(&filter), + &routes, + &HashMap::new(), + ); + + assert_eq!(decision, NetworkDecision::Fail); + + let decide = |url, resource_type| { + decide_request( + url, + resource_type, + &HashMap::new(), + Some(&filter), + &[], + &HashMap::new(), + ) + }; + assert_eq!( + decide("file:///etc/hosts", "Document"), + NetworkDecision::Fail + ); + assert_eq!( + decide("about:blank", "Document"), + NetworkDecision::Continue { headers: None } + ); + } + + #[test] + fn first_matching_route_wins() { + let routes = vec![ + mock_route("*", "first", 200), + mock_route("*", "second", 200), + ]; + + let decision = decide_request( + "https://example.com/page", + "document", + &HashMap::new(), + None, + &routes, + &HashMap::new(), + ); + + match decision { + NetworkDecision::Fulfill { body, .. } => assert_eq!(body, "Zmlyc3Q="), + other => panic!("expected fulfill, got {:?}", other), + } + } + + #[test] + fn mock_route_returns_status_body_and_headers() { + let routes = vec![RouteEntry { + url_pattern: "*".to_string(), + response: Some(RouteResponse { + status: Some(201), + body: Some("hello".to_string()), + content_type: Some("text/plain".to_string()), + headers: Some(HashMap::from([("X-Test".to_string(), "yes".to_string())])), + allow_origin: None, + }), + abort: false, + resource_types: Vec::new(), + }]; + + let decision = decide_request( + "https://example.com/page", + "document", + &HashMap::new(), + None, + &routes, + &HashMap::new(), + ); + + assert_eq!( + decision, + NetworkDecision::Fulfill { + status: 201, + headers: vec![ + ("Content-Type".to_string(), "text/plain".to_string()), + ("X-Test".to_string(), "yes".to_string()), + ], + body: "aGVsbG8=".to_string(), + } + ); + } + + #[tokio::test] + async fn mocked_responses_preserve_json_cors_and_base64_over_bidi() { + let (ff, _tx, _, messages, _) = + crate::native::firefox::session::tests::fake_session_with_rdp().await; + let net = NetContext::default(); + let mut route = mock_route("*", r#"{"mock":true}"#, 201); + route.response.as_mut().unwrap().allow_origin = Some("https://page.example".to_string()); + net.routes.write().await.push(route); + + for (headers, preflight, allowed) in [ + (json!({"oRiGiN": "https://page.example"}), false, true), + ( + json!({ + "Referer": "https://page.example/form", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type,x-client" + }), + true, + true, + ), + (json!({"Origin": "https://other.example"}), false, false), + ( + json!({ + "Origin": "https://other.example", + "Access-Control-Request-Method": "POST" + }), + true, + false, + ), + ] { + let headers = headers + .as_object() + .unwrap() + .iter() + .map(|(name, value)| (name.clone(), value.as_str().unwrap().to_string())) + .collect(); + before_request( + &ff.bidi, + &net, + &json!({"request": "r1", "url": "https://api.example/data", "headers": bidi_headers(headers)}), + ) + .await + .unwrap(); + let sent = messages.lock().unwrap().last().unwrap().clone(); + assert_eq!(sent["method"], "network.provideResponse"); + let params = &sent["params"]; + assert_eq!(params["request"], "r1"); + assert_eq!( + params["statusCode"], + if preflight && allowed { 204 } else { 201 } + ); + assert_eq!(params["body"]["type"], "base64"); + assert_eq!( + params["body"]["value"], + if preflight && allowed { + "" + } else { + "eyJtb2NrIjp0cnVlfQ==" + } + ); + let mut expected_headers = json!({"Content-Type": "application/json"}); + if allowed { + expected_headers["Access-Control-Allow-Origin"] = json!("https://page.example"); + expected_headers["Access-Control-Allow-Credentials"] = json!("true"); + if preflight { + expected_headers["Access-Control-Allow-Methods"] = json!("POST"); + expected_headers["Access-Control-Allow-Headers"] = + json!("content-type,x-client"); + } + } + assert_eq!(header_object(¶ms["headers"]), expected_headers); + } + } + + #[test] + fn extra_headers_are_merged_on_continue() { + let origin_headers = HashMap::from([( + "https://example.com".to_string(), + HashMap::from([("X-Extra".to_string(), "new".to_string())]), + )]); + let headers = HashMap::from([ + ("Accept".to_string(), "text/html".to_string()), + ("x-extra".to_string(), "old".to_string()), + ]); + + let decision = decide_request( + "https://example.com/page", + "document", + &headers, + None, + &[], + &origin_headers, + ); + + assert_eq!( + decision, + NetworkDecision::Continue { + headers: Some(vec![ + ("Accept".to_string(), "text/html".to_string()), + ("X-Extra".to_string(), "new".to_string()), + ]), + } + ); + } + + #[test] + fn non_matching_allowed_request_continues_unchanged() { + let filter = DomainFilter::new("allowed.example"); + let routes = vec![mock_route("*.json", "mocked", 200)]; + + let decision = decide_request( + "https://allowed.example/page", + "document", + &HashMap::new(), + Some(&filter), + &routes, + &HashMap::new(), + ); + + assert_eq!(decision, NetworkDecision::Continue { headers: None }); + } + + use crate::native::firefox::session::tests::fake_session; + + fn request_event(method: &str, id: &str, url: &str, blocked: bool) -> Value { + json!({ + "type": "event", + "method": method, + "params": { + "isBlocked": blocked, + "timestamp": 1000, + "request": {"request": id, "url": url, "method": "GET", "headers": []}, + "response": {"status": 200, "headers": []}, + }, + }) + } + + #[tokio::test] + async fn dispatcher_resolves_each_blocked_request_once() { + let (mut ff, tx, calls) = fake_session().await; + ff.tab_id("popup"); + ff.emulation.write().unwrap().user_agent = Some("Popup Agent".to_string()); + let net = NetContext::default(); + *net.domain_filter.write().await = Some(DomainFilter::new("allowed.example")); + let _dispatcher = spawn_dispatcher( + ff.bidi.clone(), + ff.rdp.clone(), + net, + ff.bidi.subscribe_buffered(), + ff.emulation.clone(), + ); + let blocked = "network.beforeRequestSent"; + tx.send(request_event( + blocked, + "r1", + "https://blocked.example/x", + true, + )) + .unwrap(); + tx.send(request_event( + blocked, + "r2", + "https://allowed.example/x", + true, + )) + .unwrap(); + tx.send(request_event( + blocked, + "r3", + "https://blocked.example/y", + false, + )) + .unwrap(); + tx.send(json!({ + "type": "event", + "method": "browsingContext.contextCreated", + "params": {"context": "popup", "parent": null} + })) + .unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + let mut seen = calls.lock().unwrap().clone(); + seen.sort(); + assert_eq!( + seen, + [ + "emulation.setUserAgentOverride", + "network.continueRequest", + "network.failRequest" + ] + ); + } + + #[tokio::test] + async fn in_flight_set_ignores_websockets_and_clears_on_completion() { + let (ff, _tx, _) = fake_session().await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + let started = "network.beforeRequestSent"; + apply_events( + &mut state, + vec![ + request_event(started, "a", "http://example.com/", false), + request_event(started, "b", "ws://example.com/socket", false), + ], + ) + .await; + assert_eq!(state.firefox.as_ref().unwrap().inflight.len(), 1); + apply_events( + &mut state, + vec![request_event( + "network.responseCompleted", + "a", + "http://example.com/", + false, + )], + ) + .await; + assert!(state.firefox.as_ref().unwrap().inflight.is_empty()); + } + + #[test] + fn log_entries_fill_console_and_errors() { + let mut state = DaemonState::new(); + log_entry( + &mut state, + &json!({"type": "console", "method": "warn", "text": "careful", "args": [{"type": "string", "value": "careful"}]}), + ); + log_entry( + &mut state, + &json!({"type": "javascript", "text": "Error: boom", "stackTrace": {"callFrames": [{"url": "http://x/", "lineNumber": 3, "columnNumber": 7}]}}), + ); + let console = state.event_tracker.get_console_json(); + assert_eq!(console["messages"][0]["type"], "warning"); + assert_eq!(console["messages"][0]["args"][0]["value"], "careful"); + let errors = state.event_tracker.get_errors_json(); + assert_eq!(errors["errors"][0]["text"], "Error: boom"); + assert_eq!(errors["errors"][0]["line"], 3); + } +} diff --git a/cli/src/native/firefox/page.rs b/cli/src/native/firefox/page.rs index 21d277608e..8e69d05fb0 100644 --- a/cli/src/native/firefox/page.rs +++ b/cli/src/native/firefox/page.rs @@ -5,12 +5,56 @@ use std::time::Duration; use serde_json::{json, Value}; use super::actions::session; +use super::network; use super::session::FirefoxSession; use crate::native::actions::{route_url_matches, DaemonState}; const POLL_INTERVAL: Duration = Duration::from_millis(100); -/// Turns an `about:neterror?e=...&d=...` URL into the message Firefox shows for the failure. +/// Firefox net-error codes and `about:neterror` names, mapped to the Chromium code Chrome +/// reports for the same failure. +const NET_ERRORS: &[(&str, &str)] = &[ + ("NS_ERROR_ABORT", "net::ERR_FAILED"), + ("NS_ERROR_UNKNOWN_HOST", "net::ERR_NAME_NOT_RESOLVED"), + ("NS_ERROR_CONNECTION_REFUSED", "net::ERR_CONNECTION_REFUSED"), + ("NS_ERROR_NET_TIMEOUT", "net::ERR_TIMED_OUT"), + ("NS_ERROR_NET_TIMEOUT_EXTERNAL", "net::ERR_TIMED_OUT"), + ("NS_ERROR_OFFLINE", "net::ERR_INTERNET_DISCONNECTED"), + ( + "NS_ERROR_UNKNOWN_PROXY_HOST", + "net::ERR_PROXY_CONNECTION_FAILED", + ), + ("dnsNotFound", "net::ERR_NAME_NOT_RESOLVED"), + ("connectionFailure", "net::ERR_CONNECTION_REFUSED"), + ("netTimeout", "net::ERR_TIMED_OUT"), + ("offline", "net::ERR_INTERNET_DISCONNECTED"), + ("proxyConnectFailure", "net::ERR_PROXY_CONNECTION_FAILED"), +]; + +/// The Chromium code for an exact Firefox net-error name or Gecko error code. +fn chromium_net_error(code: &str) -> Option<&'static str> { + NET_ERRORS + .iter() + .find(|(gecko, _)| *gecko == code) + .map(|(_, chromium)| *chromium) +} + +/// Finds the first known net-error token in free-form Firefox text such as `Error: NS_ERROR_ABORT`. +/// Firefox omits the `e` name for failures it has no neterror page for, which is how a route abort +/// arrives, and a BiDi command error carries the Gecko code in its message instead of a URL. +fn net_error_token(text: &str) -> Option<&'static str> { + text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .find_map(chromium_net_error) +} + +/// Rewrites the Gecko net-error token in a Firefox error message to the Chromium code Chrome +/// reports for the same failure. Messages without a known code are returned unchanged. +pub(super) fn normalize_net_error_text(text: &str) -> String { + net_error_token(text).map_or_else(|| text.to_string(), str::to_string) +} + +/// Turns an `about:neterror?e=...&d=...` URL into the failure message, using Chrome's +/// `net::ERR_*` text when the Firefox code has a known Chromium equivalent. fn neterror_message(url: &str) -> Option { if !url.starts_with("about:neterror") { return None; @@ -24,7 +68,13 @@ fn neterror_message(url: &str) -> Option { .filter(|v| !v.is_empty()) }; let code = pair("e").unwrap_or_else(|| "unknown error".to_string()); - Some(match pair("d") { + let detail = pair("d"); + if let Some(chromium) = + chromium_net_error(&code).or_else(|| detail.as_deref().and_then(net_error_token)) + { + return Some(chromium.to_string()); + } + Some(match detail { Some(detail) => format!("{code}: {detail}"), None => code, }) @@ -43,11 +93,15 @@ pub async fn navigate(cmd: &Value, state: &mut DaemonState) -> Result "interactive", Some("none") => "none", _ => "complete", }; + network::set_origin_headers(cmd, url, state).await?; reset_page_state(state); let ff = session(state)?; let context = ff.top_context.clone(); @@ -60,7 +114,7 @@ pub async fn navigate(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result } if let Some(load_state) = cmd.get("loadState").and_then(Value::as_str) { - if let Some(check) = load_state_check(load_state) { + if load_state == "networkidle" { + network::wait_idle(state, timeout_ms) + .await + .map_err(|_| format!("Wait timed out after {timeout_ms}ms"))?; + } else if let Some(check) = load_state_check(load_state) { poll_until_true(ff, check, timeout_ms).await?; } return Ok(json!({ "waited": "load", "state": load_state })); @@ -305,7 +368,11 @@ pub async fn waitforloadstate(cmd: &Value, state: &mut DaemonState) -> Result Result>, } -/// Rejects options Firefox cannot honour. `cdp`, `auto_connect`, `provider` and -/// `allowed_domains` are resolved from flags, env and config by the caller. +/// Rejects options Firefox cannot honour. `cdp`, `auto_connect` and `provider` +/// are resolved from flags, env and config by the caller. pub fn validate_firefox_options( options: &LaunchOptions, cdp: Option<&str>, auto_connect: bool, provider: Option<&str>, - allowed_domains: bool, ) -> Result<(), String> { let unsupported = |flag: &str| Err(format!("{flag} is not supported with --engine firefox")); if cdp.is_some() { @@ -79,9 +78,6 @@ pub fn validate_firefox_options( if options.ca_cert.is_some() { return unsupported("--ca-cert"); } - if allowed_domains { - return Err("allowed-domains is not yet supported on Firefox".to_string()); - } Ok(()) } @@ -148,7 +144,7 @@ pub fn check_firefox_version(version: &str) -> Result<(), String> { } } -fn user_js(download_path: Option<&str>) -> String { +fn user_js(download_path: Option<&str>, restrict_webrtc: bool) -> String { let mut js = String::from( "user_pref(\"devtools.debugger.remote-enabled\", true);\n\ user_pref(\"devtools.chrome.enabled\", true);\n\ @@ -158,6 +154,9 @@ fn user_js(download_path: Option<&str>) -> String { user_pref(\"browser.download.always_ask_before_handling_new_types\", false);\n\ user_pref(\"browser.download.manager.showWhenStarting\", false);\n", ); + if restrict_webrtc { + js.push_str("user_pref(\"media.peerconnection.enabled\", false);\n"); + } if let Some(dir) = download_path { let dir = serde_json::to_string(dir).unwrap_or_default(); js.push_str(&format!( @@ -206,9 +205,13 @@ fn prepare( headless: bool, profile_dir: &Path, download_path: Option<&str>, + restrict_webrtc: bool, ) -> Result<(DevtoolsEndpoint, Option, Child), String> { - std::fs::write(profile_dir.join("user.js"), user_js(download_path)) - .map_err(|e| format!("Failed to write Firefox profile: {e}"))?; + std::fs::write( + profile_dir.join("user.js"), + user_js(download_path, restrict_webrtc), + ) + .map_err(|e| format!("Failed to write Firefox profile: {e}"))?; let (devtools, rdp, socket_dir) = devtools_endpoint(profile_dir)?; let mut args: Vec = Vec::new(); if headless { @@ -241,6 +244,7 @@ impl FirefoxProcess { path: &Path, headless: bool, download_path: Option<&str>, + restrict_webrtc: bool, timeout: Duration, ) -> Result { let id = &uuid::Uuid::new_v4().simple().to_string()[..8]; @@ -248,9 +252,11 @@ impl FirefoxProcess { create_private_dir(&profile_dir) .map_err(|e| format!("Failed to create Firefox profile dir: {e}"))?; let (devtools, socket_dir, mut child) = - prepare(path, headless, &profile_dir, download_path).inspect_err(|_| { - let _ = std::fs::remove_dir_all(&profile_dir); - })?; + prepare(path, headless, &profile_dir, download_path, restrict_webrtc).inspect_err( + |_| { + let _ = std::fs::remove_dir_all(&profile_dir); + }, + )?; let stderr: Option> = child.stderr.take().map(|s| Box::new(s) as _); let tail = Arc::new(Mutex::new(VecDeque::new())); let (tx, rx) = mpsc::channel(); @@ -379,12 +385,12 @@ mod tests { } fn err_of(o: &LaunchOptions) -> String { - validate_firefox_options(o, None, false, None, false).unwrap_err() + validate_firefox_options(o, None, false, None).unwrap_err() } #[test] fn validate_accepts_plain_options() { - assert!(validate_firefox_options(&ok(), None, false, None, false).is_ok()); + assert!(validate_firefox_options(&ok(), None, false, None).is_ok()); } #[test] @@ -407,15 +413,12 @@ mod tests { let mut o = ok(); o.ca_cert = Some("c".into()); assert!(err_of(&o).contains("--ca-cert")); - let e = validate_firefox_options(&ok(), Some("9222"), false, None, false).unwrap_err(); + let e = validate_firefox_options(&ok(), Some("9222"), false, None).unwrap_err(); assert!(e.contains("--cdp")); - let e = validate_firefox_options(&ok(), None, true, None, false).unwrap_err(); + let e = validate_firefox_options(&ok(), None, true, None).unwrap_err(); assert!(e.contains("--auto-connect")); - let e = - validate_firefox_options(&ok(), None, false, Some("browserbase"), false).unwrap_err(); + let e = validate_firefox_options(&ok(), None, false, Some("browserbase")).unwrap_err(); assert!(e.contains("--provider")); - let e = validate_firefox_options(&ok(), None, false, None, true).unwrap_err(); - assert!(e.contains("allowed-domains is not yet supported on Firefox")); } #[test] @@ -431,6 +434,12 @@ mod tests { ); } + #[test] + fn webrtc_is_disabled_only_when_domains_are_restricted() { + assert!(user_js(None, true).contains("media.peerconnection.enabled\", false")); + assert!(!user_js(None, false).contains("peerconnection")); + } + #[test] fn version_gate() { let e = check_firefox_version("149.0").unwrap_err(); @@ -477,7 +486,7 @@ mod tests { dir.path(), "echo noise >&2\necho 'WebDriver BiDi listening on ws://127.0.0.1:4242' >&2\nexec sleep 30", ); - let p = FirefoxProcess::launch(&bin, true, None, Duration::from_secs(10)).unwrap(); + let p = FirefoxProcess::launch(&bin, true, None, false, Duration::from_secs(10)).unwrap(); assert_eq!(p.bidi_url, "ws://127.0.0.1:4242"); let profile = p.profile_dir.clone(); assert!(profile.join("user.js").exists()); @@ -525,7 +534,7 @@ mod tests { log.display() ); let bin = fake(dir.path(), &body); - let e = FirefoxProcess::launch(&bin, true, None, Duration::from_millis(800)) + let e = FirefoxProcess::launch(&bin, true, None, false, Duration::from_millis(800)) .err() .unwrap(); assert!(e.contains("boom on stderr"), "{e}"); @@ -539,7 +548,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let bin = fake(dir.path(), "echo 'bad profile' >&2\nexit 3"); let start = Instant::now(); - let e = FirefoxProcess::launch(&bin, true, None, Duration::from_secs(20)) + let e = FirefoxProcess::launch(&bin, true, None, false, Duration::from_secs(20)) .err() .unwrap(); assert!(start.elapsed() < Duration::from_secs(10)); @@ -550,8 +559,8 @@ mod tests { #[ignore = "requires FIREFOX_BIN"] fn real_firefox_smoke() { let bin = std::env::var("FIREFOX_BIN").expect("FIREFOX_BIN"); - let p = - FirefoxProcess::launch(Path::new(&bin), true, None, Duration::from_secs(30)).unwrap(); + let p = FirefoxProcess::launch(Path::new(&bin), true, None, false, Duration::from_secs(30)) + .unwrap(); assert!(p.bidi_url.starts_with("ws://127.0.0.1:"), "{}", p.bidi_url); let profile = p.profile_dir.clone(); drop(p); diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index ebc4dea939..69e8c42ae8 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -1,24 +1,29 @@ //! A running Firefox: the owned process, both protocol clients and per-session state. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::future::Future; +use std::sync::Arc; use std::time::Duration; use serde_json::{json, Value}; -use tokio::sync::broadcast::{self, error::TryRecvError}; +use tokio::sync::mpsc; use super::bidi::{remote_to_json, BidiClient}; +use super::emulation::{self, Settings as EmulationSettings}; +use super::network::{AbortOnDrop, NetContext, NETWORK_EVENTS}; use super::process::{check_firefox_version, find_firefox, DevtoolsEndpoint, FirefoxProcess}; use super::rdp::RdpClient; -use crate::native::actions::PendingDialog; +use crate::native::actions::{EmulatedMedia, PendingDialog}; use crate::native::cdp::chrome::LaunchOptions; const START_TIMEOUT: Duration = Duration::from_secs(30); const DEVTOOLS_CONNECT_WINDOW: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); -const SUBSCRIBED_EVENTS: [&str; 4] = [ +const CONTEXT_EVENTS: [&str; 2] = [ "browsingContext.contextCreated", "browsingContext.contextDestroyed", +]; +const PROMPT_EVENTS: [&str; 2] = [ "browsingContext.userPromptOpened", "browsingContext.userPromptClosed", ]; @@ -27,7 +32,7 @@ const SUBSCRIBED_EVENTS: [&str; 4] = [ pub struct FirefoxSession { process: Option, pub headless: bool, - pub bidi: BidiClient, + pub bidi: Arc, pub rdp: RdpClient, pub accessibility_actor: String, /// Active browsing context id: a top-level tab, or a frame after `frame `. @@ -49,7 +54,17 @@ pub struct FirefoxSession { pub touches: Vec<(f64, f64)>, /// Durable ids of nodes inside open shadow roots, which a stale ref must never recover by role and name. pub shadow_ids: std::collections::HashSet, - events: broadcast::Receiver, + /// Emulation applied to the active tab and replayed onto new tabs. + pub emulation: Arc>, + /// Launch download directory, restored after `download` redirects it. + pub download_dir: Option, + events: mpsc::Receiver, + pub net: NetContext, + /// True once the single global network intercept is installed; it is never installed twice. + pub intercepting: bool, + /// Ids of requests that have started and not yet finished or failed. + pub inflight: HashSet, + pub(crate) dispatcher: Option, } /// Builds the BiDi `proxy` capability from a `--proxy` value such as `http://host:3128`. @@ -99,24 +114,39 @@ async fn connect_devtools_loop(endpoint: &DevtoolsEndpoint) -> Result Result { + pub async fn launch( + options: &LaunchOptions, + net: NetContext, + auto_dialog: bool, + ) -> Result { let path = find_firefox(options.executable_path.as_deref())?; let (headless, download) = (options.headless, options.download_path.clone()); + let restrict_webrtc = options.restrict_webrtc; let process = tokio::task::spawn_blocking(move || { - FirefoxProcess::launch(&path, headless, download.as_deref(), START_TIMEOUT) + FirefoxProcess::launch( + &path, + headless, + download.as_deref(), + restrict_webrtc, + START_TIMEOUT, + ) }) .await .map_err(|e| format!("Firefox launch task failed: {e}"))??; - let bidi = tokio::time::timeout( - START_TIMEOUT, - BidiClient::connect(&format!("{}/session", process.bidi_url)), - ) - .await - .map_err(|_| "Firefox BiDi connection timed out".to_string())??; - let events = bidi.subscribe(); + let bidi = Arc::new( + tokio::time::timeout( + START_TIMEOUT, + BidiClient::connect(&format!("{}/session", process.bidi_url)), + ) + .await + .map_err(|_| "Firefox BiDi connection timed out".to_string())??, + ); + let events = bidi.subscribe_buffered(); + let dispatch_events = bidi.subscribe_buffered(); let mut caps = json!({ "acceptInsecureCerts": options.ignore_https_errors, "unhandledPromptBehavior": if auto_dialog { @@ -139,8 +169,18 @@ impl FirefoxSession { .as_str() .unwrap_or(""), )?; - bidi.send("session.subscribe", json!({"events": SUBSCRIBED_EVENTS})) + let events_to_watch: Vec<&str> = CONTEXT_EVENTS + .into_iter() + .chain(PROMPT_EVENTS) + .chain(NETWORK_EVENTS) + .collect(); + bidi.send("session.subscribe", json!({"events": events_to_watch})) .await?; + bidi.send( + "network.addDataCollector", + json!({"dataTypes": ["response"], "maxEncodedDataSize": 10_000_000}), + ) + .await?; let rdp = connect_devtools(&process.devtools).await?; let root = rdp.request("root", "getRoot", json!({})).await?; @@ -156,7 +196,22 @@ impl FirefoxSession { .as_str() .ok_or("Firefox reported no browsing context")? .to_string(); - Ok(Self { + let emulation = Arc::new(std::sync::RwLock::new(EmulationSettings { + user_agent: options.user_agent.clone(), + media: options.color_scheme.as_ref().map(|scheme| EmulatedMedia { + media: None, + features: vec![("prefers-color-scheme".to_string(), scheme.clone())], + }), + ..EmulationSettings::default() + })); + let dispatcher = Some(super::network::spawn_dispatcher( + bidi.clone(), + rdp.clone(), + net.clone(), + dispatch_events, + emulation.clone(), + )); + let session = Self { process: Some(process), headless: options.headless, bidi, @@ -174,8 +229,16 @@ impl FirefoxSession { tab_labels: HashMap::new(), touches: Vec::new(), shadow_ids: Default::default(), + emulation, + download_dir: options.download_path.clone(), events, - }) + net, + intercepting: false, + inflight: HashSet::new(), + dispatcher, + }; + let _ = emulation::apply_to_context(&session, &session.context).await; + Ok(session) } /// Returns the stable `t` id for `context`, assigning the next id the first time it is seen. @@ -205,22 +268,19 @@ impl FirefoxSession { .await; } - /// Applies buffered BiDi events to the pending dialog slot. - pub fn drain_events(&mut self, dialog: &mut Option) { - loop { - match self.events.try_recv() { - Ok(e) => { - if e["method"] == "browsingContext.contextDestroyed" - && e["params"]["context"] == self.top_context - { - self.top_context.clear(); - } - apply_event(&e, dialog, &mut self.context_parents); - } - Err(TryRecvError::Lagged(_)) => continue, - Err(_) => break, + /// Applies buffered BiDi events to the pending dialog slot and returns them for further handling. + pub fn drain_events(&mut self, dialog: &mut Option) -> Vec { + let mut drained = Vec::new(); + while let Ok(e) = self.events.try_recv() { + if e["method"] == "browsingContext.contextDestroyed" + && e["params"]["context"] == self.top_context + { + self.top_context.clear(); } + apply_event(&e, dialog, &mut self.context_parents); + drained.push(e); } + drained } /// Runs `fut` until it finishes or a prompt opens in `context` or its owning tab. Returns `None` for the prompt. @@ -229,22 +289,21 @@ impl FirefoxSession { context: &str, fut: impl Future>, ) -> Result, String> { - let mut events = self.bidi.subscribe(); + let mut events = self.bidi.subscribe_buffered(); let mut parents = self.context_parents.clone(); tokio::pin!(fut); loop { tokio::select! { r = &mut fut => return r.map(Some), e = events.recv() => match e { - Ok(e) if e["method"] == "browsingContext.userPromptOpened" + Some(e) if e["method"] == "browsingContext.userPromptOpened" && e["params"]["handler"] != "accept" && [context, &self.context, &self.top_context].iter().any(|active| owner_context(active, &parents) == owner_context(e["params"]["context"].as_str().unwrap_or_default(), &parents) ) => return Ok(None), - Ok(e) => apply_event(&e, &mut None, &mut parents), - Err(broadcast::error::RecvError::Closed) => return fut.await.map(Some), - _ => {} + Some(e) => apply_event(&e, &mut None, &mut parents), + None => return fut.await.map(Some), }, } } @@ -386,6 +445,15 @@ pub(crate) mod tests { .await } + /// A BiDi command reply: a `result` payload or a protocol `error`/`message` pair. + pub(crate) enum FakeReply { + Result(Value), + Error { + error: &'static str, + message: &'static str, + }, + } + /// Same as `fake_session`, with `responder` deciding each command's `result`. pub(crate) async fn fake_session_with( responder: impl Fn(&Value) -> Value + Send + 'static, @@ -393,6 +461,18 @@ pub(crate) mod tests { FirefoxSession, tokio::sync::mpsc::UnboundedSender, Calls, + ) { + fake_session_with_replies(move |v: &Value| FakeReply::Result(responder(v))).await + } + + /// Same as `fake_session_with`, but `responder` can also reject a command the way Firefox + /// does with a BiDi `error` reply. + pub(crate) async fn fake_session_with_replies( + responder: impl Fn(&Value) -> FakeReply + Send + 'static, + ) -> ( + FirefoxSession, + tokio::sync::mpsc::UnboundedSender, + Calls, ) { let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("ws://{}", l.local_addr().unwrap()); @@ -408,8 +488,17 @@ pub(crate) mod tests { let Some(Ok(Message::Text(t))) = m else { break }; let v: Value = serde_json::from_str(&t).unwrap(); log.lock().unwrap().push(v["method"].as_str().unwrap().to_string()); - let reply = - json!({"type":"success","id":v["id"],"result":responder(&v)}); + let reply = match responder(&v) { + FakeReply::Result(result) => { + json!({"type":"success","id":v["id"],"result":result}) + } + FakeReply::Error { error, message } => json!({ + "type": "error", + "id": v["id"], + "error": error, + "message": message + }), + }; ws.send(Message::Text(reply.to_string())).await.unwrap(); } Some(ev) = rx.recv() => { @@ -419,7 +508,8 @@ pub(crate) mod tests { } }); let bidi = BidiClient::connect(&url).await.unwrap(); - let events = bidi.subscribe(); + let bidi = Arc::new(bidi); + let events = bidi.subscribe_buffered(); let (a, mut b) = tokio::io::duplex(4096); let greeting = json!({"from":"root"}).to_string(); b.write_all(format!("{}:{}", greeting.len(), greeting).as_bytes()) @@ -445,11 +535,146 @@ pub(crate) mod tests { tab_labels: HashMap::new(), touches: Vec::new(), shadow_ids: Default::default(), + emulation: Arc::default(), + download_dir: None, events, + net: NetContext::default(), + intercepting: false, + inflight: HashSet::new(), + dispatcher: None, }; (session, tx, calls) } + pub(crate) type Messages = std::sync::Arc>>; + + /// A session whose BiDi server records full messages, answers `browsingContext.create` + /// with a new context and whose DevTools stream answers tab configuration requests. + pub(crate) async fn fake_session_with_rdp() -> ( + FirefoxSession, + tokio::sync::mpsc::UnboundedSender, + Calls, + Messages, + Messages, + ) { + use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; + + async fn put(s: &mut DuplexStream, v: Value) { + let b = v.to_string(); + s.write_all(format!("{}:{}", b.len(), b).as_bytes()) + .await + .unwrap(); + } + + async fn get(s: &mut DuplexStream) -> Option { + let mut len = Vec::new(); + loop { + let b = s.read_u8().await.ok()?; + if b == b':' { + break; + } + len.push(b); + } + let n: usize = String::from_utf8(len).ok()?.parse().ok()?; + let mut buf = vec![0; n]; + s.read_exact(&mut buf).await.ok()?; + serde_json::from_slice(&buf).ok() + } + + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", l.local_addr().unwrap()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let calls = Calls::default(); + let methods = calls.clone(); + let bidi_messages = Messages::default(); + let bidi_log = bidi_messages.clone(); + tokio::spawn(async move { + let (s, _) = l.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(s).await.unwrap(); + loop { + tokio::select! { + m = ws.next() => { + let Some(Ok(Message::Text(t))) = m else { break }; + let v: Value = serde_json::from_str(&t).unwrap(); + methods.lock().unwrap().push(v["method"].as_str().unwrap().to_string()); + bidi_log.lock().unwrap().push(v.clone()); + let result = if v["method"] == "browsingContext.create" { + json!({"context": "ctx-new"}) + } else if v["method"] == "browsingContext.getTree" { + json!({"contexts": [{"context": "ctx-active", "url": "https://example.com/", "userContext": "default"}]}) + } else { + json!({}) + }; + let reply = json!({"type":"success","id":v["id"],"result":result}); + ws.send(Message::Text(reply.to_string())).await.unwrap(); + } + Some(ev) = rx.recv() => { + ws.send(Message::Text(ev.to_string())).await.unwrap(); + } + } + } + }); + let bidi = BidiClient::connect(&url).await.unwrap(); + let events = bidi.subscribe_buffered(); + + let (a, mut b) = tokio::io::duplex(65536); + let rdp_messages = Messages::default(); + let rdp_log = rdp_messages.clone(); + tokio::spawn(async move { + put(&mut b, json!({"from":"root"})).await; + while let Some(v) = get(&mut b).await { + rdp_log.lock().unwrap().push(v.clone()); + let to = v["to"].as_str().unwrap_or(""); + let kind = v["type"].as_str().unwrap_or(""); + let reply = match (to, kind) { + ("root", "listTabs") => json!({ + "from": "root", + "tabs": [{"actor": "tab1", "selected": true}], + }), + ("tab1", "getWatcher") => json!({"from": "tab1", "actor": "watcher1"}), + ("watcher1", "getTargetConfigurationActor") => json!({ + "from": "watcher1", + "configuration": {"actor": "cfg1"}, + }), + ("cfg1", "updateConfiguration") => json!({ + "from": "cfg1", + "configuration": {}, + }), + _ => json!({"from": to}), + }; + put(&mut b, reply).await; + } + }); + let rdp = RdpClient::from_stream(a).await.unwrap(); + let session = FirefoxSession { + process: None, + headless: true, + bidi: Arc::new(bidi), + rdp, + accessibility_actor: "acc".into(), + context: "ctx-active".into(), + tab_ids: HashMap::from([("ctx-active".into(), 1)]), + context_parents: HashMap::new(), + next_tab_id: 2, + top_context: "ctx-active".into(), + refs: HashMap::new(), + next_node_id: 0, + shadow_ids: Default::default(), + frames: None, + documents: HashMap::new(), + tab_labels: HashMap::new(), + touches: Vec::new(), + emulation: Arc::default(), + download_dir: None, + events, + net: NetContext::default(), + intercepting: false, + inflight: HashSet::new(), + dispatcher: None, + }; + (session, tx, calls, bidi_messages, rdp_messages) + } + #[test] fn proxy_capability_maps_http_and_socks() { let http = proxy_capability("http://proxy:3128", Some("a.com, b.com")); @@ -471,6 +696,20 @@ pub(crate) mod tests { assert_eq!(s.tab_id("ctx-later"), 3); } + #[tokio::test] + async fn drain_events_keeps_more_than_broadcast_capacity() { + let (mut ff, tx, _) = fake_session().await; + let mut received = ff.bidi.subscribe_buffered(); + for i in 0..2048 { + tx.send(json!({"type":"event","method":"log.entryAdded","params":{"index":i}})) + .unwrap(); + } + for _ in 0..2048 { + received.recv().await.unwrap(); + } + assert_eq!(ff.drain_events(&mut None).len(), 2048); + } + #[test] fn prompt_events_set_and_clear_the_dialog() { let mut d = None; diff --git a/cli/src/native/firefox/storage.rs b/cli/src/native/firefox/storage.rs index f43b271d0b..c070f9fea7 100644 --- a/cli/src/native/firefox/storage.rs +++ b/cli/src/native/firefox/storage.rs @@ -5,8 +5,9 @@ use serde_json::{json, Value}; use std::collections::HashSet; use super::actions::session; +use super::page::normalize_net_error_text; use super::session::FirefoxSession; -use crate::native::actions::DaemonState; +use crate::native::actions::{self as chrome, DaemonState}; use crate::native::cookies::Cookie; use crate::native::state::{self, OriginStorage, StorageState}; use crate::validation::{is_valid_session_name, session_name_error}; @@ -523,7 +524,8 @@ pub async fn load_state_path(ff: &mut FirefoxSession, path: &str) -> Result Result Result { let url = cmd.get("url").and_then(Value::as_str); + if let Some(url) = url { + chrome::check_url_allowed_by_filter(state.domain_filter.read().await.as_ref(), url)?; + } let label = cmd.get("label").and_then(Value::as_str); if let Some(label) = label { if !is_valid_label(label) { @@ -51,6 +56,7 @@ pub async fn tab_new(cmd: &Value, state: &mut DaemonState) -> Result { let nav = ff @@ -59,7 +65,10 @@ pub async fn tab_new(cmd: &Value, state: &mut DaemonState) -> Result "about:blank".to_string(), @@ -217,6 +226,7 @@ pub async fn window_new(_cmd: &Value, state: &mut DaemonState) -> Result [args] Configures various browser settings and emulation options. Settings: - viewport [scale] Set viewport size (scale = deviceScaleFactor, e.g. 2 for retina) - device Emulate device (e.g., "iPhone 12") + viewport [scale] Set desktop viewport (mobile mode is unsupported in Firefox) + device Emulate device (unsupported in Firefox; use set viewport plus the --user-agent launch flag or AGENT_BROWSER_USER_AGENT instead of set device) geo Set geolocation offline [on|off] Toggle offline mode; off restores the new-tab default headers Set extra HTTP headers; use {} to clear them for new tabs credentials Set HTTP authentication for current and future tabs media [dark|light] Set color scheme preference - [reduced-motion] Enable reduced motion + [reduced-motion] Enable reduced motion (unsupported in Firefox) Global Options: --json Output as JSON @@ -4113,7 +4113,7 @@ Options: --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) - --allowed-domains Restrict network domains; rejects CDP, auto-connect, profiles, restore/state replay, direct-page providers, unsafe startup args, iOS/Safari (or AGENT_BROWSER_ALLOWED_DOMAINS) + --allowed-domains Restrict network domains and non-HTTP document navigation; rejects CDP, auto-connect, profiles, restore/state replay, direct-page providers, unsafe startup args, iOS/Safari (or AGENT_BROWSER_ALLOWED_DOMAINS) --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) diff --git a/docs/content/docs/commands.mdx b/docs/content/docs/commands.mdx index 58d208822f..b1abebdbe7 100644 --- a/docs/content/docs/commands.mdx +++ b/docs/content/docs/commands.mdx @@ -195,6 +195,8 @@ agent-browser set credentials

# HTTP basic auth for current and future t agent-browser set media [dark|light] # Emulate color scheme (persists for session) ``` +Firefox does not support mobile viewport mode, `set device`, or `reduced-motion` media emulation. Use `set viewport` plus the `--user-agent` launch flag (or `AGENT_BROWSER_USER_AGENT`) instead of `set device`. + Use `--color-scheme` for persistent dark/light mode across all commands: ```bash diff --git a/docs/content/docs/security.mdx b/docs/content/docs/security.mdx index c4aeae14ce..272f4ca5d3 100644 --- a/docs/content/docs/security.mdx +++ b/docs/content/docs/security.mdx @@ -202,6 +202,8 @@ export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked. WebSocket and EventSource connections are blocked via constructor-level patching, and `sendBeacon` returns `false` for blocked destinations. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. WebRTC peer connections are disabled entirely in supported Chromium sessions while the allowlist is active because STUN, TURN, and DNS traffic do not pass through CDP Fetch interception. Locally launched Chrome also uses `disable_non_proxied_udp` as a second layer. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Non-http(s) sub-resources such as data URIs and blobs remain allowed. When a request is blocked, the command returns an error. +Firefox also rejects non-HTTP document navigations other than `about:blank` while the allowlist is active. + > **Note:** WebSocket and EventSource blocking is best-effort because it overrides browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore those original constructors. The WebRTC replacement is installed as a non-configurable constructor, with the Chrome UDP policy providing defense in depth for local launches. For maximum protection, deny the `eval` category via `--action-policy` and enforce operating-system egress policy when using `--allowed-domains`. Config file: diff --git a/packages/@agent-browser/sandbox/mcp-tools.json b/packages/@agent-browser/sandbox/mcp-tools.json index 1d6ea3b582..b02ae6f1bd 100644 --- a/packages/@agent-browser/sandbox/mcp-tools.json +++ b/packages/@agent-browser/sandbox/mcp-tools.json @@ -11,7 +11,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -117,7 +117,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -217,7 +217,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -311,7 +311,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -416,7 +416,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -538,7 +538,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -638,7 +638,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -733,7 +733,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -848,7 +848,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -957,7 +957,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1058,7 +1058,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1169,7 +1169,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1263,7 +1263,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1357,7 +1357,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1451,7 +1451,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1556,7 +1556,7 @@ "type": "boolean" }, "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1650,7 +1650,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1750,7 +1750,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1855,7 +1855,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -1952,7 +1952,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2046,7 +2046,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2140,7 +2140,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2270,7 +2270,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2376,7 +2376,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2480,7 +2480,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2574,7 +2574,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2673,7 +2673,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2780,7 +2780,7 @@ "type": "string" }, "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2874,7 +2874,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -2971,7 +2971,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3065,7 +3065,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3159,7 +3159,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3268,7 +3268,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3374,7 +3374,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3496,7 +3496,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3611,7 +3611,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3716,7 +3716,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3825,7 +3825,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -3922,7 +3922,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4023,7 +4023,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4133,7 +4133,7 @@ "type": "string" }, "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4265,7 +4265,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4364,7 +4364,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4459,7 +4459,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4553,7 +4553,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4653,7 +4653,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4756,7 +4756,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4855,7 +4855,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -4947,7 +4947,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5046,7 +5046,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5145,7 +5145,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5244,7 +5244,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5345,7 +5345,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5439,7 +5439,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5533,7 +5533,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5632,7 +5632,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5733,7 +5733,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5832,7 +5832,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -5926,7 +5926,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6023,7 +6023,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6122,7 +6122,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6221,7 +6221,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6320,7 +6320,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6420,7 +6420,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6520,7 +6520,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6621,7 +6621,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6722,7 +6722,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6825,7 +6825,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -6950,7 +6950,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7053,7 +7053,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7156,7 +7156,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7258,7 +7258,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7355,7 +7355,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7455,7 +7455,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7571,7 +7571,7 @@ "type": "string" }, "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7678,7 +7678,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7775,7 +7775,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7885,7 +7885,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -7985,7 +7985,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8108,7 +8108,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8202,7 +8202,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8313,7 +8313,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8413,7 +8413,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8514,7 +8514,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8611,7 +8611,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8708,7 +8708,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8802,7 +8802,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -8902,7 +8902,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9006,7 +9006,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9103,7 +9103,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9200,7 +9200,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9300,7 +9300,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9397,7 +9397,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9523,7 +9523,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9647,7 +9647,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9772,7 +9772,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9866,7 +9866,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -9961,7 +9961,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10061,7 +10061,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10201,7 +10201,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10314,7 +10314,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10413,7 +10413,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10523,7 +10523,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10617,7 +10617,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10724,7 +10724,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10818,7 +10818,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -10912,7 +10912,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11011,12 +11011,12 @@ "openWorldHint": true, "readOnlyHint": false }, - "description": "Emulate a device by name.", + "description": "Emulate a device by name. Firefox does not support set device; use set viewport plus the --user-agent launch flag or AGENT_BROWSER_USER_AGENT instead of set device.", "inputSchema": { "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11116,7 +11116,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11220,7 +11220,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11318,12 +11318,12 @@ "openWorldHint": true, "readOnlyHint": false }, - "description": "Set media emulation.", + "description": "Set media emulation. Firefox does not support reduced motion.", "inputSchema": { "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11432,7 +11432,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11527,12 +11527,12 @@ "openWorldHint": true, "readOnlyHint": false }, - "description": "Set viewport size.", + "description": "Set viewport size. Firefox does not support mobile mode.", "inputSchema": { "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11642,7 +11642,7 @@ "type": "boolean" }, "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11747,7 +11747,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11841,7 +11841,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -11938,7 +11938,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12066,7 +12066,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12170,7 +12170,7 @@ "type": "boolean" }, "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12267,7 +12267,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12361,7 +12361,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12461,7 +12461,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12565,7 +12565,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12665,7 +12665,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12765,7 +12765,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12869,7 +12869,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -12976,7 +12976,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13088,7 +13088,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13182,7 +13182,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13279,7 +13279,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13373,7 +13373,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13482,7 +13482,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13581,7 +13581,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13676,7 +13676,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13777,7 +13777,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13879,7 +13879,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -13980,7 +13980,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14074,7 +14074,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14168,7 +14168,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14265,7 +14265,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14381,7 +14381,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14482,7 +14482,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14576,7 +14576,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14686,7 +14686,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14786,7 +14786,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14889,7 +14889,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -14992,7 +14992,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15102,7 +15102,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15208,7 +15208,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15313,7 +15313,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15417,7 +15417,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15518,7 +15518,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15618,7 +15618,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15731,7 +15731,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15831,7 +15831,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, @@ -15935,7 +15935,7 @@ "additionalProperties": false, "properties": { "allowedDomains": { - "description": "Restrict browser and read traffic to these domain patterns. Chromium sessions also disable RTCPeerConnection while this is active.", + "description": "Restrict browser and read traffic to these domain patterns, including non-HTTP document navigations. Chromium sessions also disable RTCPeerConnection while this is active.", "items": { "type": "string" }, diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 8a4d8fa8f6..e53e146c92 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -203,6 +203,8 @@ agent-browser set media dark # Emulate color scheme agent-browser set media light reduced-motion # Light mode + reduced motion ``` +Firefox does not support mobile viewport mode, `set device`, or `reduced-motion` media emulation. Use `set viewport` plus the `--user-agent` launch flag (or `AGENT_BROWSER_USER_AGENT`) instead of `set device`. + ## Cookies and Storage ```bash @@ -445,7 +447,7 @@ Common tools include: - `agent_browser_eval` - `agent_browser_close` -Tool calls use the same config files and environment variables as the CLI. Each tool accepts typed arguments plus `extraArgs` for per-command options and display or emulation flags such as `--engine`; global flags that change network, file, policy, or host access (such as `--allowed-domains`, `--action-policy`, `--executable-path`, `--args`, and `--config`) are rejected and must be set in the MCP server's environment or config. The typed `allowedDomains` argument is also rejected when the server's environment or config already sets allowed domains. The common `allowedDomains` array maps to `--allowed-domains` and activates the same WebRTC containment and launch-mode restrictions. Tool discovery is paginated and includes read-only/open-world annotations so modern MCP clients can load the large typed surface incrementally. Use the `session` tool argument or `AGENT_BROWSER_SESSION` to isolate browser state. +Tool calls use the same config files and environment variables as the CLI. Each tool accepts typed arguments plus `extraArgs` for per-command options and display or emulation flags such as `--engine`; global flags that change network, file, policy, or host access (such as `--allowed-domains`, `--action-policy`, `--executable-path`, `--args`, and `--config`) are rejected and must be set in the MCP server's environment or config. The typed `allowedDomains` argument is also rejected when the server's environment or config already sets allowed domains. The common `allowedDomains` array maps to `--allowed-domains`, including Firefox's rejection of non-HTTP document navigations, and activates the same WebRTC containment and launch-mode restrictions. Tool discovery is paginated and includes read-only/open-world annotations so modern MCP clients can load the large typed surface incrementally. Use the `session` tool argument or `AGENT_BROWSER_SESSION` to isolate browser state. ## Global Options diff --git a/skill-data/core/references/trust-boundaries.md b/skill-data/core/references/trust-boundaries.md index 15338ceb19..8ac6aabfe0 100644 --- a/skill-data/core/references/trust-boundaries.md +++ b/skill-data/core/references/trust-boundaries.md @@ -45,7 +45,7 @@ The hook in particular exposes `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` to every ## Network interception and automation artifacts -- `--allowed-domains` blocks non-allowlisted HTTP traffic, WebSocket and EventSource connections, and `sendBeacon` calls. It also disables `RTCPeerConnection` for supported Chromium sessions because STUN, TURN, and related DNS traffic do not pass through CDP HTTP interception. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. Locally launched Chrome additionally disables non-proxied WebRTC UDP. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Treat this as browser-level containment and combine it with host or container egress controls when you need an operating-system security boundary. +- `--allowed-domains` blocks non-allowlisted HTTP traffic, non-HTTP document navigations in Firefox other than `about:blank`, WebSocket and EventSource connections, and `sendBeacon` calls. Non-HTTP sub-resources remain allowed. It also disables `RTCPeerConnection` for supported Chromium sessions because STUN, TURN, and related DNS traffic do not pass through CDP HTTP interception. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. Locally launched Chrome additionally disables non-proxied WebRTC UDP. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Treat this as browser-level containment and combine it with host or container egress controls when you need an operating-system security boundary. - `network route` can fail or mock requests. Treat it the way you treat production traffic manipulation — confirm with the user before using it against anything other than a dev server. - `har start` / `har stop` records every request and response body to disk, including auth headers and bearer tokens. Don't share HAR files without redaction. - Screenshots and videos can accidentally capture secrets (auto-filled form fields, visible tokens in URL bars, etc.). Review before sending. From 2e61c4fe52455b13a78f42e1200bb298026c241e Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:39:36 -0800 Subject: [PATCH 02/36] fix(firefox): retain HAR request bodies and elapsed time --- cli/src/native/actions.rs | 45 +++++++++- cli/src/native/e2e_tests.rs | 49 +++++++++++ cli/src/native/firefox/network.rs | 133 +++++++++++++++++++++++++++--- cli/src/native/firefox/session.rs | 5 +- 4 files changed, 216 insertions(+), 16 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index df6f326ac5..ff9d40f6fe 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -88,7 +88,7 @@ struct ActiveProviderSession { #[derive(Clone)] pub struct HarEntry { pub request_id: String, - /// Seconds since Unix epoch (CDP `wallTime`), with sub-second precision. + /// Seconds since Unix epoch from CDP `wallTime` or a BiDi event timestamp. pub wall_time: f64, // Request fields pub method: String, @@ -112,6 +112,8 @@ pub struct HarEntry { /// Monotonic timestamp (seconds) from `Network.loadingFinished`; used to /// compute the `receive` timing phase. pub loading_finished_timestamp: Option, + pub response_started_timestamp: Option, + pub response_completed_timestamp: Option, /// Response body fetched via `Network.getResponseBody` when the entry /// finished loading, subject to the active [`HarContentMode`] and size caps. pub response_body: Option, @@ -2138,6 +2140,8 @@ impl DaemonState { response_body_size: -1, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: None, response_body_base64: false, }); @@ -12159,8 +12163,13 @@ fn har_entry_to_json(e: HarEntry) -> Value { .map(|(k, v)| json!({ "name": k, "value": v })) .collect(); - let (timings, total_time) = - har_compute_timings(e.cdp_timing.as_ref(), e.loading_finished_timestamp); + let (timings, total_time) = e + .response_started_timestamp + .zip(e.response_completed_timestamp) + .map(|(started, completed)| har_compute_bidi_timings(e.wall_time, started, completed)) + .unwrap_or_else(|| { + har_compute_timings(e.cdp_timing.as_ref(), e.loading_finished_timestamp) + }); let mime_type = if e.mime_type.is_empty() { "application/octet-stream".to_string() @@ -12400,6 +12409,18 @@ fn har_compute_timings( (timings, total) } +fn har_compute_bidi_timings(started: f64, response_started: f64, completed: f64) -> (Value, f64) { + let millis = |end: f64, start: f64| ((end - start) * 1000.0).max(0.0); + ( + json!({ + "send": 0, + "wait": millis(response_started, started), + "receive": millis(completed, response_started), + }), + millis(completed, started), + ) +} + /// Format a Unix epoch timestamp (seconds, fractional) as RFC 3339 using the /// `time` crate, e.g. `"2024-03-17T10:30:00.456Z"`. fn har_wall_time_to_rfc3339(wall_time: f64) -> String { @@ -18841,6 +18862,8 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd response_body_size: 42, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: Some(1773576000.4), + response_completed_timestamp: Some(1773576000.42), response_body: None, response_body_base64: false, }; @@ -18866,6 +18889,10 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd assert_eq!(har["response"]["cookies"][0]["name"], "token"); assert_eq!(har["response"]["cookies"][0]["value"], "xyz"); assert_eq!(har["_resourceType"], "XHR"); + assert_eq!(har["timings"]["send"], 0); + assert!((har["timings"]["wait"].as_f64().unwrap() - 400.0).abs() < 0.001); + assert!((har["timings"]["receive"].as_f64().unwrap() - 20.0).abs() < 0.001); + assert!((har["time"].as_f64().unwrap() - 420.0).abs() < 0.001); // No body captured: content carries size/MIME only. assert!(har["response"]["content"].get("text").is_none()); assert!(har["response"]["content"].get("encoding").is_none()); @@ -18891,6 +18918,8 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd response_body_size: 13, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: Some(r#"{"items":[1]}"#.to_string()), response_body_base64: false, }; @@ -18898,6 +18927,10 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd let har = har_entry_to_json(entry.clone()); assert_eq!(har["response"]["content"]["text"], r#"{"items":[1]}"#); assert!(har["response"]["content"].get("encoding").is_none()); + assert_eq!(har["timings"]["send"], 0); + assert_eq!(har["timings"]["wait"], 0); + assert_eq!(har["timings"]["receive"], 0); + assert_eq!(har["time"], 0.0); entry.response_body = Some("aGVsbG8=".to_string()); entry.response_body_base64 = true; @@ -18987,6 +19020,8 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd response_body_size: 0, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: None, response_body_base64: false, }; @@ -19044,6 +19079,8 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd response_body_size: 128, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: None, response_body_base64: false, }); @@ -19093,6 +19130,8 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd response_body_size: 64, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: None, response_body_base64: false, }); diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 717d209d29..8eac9e398a 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -14403,6 +14403,9 @@ fn firefox_serve( .unwrap() .push(format!("{port}{path}#authorization")); } + if path == "/har-post" { + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + } let status = if path == "/auth" { "401 Unauthorized" } else { @@ -16033,3 +16036,49 @@ async fn e2e_firefox_state_load_keeps_storage_away_from_page_scripts_and_redirec redirect_server.abort(); hook_server.abort(); } + +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_har_preserves_post_body_and_elapsed_time() { + required_firefox_binary(); + let tmp = tempfile::tempdir().unwrap(); + let _env = firefox_test_environment(tmp.path()); + let fixture = firefox_fixture().await; + let mut state = DaemonState::new(); + launch_firefox(&mut state, json!({})).await; + firefox_open(&mut state, &fixture.url).await; + let started = firefox_command( + &mut state, + json!({ "id": "har-start", "action": "har_start", "content": "all" }), + ) + .await; + assert_success(&started); + assert_eq!( + firefox_eval(&mut state, "fetch('/har-post', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'payload=har-probe'}).then(r => r.text())").await, + "ok" + ); + let path = tmp.path().join("capture.har"); + let stopped = firefox_command( + &mut state, + json!({ "id": "har-stop", "action": "har_stop", "path": path.to_str().unwrap() }), + ) + .await; + assert_success(&stopped); + let har: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let entry = har["log"]["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| { + entry["request"]["url"] + .as_str() + .unwrap() + .ends_with("/har-post") + }) + .expect("HAR must contain the delayed POST"); + assert_eq!(entry["request"]["postData"]["text"], "payload=har-probe"); + assert!(entry["time"].as_f64().unwrap() >= 300.0); + assert!(entry["timings"]["wait"].as_f64().unwrap() >= 300.0); + assert!(entry["timings"]["send"].as_f64().unwrap() >= 0.0); + firefox_close(&mut state).await; +} diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index d00a587412..84c3c51deb 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -427,12 +427,13 @@ async fn apply_events(state: &mut DaemonState, events: Vec) { let p = &e["params"]; match e["method"].as_str() { Some("network.beforeRequestSent") => request_started(state, p), - Some("network.responseStarted") => response_seen(state, p), + Some("network.responseStarted") => response_seen(state, p, true), Some("network.responseCompleted") => { - response_seen(state, p); + response_seen(state, p, false); if let Some(ff) = state.firefox.as_mut() { ff.inflight.remove(request_id(p)); } + har_request_body(state, request_id(p)).await; har_body(state, request_id(p)).await; } Some("network.fetchError") => { @@ -460,12 +461,13 @@ fn request_started(state: &mut DaemonState, p: &Value) { ff.inflight.insert(id.clone()); } } - let millis = p["timestamp"].as_u64().unwrap_or_default(); + let timestamp = p["timestamp"].as_f64(); + let millis = timestamp.map(|t| t as u64).unwrap_or_default(); let resource_type = resource_type(request).to_string(); - if state.har_recording { + if let Some(timestamp) = timestamp.filter(|_| state.har_recording) { state.har_entries.push(HarEntry { request_id: id.clone(), - wall_time: millis as f64 / 1000.0, + wall_time: timestamp / 1000.0, method: method.clone(), url: url.clone(), request_headers: header_list(&request["headers"]), @@ -481,6 +483,8 @@ fn request_started(state: &mut DaemonState, p: &Value) { response_body_size: -1, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: None, response_body_base64: false, }); @@ -501,7 +505,7 @@ fn request_started(state: &mut DaemonState, p: &Value) { } } -fn response_seen(state: &mut DaemonState, p: &Value) { +fn response_seen(state: &mut DaemonState, p: &Value, started: bool) { let id = request_id(p); let response = &p["response"]; let status = response["status"].as_i64(); @@ -530,6 +534,12 @@ fn response_seen(state: &mut DaemonState, p: &Value) { .as_i64() .or(response["content"]["size"].as_i64()) .unwrap_or(-1); + let timestamp = p["timestamp"].as_f64().map(|t| t / 1000.0); + if started { + entry.response_started_timestamp = timestamp; + } else { + entry.response_completed_timestamp = timestamp; + } } } if state.request_tracking { @@ -546,12 +556,11 @@ fn response_seen(state: &mut DaemonState, p: &Value) { } } -/// Reads the body bytes of a collected response as `(text, is_base64)`. -async fn response_data(bidi: &BidiClient, id: &str) -> Result<(String, bool), String> { +async fn data(bidi: &BidiClient, id: &str, data_type: &str) -> Result<(String, bool), String> { let r = bidi .send( "network.getData", - json!({"dataType": "response", "request": id}), + json!({"dataType": data_type, "request": id}), ) .await?; Ok(( @@ -560,6 +569,42 @@ async fn response_data(bidi: &BidiClient, id: &str) -> Result<(String, bool), St )) } +async fn har_request_body(state: &mut DaemonState, id: &str) { + let wanted = state.har_recording + && state + .har_entries + .iter() + .rev() + .find(|e| e.request_id == id) + .is_some_and(|e| e.post_data.is_none()); + let Some(ff) = state.firefox.as_ref().filter(|_| wanted) else { + return; + }; + let Ok((body, base64)) = data(&ff.bidi, id, "request").await else { + return; + }; + let body = if base64 { + let Ok(bytes) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, body) + else { + return; + }; + let Ok(body) = String::from_utf8(bytes) else { + return; + }; + body + } else { + body + }; + if let Some(entry) = state + .har_entries + .iter_mut() + .rev() + .find(|e| e.request_id == id) + { + entry.post_data = Some(body); + } +} + async fn har_body(state: &mut DaemonState, id: &str) { let wanted = state.har_recording && state @@ -578,7 +623,7 @@ async fn har_body(state: &mut DaemonState, id: &str) { let Some(ff) = state.firefox.as_ref().filter(|_| wanted) else { return; }; - let Ok((body, base64)) = response_data(&ff.bidi, id).await else { + let Ok((body, base64)) = data(&ff.bidi, id, "response").await else { return; }; if body.is_empty() @@ -742,7 +787,7 @@ pub async fn request_detail(cmd: &Value, state: &mut DaemonState) -> Result Result Date: Thu, 5 Nov 2026 15:32:25 -0800 Subject: [PATCH 03/36] fix(firefox): complete native HTML5 drag gestures --- cli/src/native/e2e_tests.rs | 37 ++++++++++++++++++++++++++++++ cli/src/native/firefox/interact.rs | 4 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 8eac9e398a..ef2eb88225 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -14432,6 +14432,11 @@ fn firefox_serve( "", include_str!("test_fixtures/firefox_forms_frame.html").to_string(), ), + "/html5_drag_probe.html" => ( + "text/html; charset=utf-8", + "", + include_str!("test_fixtures/html5_drag_probe.html").to_string(), + ), "/api" => ("text/plain", "", "real".to_string()), "/auth" => ( "text/plain", @@ -14947,6 +14952,38 @@ async fn e2e_firefox_core_flow_and_ref_stability() { assert!(!firefox_processes_using(tmp.path())); } +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_html5_drag_drops_payload() { + required_firefox_binary(); + let tmp = tempfile::tempdir().unwrap(); + let _env = firefox_test_environment(tmp.path()); + let fixture = firefox_fixture().await; + let mut state = DaemonState::new(); + launch_firefox(&mut state, json!({})).await; + firefox_open(&mut state, &format!("{}html5_drag_probe.html", fixture.url)).await; + + let drag = firefox_command( + &mut state, + json!({ "id": "drag", "action": "drag", "source": "#source", "target": "#dest" }), + ) + .await; + assert_success(&drag); + + let events = firefox_eval(&mut state, "window.__html5DragProbe.events").await; + let events = events + .as_array() + .expect("html5 drag probe should expose events"); + assert!(events + .iter() + .any(|event| event["type"] == "dragover" && event["target"] == "dest")); + assert!(events.iter().any(|event| { + event["type"] == "drop" && event["target"] == "dest" && event["dropped"] == "probe" + })); + + firefox_close(&mut state).await; +} + #[tokio::test] #[ignore = "requires FIREFOX_BIN; run serially"] async fn e2e_firefox_drag_scrolls_destination_and_releases_on_failure() { diff --git a/cli/src/native/firefox/interact.rs b/cli/src/native/firefox/interact.rs index 8d8e6b2644..b9569384aa 100644 --- a/cli/src/native/firefox/interact.rs +++ b/cli/src/native/firefox/interact.rs @@ -285,7 +285,9 @@ pub async fn drag(cmd: &Value, state: &mut DaemonState) -> Result let (tx, ty) = prepare(ff, &target, target_selector, true).await?; let target_origin = Origin::Viewport { x: tx, y: ty }; release_at = target_origin.clone(); - if perform(ff, &source_target.context, pointer_hover(&target_origin)).await? { + let mut move_to_target = pointer_hover(&target_origin); + move_to_target[0]["actions"][0]["duration"] = json!(250); + if perform(ff, &source_target.context, move_to_target).await? { return Ok(()); } perform( From aedffcfbbef77f37384f7d3e36319c79b7c5db3e Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:32:25 -0800 Subject: [PATCH 04/36] fix(firefox): read the active page through BiDi --- cli/src/native/actions.rs | 12 +++++++++--- cli/src/native/e2e_tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index ff9d40f6fe..9e340253a5 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -6120,7 +6120,10 @@ async fn navigate_active_page( Ok(result) } -async fn handle_url(state: &DaemonState) -> Result { +async fn handle_url(state: &mut DaemonState) -> Result { + if state.firefox.is_some() { + return firefox::page::url(state).await; + } if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let url = wb.get_url().await?; @@ -6132,7 +6135,7 @@ async fn handle_url(state: &DaemonState) -> Result { Ok(json!({ "url": url })) } -async fn handle_read(cmd: &Value, state: &DaemonState) -> Result { +async fn handle_read(cmd: &Value, state: &mut DaemonState) -> Result { let mut options = crate::read::options_from_command(cmd)?; if let Some(allowed_domains) = { let df = state.domain_filter.read().await; @@ -6232,7 +6235,10 @@ async fn handle_title(state: &DaemonState) -> Result { Ok(json!({ "title": title })) } -async fn handle_content(state: &DaemonState) -> Result { +async fn handle_content(state: &mut DaemonState) -> Result { + if state.firefox.is_some() { + return firefox::page::content(state).await; + } if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let html = wb.get_content().await?; diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index ef2eb88225..faaddea24c 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16119,3 +16119,32 @@ async fn e2e_firefox_har_preserves_post_body_and_elapsed_time() { assert!(entry["timings"]["send"].as_f64().unwrap() >= 0.0); firefox_close(&mut state).await; } + +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_read_active_page() { + required_firefox_binary(); + let home = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let _env = firefox_test_environment(tmp.path()); + _env.set("HOME", home.path().to_str().unwrap()); + _env.remove("AGENT_BROWSER_ENCRYPTION_KEY"); + let fixture = firefox_fixture().await; + let mut state = DaemonState::new(); + launch_firefox(&mut state, json!({})).await; + firefox_open(&mut state, &fixture.url).await; + firefox_eval( + &mut state, + "document.body.innerHTML = '

Active page read marker

'", + ) + .await; + let resp = firefox_command(&mut state, json!({ "id": "read", "action": "read" })).await; + assert_success(&resp); + assert_eq!(get_data(&resp)["finalUrl"], fixture.url); + assert_eq!(get_data(&resp)["source"], "active-tab-html"); + assert!(get_data(&resp)["content"] + .as_str() + .unwrap() + .contains("Active page read marker")); + firefox_close(&mut state).await; +} From abfcae2013e22b3ea3e7f6d25bb5a1b428927480 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:14:02 -0800 Subject: [PATCH 05/36] fix(firefox): complete HTML5 drags with DOM events --- cli/src/mcp.rs | 2 +- cli/src/native/e2e_tests.rs | 31 ++++++++++ cli/src/native/firefox/interact.rs | 62 +++++++++++++++++-- cli/src/output.rs | 1 + .../@agent-browser/sandbox/mcp-tools.json | 2 +- skill-data/core/references/commands.md | 2 + 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index 9fe3b7fb03..a1d06f50c0 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -1059,7 +1059,7 @@ fn parity_tools() -> Vec { tool( TOOL_DRAG, "Drag and drop", - "Drag one element to another.", + "Drag one element to another. On Firefox, HTML5 drag-and-drop uses synthetic DOM events, so handlers that require trusted drag events are unsupported; other pointer dragging uses native pointer input.", json!({ "source": selector_schema(), "target": selector_schema(), "human": { "type": "boolean", "default": false, "description": "Use seeded, curved mouse movement." } }), &["source", "target"], ), diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index faaddea24c..e60459ae73 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -14981,6 +14981,37 @@ async fn e2e_firefox_html5_drag_drops_payload() { event["type"] == "drop" && event["target"] == "dest" && event["dropped"] == "probe" })); + for (selector, event, handler, capture) in [ + ("#source", "dragstart", "event.preventDefault()", false), + ( + "#dest", + "dragover", + "event.stopImmediatePropagation()", + true, + ), + ] { + firefox_eval(&mut state, &format!( + "window.__html5DragProbe.events.length = 0; document.querySelector('{selector}').addEventListener('{event}', event => {{ {handler}; }}, {{once: true, capture: {capture}}})" + )).await; + assert_success(&firefox_command( + &mut state, + json!({ "id": "cancelled-drag", "action": "drag", "source": "#source", "target": "#dest" }), + ).await); + let events = firefox_eval(&mut state, "window.__html5DragProbe.events").await; + assert!(!events + .as_array() + .unwrap() + .iter() + .any(|event| event["type"] == "drop")); + } + + firefox_eval(&mut state, "dest.style.cssText = 'position:absolute;top:1500px;z-index:2'; source.addEventListener('dragstart', event => { const rect = source.getBoundingClientRect(); window.__dragOffset = event.clientY - rect.top - rect.height / 2; }, {once:true})").await; + assert_success(&firefox_command( + &mut state, + json!({ "id": "offscreen-drag", "action": "drag", "source": "#source", "target": "#dest" }), + ).await); + assert_eq!(firefox_eval(&mut state, "window.__dragOffset").await, 0); + firefox_close(&mut state).await; } diff --git a/cli/src/native/firefox/interact.rs b/cli/src/native/firefox/interact.rs index b9569384aa..bc39e9b727 100644 --- a/cli/src/native/firefox/interact.rs +++ b/cli/src/native/firefox/interact.rs @@ -252,7 +252,7 @@ pub async fn swipe(cmd: &Value, state: &mut DaemonState) -> Result Result { let source = cmd .get("source") @@ -270,6 +270,62 @@ pub async fn drag(cmd: &Value, state: &mut DaemonState) -> Result let ff = session(state)?; let (sx, sy) = prepare(ff, &source_target, source, true).await?; + let html5 = ff + .bidi + .call_function( + &source_target.context, + "function() { return this.draggable === true; }", + Some(source_target.node_ref()), + vec![], + ) + .await?; + if html5 == true { + let (tx, ty) = prepare(ff, &target, target_selector, true).await?; + ff.race_prompt( + &source_target.context, + ff.bidi.call_function( + &source_target.context, + r#"function(target, points) { + const dataTransfer = new DataTransfer(); + dataTransfer.effectAllowed = 'all'; + const uri = this.localName === 'a' ? this.href + : this.localName === 'img' ? this.currentSrc || this.src : null; + if (uri) { + dataTransfer.setData('text/uri-list', uri); + dataTransfer.setData('text/plain', uri); + dataTransfer.setData('text/html', this.outerHTML); + } + const fire = (node, type, x, y) => node.dispatchEvent(new DragEvent(type, { + bubbles: true, cancelable: true, composed: true, + clientX: x, clientY: y, dataTransfer, + })); + const rect = this.getBoundingClientRect(); + const sx = rect.x + rect.width / 2; + const sy = rect.y + rect.height / 2; + const [tx, ty] = points; + if (!fire(this, 'dragstart', sx, sy)) return; + const allowed = { + none: [], copy: ['copy'], link: ['link'], move: ['move'], + copyLink: ['copy', 'link'], copyMove: ['copy', 'move'], + linkMove: ['link', 'move'], all: ['copy', 'link', 'move'], + uninitialized: ['copy', 'link', 'move'], + }[dataTransfer.effectAllowed]; + dataTransfer.dropEffect = allowed[0] || 'none'; + fire(target, 'dragenter', tx, ty); + if (!fire(target, 'dragover', tx, ty) && allowed.includes(dataTransfer.dropEffect)) { + fire(target, 'drop', tx, ty); + } else { + dataTransfer.dropEffect = 'none'; + } + fire(this, 'dragend', tx, ty); + }"#, + Some(source_target.node_ref()), + vec![target.node_ref(), json!([tx, ty])], + ), + ) + .await?; + return Ok(json!({ "dragged": true, "source": source, "target": target_selector })); + } let source_origin = Origin::Viewport { x: sx, y: sy }; let mut release_at = source_origin.clone(); let result: Result<(), String> = async { @@ -285,9 +341,7 @@ pub async fn drag(cmd: &Value, state: &mut DaemonState) -> Result let (tx, ty) = prepare(ff, &target, target_selector, true).await?; let target_origin = Origin::Viewport { x: tx, y: ty }; release_at = target_origin.clone(); - let mut move_to_target = pointer_hover(&target_origin); - move_to_target[0]["actions"][0]["duration"] = json!(250); - if perform(ff, &source_target.context, move_to_target).await? { + if perform(ff, &source_target.context, pointer_hover(&target_origin)).await? { return Ok(()); } perform( diff --git a/cli/src/output.rs b/cli/src/output.rs index 7b9382e555..a48cd8f182 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1928,6 +1928,7 @@ agent-browser drag - Drag and drop Usage: agent-browser drag [--human] Drags an element from source to target location. +On Firefox, HTML5 drag-and-drop uses synthetic DOM events, so handlers that require trusted drag events are unsupported; other pointer dragging uses native pointer input. Global Options: --json Output as JSON diff --git a/packages/@agent-browser/sandbox/mcp-tools.json b/packages/@agent-browser/sandbox/mcp-tools.json index b02ae6f1bd..60c9057591 100644 --- a/packages/@agent-browser/sandbox/mcp-tools.json +++ b/packages/@agent-browser/sandbox/mcp-tools.json @@ -3711,7 +3711,7 @@ "openWorldHint": true, "readOnlyHint": false }, - "description": "Drag one element to another.", + "description": "Drag one element to another. On Firefox, HTML5 drag-and-drop uses synthetic DOM events, so handlers that require trusted drag events are unsupported; other pointer dragging uses native pointer input.", "inputSchema": { "additionalProperties": false, "properties": { diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index e53e146c92..def5d1f0d4 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -84,6 +84,8 @@ agent-browser drag @e1 @e2 # Drag and drop agent-browser upload @e1 file.pdf # Upload files ``` +On Firefox, HTML5 drag-and-drop uses synthetic DOM events, so handlers that require trusted drag events are unsupported; other pointer dragging uses native pointer input. + Visible-label matching treats non-breaking and ordinary spaces equivalently. Clicks fail before dispatch when another element covers the target's click point. The error names the covering element, for example `covered by `. Dismiss or interact with that element, run a fresh snapshot, then retry the original action. Click and dblclick fail, instead of reporting success, when the element is hidden, disabled, or outside the viewport. `click --new-tab` opens the link's URL directly, so these checks do not apply to it. From aa564517ceeb5e5e703b47f6b8058b5c46ce4532 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:01:04 -0800 Subject: [PATCH 06/36] Keep Firefox proxy credentials scoped to their settings --- cli/src/native/actions.rs | 6 ++++++ cli/src/native/e2e_tests.rs | 1 - cli/src/native/firefox/network.rs | 19 +++++++++-------- cli/src/native/flow/mod.rs | 2 ++ cli/src/session_settings/plan.rs | 6 ++++++ cli/src/session_settings/tests.rs | 34 +++++++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 1a46ed93c2..fcc283fc16 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -5833,6 +5833,7 @@ struct SettingsOutcome { struct PendingSettings { keys: Vec, before: Running, + proxy_credentials: Option<(String, String)>, filter: Option, ca_cert: Option, attached: Option, @@ -5906,6 +5907,7 @@ async fn rollback_launch_settings( state.effective_ca_cert = pending.ca_cert; state.attached_target = pending.attached; state.cdp_headers = pending.cdp_headers; + *state.proxy_credentials.write().await = pending.proxy_credentials; outcome.mark_rolled_back(&restored); } @@ -6026,6 +6028,7 @@ async fn apply_settings_patch( Some(PendingSettings { keys, before: state.settings.clone(), + proxy_credentials: state.proxy_credentials.read().await.clone(), filter: state.domain_filter.read().await.clone(), ca_cert: state.effective_ca_cert.clone(), attached: state.attached_target.clone(), @@ -20782,6 +20785,8 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"browser":{"cd response_body_size: response_body.len() as i64, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: Some(response_body.to_string()), response_body_base64: false, }); @@ -22840,6 +22845,7 @@ mod firefox_routing_tests { .await .unwrap_err(); assert!(e.contains("--args"), "{e}"); + state.session_name = Some("k".into()); let e = handle_launch( &launch(json!({"allowedDomains":"a.test","restoreKey":"k"})), &mut state, diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 9156bc6e22..f2ac30c16c 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16974,7 +16974,6 @@ async fn e2e_firefox_har_preserves_post_body_and_elapsed_time() { firefox_close(&mut state).await; } - const FLOW_FIXTURE_PAGE: &str = r#" diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index 84c3c51deb..c5572bacdd 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -899,14 +899,17 @@ async fn wait_download( pub async fn download(cmd: &Value, state: &mut DaemonState) -> Result { let result = download_to_path(cmd, state).await; if let Some(ff) = state.firefox.as_ref() { - let dir = ff - .download_dir - .clone() - .or_else(|| dirs::download_dir().map(|p| p.to_string_lossy().into_owned())); - if let Some(dir) = dir { - let params = json!({"downloadBehavior": {"type": "allowed", "destinationFolder": dir}}); - let _ = ff.bidi.send("browser.setDownloadBehavior", params).await; - } + let behavior = match &ff.download_dir { + Some(dir) => json!({"type": "allowed", "destinationFolder": dir}), + None => Value::Null, + }; + let _ = ff + .bidi + .send( + "browser.setDownloadBehavior", + json!({"downloadBehavior": behavior}), + ) + .await; } result } diff --git a/cli/src/native/flow/mod.rs b/cli/src/native/flow/mod.rs index 6a8212e988..80612604cc 100644 --- a/cli/src/native/flow/mod.rs +++ b/cli/src/native/flow/mod.rs @@ -2077,6 +2077,8 @@ mod tests { response_body_size: body.len() as i64, cdp_timing: None, loading_finished_timestamp: None, + response_started_timestamp: None, + response_completed_timestamp: None, response_body: Some(body.to_string()), response_body_base64: false, } diff --git a/cli/src/session_settings/plan.rs b/cli/src/session_settings/plan.rs index 4dcdf7f276..7cc112bc47 100644 --- a/cli/src/session_settings/plan.rs +++ b/cli/src/session_settings/plan.rs @@ -204,6 +204,12 @@ impl Running { // extensions cannot be combined with it. The one set last // wins, so an inherited value cannot wedge the session. match key { + Key::Proxy => displace( + &mut next, + patch, + &[Key::ProxyUsername, Key::ProxyPassword], + &mut changes, + ), Key::StorageState => displace( &mut next, patch, diff --git a/cli/src/session_settings/tests.rs b/cli/src/session_settings/tests.rs index a14ba55aa6..a053639708 100644 --- a/cli/src/session_settings/tests.rs +++ b/cli/src/session_settings/tests.rs @@ -1065,6 +1065,40 @@ fn a_forced_relaunch_needs_no_change() { assert_eq!(Patch::from_wire(Some(&wire)).unwrap(), patch); } +#[test] +fn changing_proxy_drops_only_inherited_credentials() { + let running = running_with(&[ + (Key::Proxy, json!("http://old-proxy:8080")), + (Key::ProxyUsername, json!("old-user")), + (Key::ProxyPassword, json!("old-password")), + ]); + assert_eq!(running.plan(&Patch::default()).next, running); + + let mut flags = blank_flags(); + for (proxy, username, password) in [ + ("http://new-proxy:8080", None, None), + ("http://new-user@new-proxy:8080", Some("new-user"), None), + ( + "http://new-user:new-password@new-proxy:8080", + Some("new-user"), + Some("new-password"), + ), + ] { + flags.proxy = Some(proxy.to_string()); + let patch = Patch::from_flags(&flags); + let wire = patch.to_wire().unwrap(); + let patch = Patch::from_wire(Some(&wire)).unwrap(); + let plan = running.plan(&patch); + let options = plan.next.launch_options(); + assert!(plan.relaunch); + assert!(plan.ignored.is_empty()); + assert_eq!(options.proxy.as_deref(), Some("http://new-proxy:8080")); + assert_eq!(options.proxy_username.as_deref(), username); + assert_eq!(options.proxy_password.as_deref(), password); + assert!(plan.next.plan(&patch).is_empty()); + } +} + #[test] fn a_connection_target_displaces_the_others() { let running = running_with(&[ From 55acbd37b4e3fccd18e3fdc8c20965477b439635 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:32:23 -0800 Subject: [PATCH 07/36] fix(firefox): track repeated downloads on Firefox 150 Give legacy download events a fresh identity after completion because Firefox 150 reuses navigation IDs. Preserve modern download IDs and extend the real-browser regression to repeat the download. --- cli/src/native/e2e_tests.rs | 14 ++++++++++++++ cli/src/native/firefox/network.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index f2ac30c16c..190a2ee608 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16491,6 +16491,20 @@ async fn e2e_firefox_download() { assert_success(&resp); assert_eq!(get_data(&resp)["path"], json!(wait_dest.to_string_lossy())); assert_eq!(std::fs::read_to_string(&wait_dest).unwrap(), "downloaded"); + + let repeated_dest = out.path().join("repeated.txt"); + let resp = firefox_act( + &mut state, + "download", + "#dl", + json!({ "path": repeated_dest.to_str().unwrap() }), + ) + .await; + assert_success(&resp); + assert_eq!( + std::fs::read_to_string(&repeated_dest).unwrap(), + "downloaded" + ); firefox_close(&mut state).await; } diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index c5572bacdd..1d28fd270c 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -246,7 +246,8 @@ pub fn spawn_dispatcher( ) -> AbortOnDrop { AbortOnDrop(tokio::spawn(async move { let mut emulated_contexts = HashSet::new(); - while let Some(e) = rx.recv().await { + let mut legacy_downloads = HashMap::new(); + while let Some(mut e) = rx.recv().await { if e["method"] == "browsingContext.contextCreated" && e["params"]["parent"].is_null() { if let Some(context) = e["params"]["context"].as_str() { if emulated_contexts.insert(context.to_string()) { @@ -272,6 +273,34 @@ pub fn spawn_dispatcher( Some("browsingContext.downloadWillBegin" | "browsingContext.downloadEnd") ); if is_download { + if e["params"]["download"].as_str().is_none() { + if let Some(navigation) = e["params"]["navigation"].as_str() { + let key = ( + e["params"]["context"].as_str().map(str::to_owned), + navigation.to_owned(), + e["params"]["url"].as_str().map(str::to_owned), + ); + let download = if e["method"] == "browsingContext.downloadWillBegin" { + let (download, pending) = legacy_downloads + .entry(key) + .or_insert_with(|| (uuid::Uuid::new_v4().to_string(), 0usize)); + *pending += 1; + Some(download.clone()) + } else if let Some((download, pending)) = legacy_downloads.get_mut(&key) { + let download = download.clone(); + *pending -= 1; + if *pending == 0 { + legacy_downloads.remove(&key); + } + Some(download) + } else { + None + }; + if let Some(download) = download { + e["params"]["download"] = json!(download); + } + } + } net.downloads .lock() .await From b1bebfb788575d637f03024956a47ebeafdb874c Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:15:03 -0800 Subject: [PATCH 08/36] fix(firefox): track request bodies and link downloads --- cli/src/native/e2e_tests.rs | 40 ++++ cli/src/native/firefox/network.rs | 200 +++++++++++++++--- .../native/test_fixtures/firefox_probe.html | 1 + 3 files changed, 206 insertions(+), 35 deletions(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 190a2ee608..17a94b20ab 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16505,6 +16505,17 @@ async fn e2e_firefox_download() { std::fs::read_to_string(&repeated_dest).unwrap(), "downloaded" ); + + let inline_dest = out.path().join("inline.txt"); + let resp = firefox_act( + &mut state, + "download", + "#dl-inline", + json!({ "path": inline_dest.to_str().unwrap() }), + ) + .await; + assert_success(&resp); + assert_eq!(std::fs::read_to_string(&inline_dest).unwrap(), "real"); firefox_close(&mut state).await; } @@ -16952,6 +16963,13 @@ async fn e2e_firefox_har_preserves_post_body_and_elapsed_time() { let mut state = DaemonState::new(); launch_firefox(&mut state, json!({})).await; firefox_open(&mut state, &fixture.url).await; + assert_success( + &firefox_command( + &mut state, + json!({ "id": "tracked-start", "action": "requests" }), + ) + .await, + ); let started = firefox_command( &mut state, json!({ "id": "har-start", "action": "har_start", "content": "all" }), @@ -16985,6 +17003,28 @@ async fn e2e_firefox_har_preserves_post_body_and_elapsed_time() { assert!(entry["time"].as_f64().unwrap() >= 300.0); assert!(entry["timings"]["wait"].as_f64().unwrap() >= 300.0); assert!(entry["timings"]["send"].as_f64().unwrap() >= 0.0); + let tracked = firefox_command( + &mut state, + json!({ "id": "tracked", "action": "requests", "filter": "/har-post" }), + ) + .await; + assert_success(&tracked); + let request = get_data(&tracked)["requests"] + .as_array() + .and_then(|requests| requests.first()) + .expect("tracked requests must contain the HAR POST"); + assert_eq!(request["postData"], "payload=har-probe"); + let detail = firefox_command( + &mut state, + json!({ + "id": "tracked-detail", + "action": "request_detail", + "requestId": request["requestId"], + }), + ) + .await; + assert_success(&detail); + assert_eq!(get_data(&detail)["postData"], "payload=har-probe"); firefox_close(&mut state).await; } diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index 1d28fd270c..68e34f88fa 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -274,31 +274,29 @@ pub fn spawn_dispatcher( ); if is_download { if e["params"]["download"].as_str().is_none() { - if let Some(navigation) = e["params"]["navigation"].as_str() { - let key = ( - e["params"]["context"].as_str().map(str::to_owned), - navigation.to_owned(), - e["params"]["url"].as_str().map(str::to_owned), - ); - let download = if e["method"] == "browsingContext.downloadWillBegin" { - let (download, pending) = legacy_downloads - .entry(key) - .or_insert_with(|| (uuid::Uuid::new_v4().to_string(), 0usize)); - *pending += 1; - Some(download.clone()) - } else if let Some((download, pending)) = legacy_downloads.get_mut(&key) { - let download = download.clone(); - *pending -= 1; - if *pending == 0 { - legacy_downloads.remove(&key); - } - Some(download) - } else { - None - }; - if let Some(download) = download { - e["params"]["download"] = json!(download); + let key = ( + e["params"]["context"].as_str().map(str::to_owned), + e["params"]["navigation"].as_str().map(str::to_owned), + e["params"]["url"].as_str().map(str::to_owned), + ); + let download = if e["method"] == "browsingContext.downloadWillBegin" { + let (download, pending) = legacy_downloads + .entry(key) + .or_insert_with(|| (uuid::Uuid::new_v4().to_string(), 0usize)); + *pending += 1; + Some(download.clone()) + } else if let Some((download, pending)) = legacy_downloads.get_mut(&key) { + let download = download.clone(); + *pending -= 1; + if *pending == 0 { + legacy_downloads.remove(&key); } + Some(download) + } else { + None + }; + if let Some(download) = download { + e["params"]["download"] = json!(download); } } net.downloads @@ -462,7 +460,7 @@ async fn apply_events(state: &mut DaemonState, events: Vec) { if let Some(ff) = state.firefox.as_mut() { ff.inflight.remove(request_id(p)); } - har_request_body(state, request_id(p)).await; + request_body(state, request_id(p)).await; har_body(state, request_id(p)).await; } Some("network.fetchError") => { @@ -598,15 +596,26 @@ async fn data(bidi: &BidiClient, id: &str, data_type: &str) -> Result<(String, b )) } -async fn har_request_body(state: &mut DaemonState, id: &str) { - let wanted = state.har_recording +async fn request_body(state: &mut DaemonState, id: &str) { + let har_wanted = state.har_recording && state .har_entries .iter() .rev() .find(|e| e.request_id == id) .is_some_and(|e| e.post_data.is_none()); - let Some(ff) = state.firefox.as_ref().filter(|_| wanted) else { + let tracked_wanted = state.request_tracking + && state + .tracked_requests + .iter() + .rev() + .find(|e| e.request_id == id) + .is_some_and(|e| e.post_data.is_none()); + let Some(ff) = state + .firefox + .as_ref() + .filter(|_| har_wanted || tracked_wanted) + else { return; }; let Ok((body, base64)) = data(&ff.bidi, id, "request").await else { @@ -624,13 +633,25 @@ async fn har_request_body(state: &mut DaemonState, id: &str) { } else { body }; - if let Some(entry) = state - .har_entries - .iter_mut() - .rev() - .find(|e| e.request_id == id) - { - entry.post_data = Some(body); + if har_wanted { + if let Some(entry) = state + .har_entries + .iter_mut() + .rev() + .find(|e| e.request_id == id) + { + entry.post_data = Some(body.clone()); + } + } + if tracked_wanted && !body.is_empty() { + if let Some(entry) = state + .tracked_requests + .iter_mut() + .rev() + .find(|e| e.request_id == id) + { + entry.post_data = Some(body); + } } } @@ -1450,6 +1471,67 @@ mod tests { ); } + #[tokio::test] + async fn dispatcher_tracks_repeated_downloads_with_null_navigation() { + let (ff, tx, _) = fake_session().await; + let net = NetContext::default(); + let downloads = net.downloads.clone(); + let _dispatcher = spawn_dispatcher( + ff.bidi.clone(), + ff.rdp.clone(), + net, + ff.bidi.subscribe_buffered(), + ff.emulation.clone(), + ); + for filename in ["first.txt", "second.txt"] { + tx.send(json!({ + "type": "event", + "method": "browsingContext.downloadWillBegin", + "params": { + "context": "ctx-active", + "navigation": null, + "url": "http://example.com/download", + "suggestedFilename": filename, + }, + })) + .unwrap(); + tx.send(json!({ + "type": "event", + "method": "browsingContext.downloadEnd", + "params": { + "context": "ctx-active", + "navigation": null, + "url": "http://example.com/download", + "status": "complete", + "filepath": format!("/downloads/{filename}"), + }, + })) + .unwrap(); + } + let claimed = tokio::time::timeout(Duration::from_secs(5), async { + let mut claimed = Vec::new(); + loop { + let mut ledger = downloads.lock().await; + if let Some(record) = ledger.claim_finished_since(0, &[]) { + claimed.push(record); + if claimed.len() == 2 { + break claimed; + } + } + drop(ledger); + tokio::task::yield_now().await; + } + }) + .await + .expect("dispatcher should record both downloads"); + let [first, second] = claimed.as_slice() else { + unreachable!("the timeout returned two claimed downloads"); + }; + assert_ne!(first.guid, second.guid); + assert_eq!(first.suggested_filename.as_deref(), Some("first.txt")); + assert_eq!(second.suggested_filename.as_deref(), Some("second.txt")); + } + #[tokio::test] async fn har_captures_request_body_and_bidi_timestamps() { let (ff, _, _) = crate::native::firefox::session::tests::fake_session_with(|v| { @@ -1514,6 +1596,54 @@ mod tests { assert_eq!(entry.response_body.as_deref(), Some("har-response")); } + #[tokio::test] + async fn tracked_requests_include_request_body() { + let (ff, _, _) = crate::native::firefox::session::tests::fake_session_with(|v| { + if v["params"]["dataType"] == "request" { + json!({"bytes": {"type": "base64", "value": "cGF5bG9hZD10cmFja2Vk"}}) + } else { + json!({"bytes": {"type": "string", "value": "response"}}) + } + }) + .await; + let mut state = DaemonState::new(); + state.firefox = Some(ff); + state.request_tracking = true; + let request = json!({ + "request": "r1", + "url": "http://example.com/probe", + "method": "POST", + "headers": [], + "bodySize": 14, + "initiatorType": "fetch", + }); + let response = json!({"status": 200, "headers": [], "bodySize": 8}); + + apply_events( + &mut state, + vec![ + json!({ + "method": "network.beforeRequestSent", + "params": {"timestamp": 1000, "request": request}, + }), + json!({ + "method": "network.responseCompleted", + "params": {"timestamp": 2000, "request": request, "response": response}, + }), + ], + ) + .await; + + assert_eq!( + state.tracked_requests[0].post_data.as_deref(), + Some("payload=tracked") + ); + assert_eq!( + serde_json::to_value(&state.tracked_requests[0]).unwrap()["postData"], + json!("payload=tracked") + ); + } + #[tokio::test] async fn in_flight_set_ignores_websockets_and_clears_on_completion() { let (ff, _tx, _) = fake_session().await; diff --git a/cli/src/native/test_fixtures/firefox_probe.html b/cli/src/native/test_fixtures/firefox_probe.html index 6d6612462d..f8e1b159b6 100644 --- a/cli/src/native/test_fixtures/firefox_probe.html +++ b/cli/src/native/test_fixtures/firefox_probe.html @@ -9,6 +9,7 @@

Firefox probe

Download file +Download inline file
" + } + }), + ) + .await; + assert_success(&routed); + let target = format!("{}networkidle", fixture.url); + let resp = firefox_command( + &mut state, + json!({ + "id": "networkidle", + "action": "navigate", + "url": target, + "waitUntil": "networkidle", + "timeout": 5000 + }), + ) + .await; + assert_success(&resp); + assert_eq!(get_data(&resp)["title"], "request-complete"); + assert!(fixture.hit_count("/har-post") > 0); + + let resp = firefox_command( + &mut state, + json!({ + "id": "networkidle-timeout", + "action": "navigate", + "url": target, + "waitUntil": "networkidle", + "timeout": 50 + }), + ) + .await; + assert_eq!(resp["success"], false, "{resp}"); + assert!(resp["error"].as_str().unwrap_or("").contains("networkidle")); + firefox_close(&mut state).await; +} + /// PR3: allowed-domains blocks a cross-origin worker fetch and a WebSocket. #[tokio::test] #[ignore = "requires FIREFOX_BIN; run serially"] diff --git a/cli/src/native/firefox/page.rs b/cli/src/native/firefox/page.rs index 7d607b99fe..b32dcf1e85 100644 --- a/cli/src/native/firefox/page.rs +++ b/cli/src/native/firefox/page.rs @@ -96,11 +96,17 @@ pub async fn navigate(cmd: &Value, state: &mut DaemonState) -> Result "interactive", Some("none") => "none", _ => "complete", }; + let timeout_ms = cmd + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(state.default_timeout_ms); network::set_origin_headers(cmd, url, state).await?; reset_page_state(state); let ff = session(state)?; @@ -124,6 +130,12 @@ pub async fn navigate(cmd: &Value, state: &mut DaemonState) -> Result Date: Fri, 6 Nov 2026 13:10:55 -0800 Subject: [PATCH 15/36] test(firefox): accept the command deadline for idle waits --- cli/src/native/e2e_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index cf4ce7ddcb..853bd5ecae 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16305,7 +16305,11 @@ async fn e2e_firefox_navigate_networkidle_waits_for_delayed_request() { ) .await; assert_eq!(resp["success"], false, "{resp}"); - assert!(resp["error"].as_str().unwrap_or("").contains("networkidle")); + let error = resp["error"].as_str().unwrap_or("").to_lowercase(); + assert!( + error.contains("timeout") || error.contains("timed out"), + "{resp}" + ); firefox_close(&mut state).await; } From 458ec96be10be3d3a7f128ff33c176271336e1ac Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:41:22 -0800 Subject: [PATCH 16/36] fix(firefox): preserve origin auth and overlapping downloads --- cli/src/native/firefox/network.rs | 224 +++++++++++++++++++++++++----- 1 file changed, 186 insertions(+), 38 deletions(-) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index aee041375d..addda0494d 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; @@ -237,6 +237,44 @@ pub async fn ensure_intercept(ff: &mut FirefoxSession) -> Result<(), String> { Ok(()) } +type LegacyDownloadKey = (Option, Option, Option); + +/// Tracks downloads from Firefox builds that report no download id, so each +/// start keeps its own id until the matching end event arrives. +#[derive(Default)] +struct LegacyDownloadIds { + pending: HashMap>, +} + +impl LegacyDownloadIds { + fn key(params: &Value) -> LegacyDownloadKey { + ( + params["context"].as_str().map(str::to_owned), + params["navigation"].as_str().map(str::to_owned), + params["url"].as_str().map(str::to_owned), + ) + } + + fn started(&mut self, params: &Value) -> String { + let id = uuid::Uuid::new_v4().to_string(); + self.pending + .entry(Self::key(params)) + .or_default() + .push_back(id.clone()); + id + } + + fn ended(&mut self, params: &Value) -> Option { + let key = Self::key(params); + let ids = self.pending.get_mut(&key)?; + let id = ids.pop_front(); + if ids.is_empty() { + self.pending.remove(&key); + } + id + } +} + /// Starts the task that resolves every intercepted request exactly once. pub fn spawn_dispatcher( bidi: Arc, @@ -247,7 +285,7 @@ pub fn spawn_dispatcher( ) -> AbortOnDrop { AbortOnDrop(tokio::spawn(async move { let mut emulated_contexts = HashSet::new(); - let mut legacy_downloads = HashMap::new(); + let mut legacy_downloads = LegacyDownloadIds::default(); while let Some(mut e) = rx.recv().await { if e["method"] == "browsingContext.contextCreated" && e["params"]["parent"].is_null() { if let Some(context) = e["params"]["context"].as_str() { @@ -275,26 +313,10 @@ pub fn spawn_dispatcher( ); if is_download { if e["params"]["download"].as_str().is_none() { - let key = ( - e["params"]["context"].as_str().map(str::to_owned), - e["params"]["navigation"].as_str().map(str::to_owned), - e["params"]["url"].as_str().map(str::to_owned), - ); let download = if e["method"] == "browsingContext.downloadWillBegin" { - let (download, pending) = legacy_downloads - .entry(key) - .or_insert_with(|| (uuid::Uuid::new_v4().to_string(), 0usize)); - *pending += 1; - Some(download.clone()) - } else if let Some((download, pending)) = legacy_downloads.get_mut(&key) { - let download = download.clone(); - *pending -= 1; - if *pending == 0 { - legacy_downloads.remove(&key); - } - Some(download) + Some(legacy_downloads.started(&e["params"])) } else { - None + legacy_downloads.ended(&e["params"]) }; if let Some(download) = download { e["params"]["download"] = json!(download); @@ -431,39 +453,51 @@ async fn before_request( Ok(()) } -/// Answers an auth challenge; proxy credentials go only to proxy (407) challenges, never to origins. +/// Answers a proxy auth challenge; origin challenges keep the browser's default handling. async fn auth_required( bidi: &BidiClient, net: &NetContext, id: &str, event: &Value, ) -> Result<(), String> { - let credentials = if event["response"]["status"] == 407 { + let status = event["response"]["status"].as_i64().unwrap_or(0); + let credentials = if status == 407 { net.proxy_credentials.read().await.clone() } else { None }; - let params = match credentials { - Some((username, password)) - if { - let mut tries = net.proxy_auth_requests.write().await; - let n = tries.entry(id.to_string()).or_insert(0); - *n += 1; - *n <= 2 - } => - { - json!({ - "request": id, - "action": "provideCredentials", - "credentials": {"type": "password", "username": username, "password": password}, - }) - } - _ => json!({"request": id, "action": "cancel"}), + let may_retry = if credentials.is_some() { + let mut tries = net.proxy_auth_requests.write().await; + let n = tries.entry(id.to_string()).or_insert(0); + *n += 1; + *n <= 2 + } else { + false }; + let params = auth_response(id, status, credentials, may_retry); bidi.send("network.continueWithAuth", params).await?; Ok(()) } +fn auth_response( + id: &str, + status: i64, + credentials: Option<(String, String)>, + may_retry: bool, +) -> Value { + if status != 407 { + return json!({"request": id, "action": "default"}); + } + match credentials { + Some((username, password)) if may_retry => json!({ + "request": id, + "action": "provideCredentials", + "credentials": {"type": "password", "username": username, "password": password}, + }), + _ => json!({"request": id, "action": "cancel"}), + } +} + /// Drains buffered BiDi events and applies them to the daemon's dialog, network and console state. pub async fn drain(state: &mut DaemonState) { if let Some(ff) = state.firefox.as_mut() { @@ -1207,6 +1241,58 @@ mod tests { writer.await.unwrap(); } + #[test] + fn legacy_download_end_matches_only_the_same_key() { + let mut ids = LegacyDownloadIds::default(); + let start = json!({ + "context": "c", + "navigation": "n1", + "url": "https://example.com/file.bin", + }); + let other = json!({ + "context": "c", + "navigation": "n2", + "url": "https://example.com/file.bin", + }); + let id = ids.started(&start); + assert_eq!(ids.ended(&other), None); + assert_eq!(ids.ended(&start), Some(id)); + } + + #[test] + fn origin_auth_keeps_default_handling_and_ignores_proxy_credentials() { + let response = auth_response( + "request", + 401, + Some(("proxy-user".into(), "proxy-pass".into())), + true, + ); + assert_eq!(response["action"], "default"); + assert!(response.get("credentials").is_none()); + } + + #[test] + fn proxy_auth_still_uses_configured_credentials() { + let response = auth_response( + "request", + 407, + Some(("proxy-user".into(), "proxy-pass".into())), + true, + ); + assert_eq!(response["action"], "provideCredentials"); + assert_eq!(response["credentials"]["username"], "proxy-user"); + assert_eq!(response["credentials"]["password"], "proxy-pass"); + assert_eq!( + auth_response( + "request", + 407, + Some(("proxy-user".into(), "proxy-pass".into())), + false, + )["action"], + "cancel" + ); + } + #[test] fn blocked_domain_fails_even_when_route_matches() { let filter = DomainFilter::new("allowed.example"); @@ -1604,6 +1690,68 @@ mod tests { assert_eq!(second.suggested_filename.as_deref(), Some("second.txt")); } + #[tokio::test] + async fn dispatcher_separates_overlapping_downloads_with_null_navigation() { + let (ff, tx, _) = fake_session().await; + let net = NetContext::default(); + let downloads = net.downloads.clone(); + let _dispatcher = spawn_dispatcher( + ff.bidi.clone(), + ff.rdp.clone(), + net, + ff.bidi.subscribe_buffered(), + ff.emulation.clone(), + ); + for filename in ["first.txt", "second.txt"] { + tx.send(json!({ + "type": "event", + "method": "browsingContext.downloadWillBegin", + "params": { + "context": "ctx-active", + "navigation": null, + "url": "http://example.com/download", + "suggestedFilename": filename, + }, + })) + .unwrap(); + } + for filename in ["first.txt", "second.txt"] { + tx.send(json!({ + "type": "event", + "method": "browsingContext.downloadEnd", + "params": { + "context": "ctx-active", + "navigation": null, + "url": "http://example.com/download", + "status": "complete", + "filepath": format!("/downloads/{filename}"), + }, + })) + .unwrap(); + } + let claimed = tokio::time::timeout(Duration::from_secs(5), async { + let mut claimed = Vec::new(); + loop { + let mut ledger = downloads.lock().await; + if let Some(record) = ledger.claim_finished_since(0, &[]) { + claimed.push(record); + if claimed.len() == 2 { + break claimed; + } + } + drop(ledger); + tokio::task::yield_now().await; + } + }) + .await + .expect("dispatcher should record both overlapping downloads"); + assert_eq!(claimed[0].suggested_filename.as_deref(), Some("first.txt")); + assert_eq!( + claimed[1].suggested_filename.as_deref(), + Some("second.txt") + ); + } + #[tokio::test] async fn har_captures_request_body_and_bidi_timestamps() { let (ff, _, _) = crate::native::firefox::session::tests::fake_session_with(|v| { From 33095926bd1d90407bb03ec41a16c11c2e12aaee Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:43:55 -0800 Subject: [PATCH 17/36] test(firefox): check origin auth without blocking navigation --- cli/src/native/e2e_tests.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 7b59e25251..eff695f3fe 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16376,7 +16376,16 @@ async fn e2e_firefox_proxy_credentials_not_sent_to_origin() { } }), ) .await; - firefox_open(&mut state, &format!("{}auth", fixture.url)).await; + firefox_open(&mut state, &fixture.url).await; + let request = format!( + "let image = document.createElement('img'); image.src = '{}auth'; document.body.append(image); 'started'", + fixture.url + ); + assert_eq!(firefox_eval(&mut state, &request).await, json!("started")); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while fixture.hit_count("/auth") == 0 && tokio::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } assert!(fixture.hit_count("/auth") > 0, "origin must be reached"); assert_eq!( fixture.hit_count("#authorization"), From e0de4f4602724aac116e4b2e69c232596679cfaa Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:44:07 -0800 Subject: [PATCH 18/36] fix(firefox): initialize the current HAR entry fields --- cli/src/native/firefox/network.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index addda0494d..de628c3f1e 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -551,6 +551,7 @@ fn request_started(state: &mut DaemonState, p: &Value) { if let Some(timestamp) = timestamp.filter(|_| state.har_recording) { state.har_entries.push(HarEntry { request_id: id.clone(), + remote_addr: None, wall_time: timestamp / 1000.0, method: method.clone(), url: url.clone(), From 26502cb35292d9cba904f1d4e0a81430ca3c62df Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:45:29 -0800 Subject: [PATCH 19/36] style(firefox): format the overlapping download assertion --- cli/src/native/firefox/network.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index de628c3f1e..e12b55ab9a 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -1747,10 +1747,7 @@ mod tests { .await .expect("dispatcher should record both overlapping downloads"); assert_eq!(claimed[0].suggested_filename.as_deref(), Some("first.txt")); - assert_eq!( - claimed[1].suggested_filename.as_deref(), - Some("second.txt") - ); + assert_eq!(claimed[1].suggested_filename.as_deref(), Some("second.txt")); } #[tokio::test] From 44d6661106cbd3aab37a6e453367de1e505f8527 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:28:57 -0800 Subject: [PATCH 20/36] fix(firefox): scope networkidle waits to the active tab --- cli/src/native/e2e_tests.rs | 68 +++++++++++++++++ cli/src/native/firefox/network.rs | 65 +++++++--------- cli/src/native/firefox/session.rs | 121 ++++++++++++++++++++++++++++-- 3 files changed, 210 insertions(+), 44 deletions(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index eff695f3fe..f729c76da8 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16254,6 +16254,74 @@ async fn e2e_firefox_route_mock() { firefox_close(&mut state).await; } +#[tokio::test] +#[ignore = "requires FIREFOX_BIN; run serially"] +async fn e2e_firefox_networkidle_is_scoped_to_the_active_tab() { + required_firefox_binary(); + let tmp = tempfile::tempdir().unwrap(); + let _env = firefox_test_environment(tmp.path()); + let fixture = firefox_fixture().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let pending_url = format!("http://{}/pending", listener.local_addr().unwrap()); + let (started, accepted) = tokio::sync::oneshot::channel(); + let pending = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buf = [0; 4096]; + stream.read(&mut buf).await.unwrap(); + let _ = started.send(()); + while stream.read(&mut buf).await.unwrap_or(0) != 0 {} + }); + let mut state = DaemonState::new(); + launch_firefox(&mut state, json!({})).await; + firefox_open(&mut state, &fixture.url).await; + firefox_eval( + &mut state, + &format!("window.pollController = new AbortController(); fetch('{pending_url}', {{mode:'no-cors', signal:pollController.signal}}).catch(() => {{}}); true"), + ) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), accepted) + .await + .unwrap() + .unwrap(); + assert_success( + &firefox_command( + &mut state, + json!({"id":"new", "action":"tab_new", "url":fixture.url}), + ) + .await, + ); + assert_success( + &firefox_command( + &mut state, + json!({"id":"idle", "action":"waitforloadstate", "state":"networkidle", "timeout":1500}), + ) + .await, + ); + assert_success( + &firefox_command( + &mut state, + json!({"id":"switch", "action":"tab_switch", "tabId":"t1"}), + ) + .await, + ); + let busy = firefox_command( + &mut state, + json!({"id":"busy", "action":"waitforloadstate", "state":"networkidle", "timeout":700}), + ) + .await; + assert_eq!(busy["success"], false, "{busy}"); + firefox_eval(&mut state, "pollController.abort(); true").await; + assert_success( + &firefox_command( + &mut state, + json!({"id":"aborted", "action":"waitforloadstate", "state":"networkidle", "timeout":2000}), + ) + .await, + ); + firefox_close(&mut state).await; + pending.abort(); +} + #[tokio::test] #[ignore = "requires FIREFOX_BIN; run serially"] async fn e2e_firefox_navigate_networkidle_waits_for_delayed_request() { diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index e12b55ab9a..7e185363bd 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -514,17 +514,9 @@ async fn apply_events(state: &mut DaemonState, events: Vec) { Some("network.responseStarted") => response_seen(state, p, true), Some("network.responseCompleted") => { response_seen(state, p, false); - if let Some(ff) = state.firefox.as_mut() { - ff.inflight.remove(request_id(p)); - } request_body(state, request_id(p)).await; har_body(state, request_id(p)).await; } - Some("network.fetchError") => { - if let Some(ff) = state.firefox.as_mut() { - ff.inflight.remove(request_id(p)); - } - } Some("log.entryAdded") => log_entry(state, p), _ => {} } @@ -540,11 +532,6 @@ fn request_started(state: &mut DaemonState, p: &Value) { let id = request_id(p).to_string(); let url = request["url"].as_str().unwrap_or("").to_string(); let method = request["method"].as_str().unwrap_or("GET").to_string(); - if !is_websocket(&url) { - if let Some(ff) = state.firefox.as_mut() { - ff.inflight.insert(id.clone()); - } - } let timestamp = p["timestamp"].as_f64(); let millis = timestamp.map(|t| t as u64).unwrap_or_default(); let resource_type = resource_type(request).to_string(); @@ -776,7 +763,7 @@ fn log_entry(state: &mut DaemonState, p: &Value) { } } -/// Waits until no tracked request has been in flight for 500ms; `Err` on timeout. +/// Waits until the active tab and its frames have no unfinished requests for 500ms. pub async fn wait_idle(state: &mut DaemonState, timeout_ms: u64) -> Result<(), ()> { let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); let mut idle_since: Option = None; @@ -785,7 +772,7 @@ pub async fn wait_idle(state: &mut DaemonState, timeout_ms: u64) -> Result<(), ( let busy = state .firefox .as_ref() - .is_some_and(|ff| !ff.inflight.is_empty()); + .is_some_and(FirefoxSession::has_inflight_in_active_tab); let now = tokio::time::Instant::now(); if busy { idle_since = None; @@ -1568,6 +1555,7 @@ mod tests { "method": method, "params": { "isBlocked": blocked, + "context": "ctx-active", "timestamp": 1000, "request": {"request": id, "url": url, "method": "GET", "headers": []}, "response": {"status": 200, "headers": []}, @@ -1864,30 +1852,29 @@ mod tests { #[tokio::test] async fn in_flight_set_ignores_websockets_and_clears_on_completion() { - let (ff, _tx, _) = fake_session().await; - let mut state = DaemonState::new(); - state.firefox = Some(ff); - let started = "network.beforeRequestSent"; - apply_events( - &mut state, - vec![ - request_event(started, "a", "http://example.com/", false), - request_event(started, "b", "ws://example.com/socket", false), - ], - ) - .await; - assert_eq!(state.firefox.as_ref().unwrap().inflight.len(), 1); - apply_events( - &mut state, - vec![request_event( - "network.responseCompleted", - "a", - "http://example.com/", - false, - )], - ) - .await; - assert!(state.firefox.as_ref().unwrap().inflight.is_empty()); + let (mut ff, tx, _) = fake_session().await; + let mut received = ff.bidi.subscribe_buffered(); + for (id, url) in [ + ("a", "http://example.com/"), + ("b", "ws://example.com/socket"), + ] { + tx.send(request_event("network.beforeRequestSent", id, url, false)) + .unwrap(); + } + received.recv().await.unwrap(); + received.recv().await.unwrap(); + ff.drain_events(&mut None); + assert_eq!(ff.inflight.len(), 1); + tx.send(request_event( + "network.responseCompleted", + "a", + "http://example.com/", + false, + )) + .unwrap(); + received.recv().await.unwrap(); + ff.drain_events(&mut None); + assert!(ff.inflight.is_empty()); } #[test] diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index 7a40b981f7..8b2b15e754 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -63,8 +63,8 @@ pub struct FirefoxSession { pub net: NetContext, /// True once the single global network intercept is installed; it is never installed twice. pub intercepting: bool, - /// Ids of requests that have started and not yet finished or failed. - pub inflight: HashSet, + /// Unfinished requests mapped to their reported browsing contexts. + pub inflight: HashMap>, pub(crate) dispatcher: Option, pub visited_origins: HashSet, } @@ -242,7 +242,7 @@ impl FirefoxSession { events, net, intercepting: false, - inflight: HashSet::new(), + inflight: HashMap::new(), dispatcher, visited_origins: HashSet::new(), }; @@ -259,6 +259,14 @@ impl FirefoxSession { self.note_visit(url); } if let Some(children) = context["children"].as_array() { + if let Some(parent) = context["context"].as_str() { + for child in children { + if let Some(child) = child["context"].as_str() { + self.context_parents + .insert(child.to_string(), parent.to_string()); + } + } + } self.record_context_origins(children); } } @@ -304,6 +312,7 @@ impl FirefoxSession { pub fn drain_events(&mut self, dialog: &mut Option) -> Vec { let mut drained = Vec::new(); while let Ok(e) = self.events.try_recv() { + self.track_request(&e); if e["method"] == "browsingContext.contextDestroyed" && e["params"]["context"] == self.top_context { @@ -323,6 +332,48 @@ impl FirefoxSession { drained } + /// Tracks request lifetimes and removes requests belonging to destroyed contexts. + pub(super) fn track_request(&mut self, event: &Value) { + let params = &event["params"]; + let id = params["request"]["request"].as_str(); + match event["method"].as_str() { + Some("network.beforeRequestSent") => { + let url = params["request"]["url"].as_str().unwrap_or(""); + if !url.starts_with("ws:") && !url.starts_with("wss:") { + if let Some(id) = id { + self.inflight.insert( + id.to_string(), + params["context"].as_str().map(str::to_string), + ); + } + } + } + Some("network.responseCompleted" | "network.fetchError") => { + if let Some(id) = id { + self.inflight.remove(id); + } + } + Some("browsingContext.contextDestroyed") => { + if let Some(context) = params["context"].as_str() { + self.inflight.retain(|_, owner| { + !owner.as_deref().is_some_and(|owner| { + context_contains(context, owner, &self.context_parents) + }) + }); + } + } + _ => {} + } + } + + /// True while the active tab or one of its descendant frames has an unfinished request. + pub fn has_inflight_in_active_tab(&self) -> bool { + self.inflight + .values() + .filter_map(|context| context.as_deref()) + .any(|context| context_contains(&self.top_context, context, &self.context_parents)) + } + /// Runs `fut` until it finishes or a prompt opens in `context` or its owning tab. Returns `None` for the prompt. pub async fn race_prompt( &self, @@ -455,6 +506,22 @@ fn owner_context<'a>(mut context: &'a str, parents: &'a HashMap) context } +fn context_contains<'a>( + root: &str, + mut context: &'a str, + parents: &'a HashMap, +) -> bool { + loop { + if root == context { + return true; + } + match parents.get(context) { + Some(parent) => context = parent, + None => return false, + } + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -592,7 +659,7 @@ pub(crate) mod tests { events, net: NetContext::default(), intercepting: false, - inflight: HashSet::new(), + inflight: HashMap::new(), dispatcher: None, }; (session, tx, calls) @@ -722,7 +789,7 @@ pub(crate) mod tests { visited_origins: HashSet::new(), net: NetContext::default(), intercepting: false, - inflight: HashSet::new(), + inflight: HashMap::new(), dispatcher: None, }; (session, tx, calls, bidi_messages, rdp_messages) @@ -816,6 +883,50 @@ pub(crate) mod tests { assert!(d.is_none()); } + #[tokio::test] + async fn inflight_requests_follow_tab_ancestry_and_end_on_completion_error_or_destroy() { + let (mut session, _, _) = fake_session().await; + session.record_context_origins(&[json!({ + "context": "ctx-active", + "children": [{"context": "frame", "children": [{"context": "nested"}]}], + })]); + for (id, context) in [("background", Some("other")), ("unknown", None)] { + session.track_request(&json!({ + "method": "network.beforeRequestSent", + "params": {"context": context, "request": {"request": id, "url": "http://example.com/"}}, + })); + } + assert!(!session.has_inflight_in_active_tab()); + session.top_context = "other".into(); + assert!(session.has_inflight_in_active_tab()); + session.track_request(&json!({ + "method": "network.fetchError", + "params": {"request": {"request": "background"}}, + })); + assert!(!session.has_inflight_in_active_tab()); + session.top_context = "ctx-active".into(); + session.context = "frame".into(); + for id in ["completed", "destroyed"] { + session.track_request(&json!({ + "method": "network.beforeRequestSent", + "params": {"context": "nested", "request": {"request": id, "url": "http://example.com/"}}, + })); + } + assert!(session.has_inflight_in_active_tab()); + session.track_request(&json!({ + "method": "network.responseCompleted", + "params": {"request": {"request": "completed"}}, + })); + assert!(!session.inflight.contains_key("completed")); + assert!(session.has_inflight_in_active_tab()); + session.track_request(&json!({ + "method": "browsingContext.contextDestroyed", + "params": {"context": "frame"}, + })); + assert!(!session.inflight.contains_key("destroyed")); + assert!(!session.has_inflight_in_active_tab()); + } + #[tokio::test] async fn race_prompt_returns_none_when_a_prompt_opens_on_the_active_context() { let (mut s, tx, _) = fake_session().await; From a2ae6d6ffd74253ee7a2919876c0f0acb4c72953 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:13:34 -0800 Subject: [PATCH 21/36] Reuse the shared BiDi client in the fake Firefox session --- cli/src/native/firefox/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index 66d4a10e0c..41a6d97e37 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -684,7 +684,7 @@ pub(crate) mod tests { let session = FirefoxSession { process: None, headless: true, - bidi: Arc::new(bidi), + bidi, auto_dialog: Arc::new(AtomicBool::new(false)), auto_dialog_changed: Arc::new(Notify::new()), rdp, From 71cf692d611ce3c33cde37c01f905a0c92e440d8 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:09:23 -0800 Subject: [PATCH 22/36] Keep Firefox media emulation on the active tab --- cli/src/native/firefox/emulation.rs | 9 +++++---- cli/src/native/firefox/tabs.rs | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/src/native/firefox/emulation.rs b/cli/src/native/firefox/emulation.rs index ed0f1c701a..57f442d8bd 100644 --- a/cli/src/native/firefox/emulation.rs +++ b/cli/src/native/firefox/emulation.rs @@ -205,7 +205,11 @@ pub(crate) async fn update_tab_configuration( /// Replays every stored setting onto `context`, used for tabs the daemon creates. pub async fn apply_to_context(ff: &FirefoxSession, context: &str) -> Result<(), String> { let settings = ff.emulation.read().unwrap().clone(); - apply_settings_to_context(&ff.bidi, &ff.rdp, &settings, context).await + apply_settings_to_context(&ff.bidi, &ff.rdp, &settings, context).await?; + if let Some(ref media) = settings.media { + update_tab_configuration(&ff.bidi, &ff.rdp, context, &media_config(media)?).await?; + } + Ok(()) } pub(crate) async fn apply_settings_to_context( @@ -232,9 +236,6 @@ pub(crate) async fn apply_settings_to_context( if let Some(offline) = settings.offline { send_scoped_bidi(bidi, context, self::offline(offline)).await?; } - if let Some(ref media) = settings.media { - update_tab_configuration(bidi, rdp, context, &media_config(media)?).await?; - } Ok(()) } diff --git a/cli/src/native/firefox/tabs.rs b/cli/src/native/firefox/tabs.rs index 232e9dac61..e5e6d1ce42 100644 --- a/cli/src/native/firefox/tabs.rs +++ b/cli/src/native/firefox/tabs.rs @@ -175,6 +175,7 @@ pub async fn tab_switch(cmd: &Value, state: &mut DaemonState) -> Result Date: Fri, 6 Nov 2026 21:09:50 -0800 Subject: [PATCH 23/36] Test Firefox media emulation does not select background tabs --- cli/src/native/e2e_tests.rs | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 919a41b1e9..b6f1ca4fa7 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16802,6 +16802,52 @@ async fn e2e_firefox_emulation() { .await, json!(true) ); + + let original = state.firefox.as_ref().unwrap().top_context.clone(); + let created = state + .firefox + .as_ref() + .unwrap() + .bidi + .send( + "browsingContext.create", + json!({"type": "tab", "background": true}), + ) + .await + .unwrap(); + let background = created["context"].as_str().unwrap().to_string(); + tokio::time::sleep(tokio::time::Duration::from_millis(250)).await; + let tabs = state + .firefox + .as_ref() + .unwrap() + .rdp + .request("root", "listTabs", json!({})) + .await + .unwrap(); + assert!(tabs["tabs"] + .as_array() + .unwrap() + .iter() + .any(|tab| tab["selected"] == true && tab["url"] == fixture.url)); + assert_eq!(state.firefox.as_ref().unwrap().top_context, original); + + assert_success( + &firefox_command( + &mut state, + json!({"id": "background", "action": "tab_switch", "tabId": background}), + ) + .await, + ); + firefox_open(&mut state, &fixture.other).await; + assert_eq!( + firefox_eval( + &mut state, + "matchMedia('(prefers-color-scheme: dark)').matches" + ) + .await, + json!(true) + ); firefox_close(&mut state).await; } From cd0fd174b3710ccdd511887a98de2a8956637874 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:14:16 -0800 Subject: [PATCH 24/36] Remove the unused RDP argument from background emulation --- cli/src/native/firefox/emulation.rs | 3 +-- cli/src/native/firefox/network.rs | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cli/src/native/firefox/emulation.rs b/cli/src/native/firefox/emulation.rs index 57f442d8bd..5be9077eea 100644 --- a/cli/src/native/firefox/emulation.rs +++ b/cli/src/native/firefox/emulation.rs @@ -205,7 +205,7 @@ pub(crate) async fn update_tab_configuration( /// Replays every stored setting onto `context`, used for tabs the daemon creates. pub async fn apply_to_context(ff: &FirefoxSession, context: &str) -> Result<(), String> { let settings = ff.emulation.read().unwrap().clone(); - apply_settings_to_context(&ff.bidi, &ff.rdp, &settings, context).await?; + apply_settings_to_context(&ff.bidi, &settings, context).await?; if let Some(ref media) = settings.media { update_tab_configuration(&ff.bidi, &ff.rdp, context, &media_config(media)?).await?; } @@ -214,7 +214,6 @@ pub async fn apply_to_context(ff: &FirefoxSession, context: &str) -> Result<(), pub(crate) async fn apply_settings_to_context( bidi: &BidiClient, - rdp: &RdpClient, settings: &Settings, context: &str, ) -> Result<(), String> { diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index 7e185363bd..cb88fa902b 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -291,10 +291,9 @@ pub fn spawn_dispatcher( if let Some(context) = e["params"]["context"].as_str() { if emulated_contexts.insert(context.to_string()) { let settings = emulation.read().unwrap().clone(); - if let Err(error) = super::emulation::apply_settings_to_context( - &bidi, &rdp, &settings, context, - ) - .await + if let Err(error) = + super::emulation::apply_settings_to_context(&bidi, &settings, context) + .await { eprintln!( "Warning: failed to apply Firefox emulation to {context}: {error}" From 16636026b289aa27f65a12e02332beaa9f71471d Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:26:17 -0800 Subject: [PATCH 25/36] Remove the unused RDP dispatcher dependency --- cli/src/native/firefox/network.rs | 4 ---- cli/src/native/firefox/session.rs | 1 - 2 files changed, 5 deletions(-) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index cb88fa902b..8f74cbef09 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -278,7 +278,6 @@ impl LegacyDownloadIds { /// Starts the task that resolves every intercepted request exactly once. pub fn spawn_dispatcher( bidi: Arc, - rdp: super::rdp::RdpClient, net: NetContext, mut rx: tokio::sync::mpsc::Receiver, emulation: Arc>, @@ -1571,7 +1570,6 @@ mod tests { *net.domain_filter.write().await = Some(DomainFilter::new("allowed.example")); let _dispatcher = spawn_dispatcher( ff.bidi.clone(), - ff.rdp.clone(), net, ff.bidi.subscribe_buffered(), ff.emulation.clone(), @@ -1624,7 +1622,6 @@ mod tests { let downloads = net.downloads.clone(); let _dispatcher = spawn_dispatcher( ff.bidi.clone(), - ff.rdp.clone(), net, ff.bidi.subscribe_buffered(), ff.emulation.clone(), @@ -1685,7 +1682,6 @@ mod tests { let downloads = net.downloads.clone(); let _dispatcher = spawn_dispatcher( ff.bidi.clone(), - ff.rdp.clone(), net, ff.bidi.subscribe_buffered(), ff.emulation.clone(), diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index 41a6d97e37..681b2c5849 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -213,7 +213,6 @@ impl FirefoxSession { })); let dispatcher = Some(super::network::spawn_dispatcher( bidi.clone(), - rdp.clone(), net.clone(), dispatch_events, emulation.clone(), From 18025d7227370ac5b22cd93cba8d7ccf36118cb2 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:30:29 -0800 Subject: [PATCH 26/36] Scope Firefox request recording to the active tab --- cli/src/native/firefox/network.rs | 43 +++++++++++++++++++++++++++++++ cli/src/native/firefox/session.rs | 4 +++ 2 files changed, 47 insertions(+) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index 8f74cbef09..74661b660d 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -526,6 +526,15 @@ fn request_id(p: &Value) -> &str { } fn request_started(state: &mut DaemonState, p: &Value) { + if let Some(context) = p["context"].as_str() { + let active = state + .firefox + .as_ref() + .is_some_and(|ff| ff.context_is_in_active_tab(context)); + if !active { + return; + } + } let request = &p["request"]; let id = request_id(p).to_string(); let url = request["url"].as_str().unwrap_or("").to_string(); @@ -1845,6 +1854,40 @@ mod tests { ); } + #[tokio::test] + async fn request_recording_excludes_background_tabs() { + let (mut ff, _, _) = crate::native::firefox::session::tests::fake_session_with(|_| { + json!({"bytes": {"type": "string", "value": "body"}}) + }) + .await; + ff.top_context = "active".into(); + ff.context = "active".into(); + let mut state = DaemonState::new(); + state.firefox = Some(ff); + state.request_tracking = true; + state.har_recording = true; + + for (id, context) in [("active", "active"), ("frame", "frame"), ("other", "other")] { + apply_events( + &mut state, + vec![json!({ + "method": "network.beforeRequestSent", + "params": { + "context": context, + "timestamp": 1000, + "request": {"request": id, "url": format!("https://{context}.test/"), "method": "GET", "headers": []}, + }, + })], + ) + .await; + } + + assert_eq!(state.tracked_requests.len(), 1); + assert_eq!(state.tracked_requests[0].request_id, "active"); + assert_eq!(state.har_entries.len(), 1); + assert_eq!(state.har_entries[0].request_id, "active"); + } + #[tokio::test] async fn in_flight_set_ignores_websockets_and_clears_on_completion() { let (mut ff, tx, _) = fake_session().await; diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index 681b2c5849..6d29dbb0e0 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -379,6 +379,10 @@ impl FirefoxSession { .any(|context| context_contains(&self.top_context, context, &self.context_parents)) } + pub(crate) fn context_is_in_active_tab(&self, context: &str) -> bool { + owner_context(context, &self.context_parents) == self.top_context + } + /// Runs `fut` until it finishes or a prompt opens in `context` or its owning tab. Returns `None` for the prompt. pub async fn race_prompt( &self, From ae6059f72753d7f22ca4e7bd92fc44272d923ecf Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:30:40 -0800 Subject: [PATCH 27/36] Cover active child frames in request scope test --- cli/src/native/firefox/network.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index 74661b660d..9b4690117d 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -1862,6 +1862,7 @@ mod tests { .await; ff.top_context = "active".into(); ff.context = "active".into(); + ff.context_parents.insert("frame".into(), "active".into()); let mut state = DaemonState::new(); state.firefox = Some(ff); state.request_tracking = true; @@ -1882,10 +1883,12 @@ mod tests { .await; } - assert_eq!(state.tracked_requests.len(), 1); + assert_eq!(state.tracked_requests.len(), 2); assert_eq!(state.tracked_requests[0].request_id, "active"); - assert_eq!(state.har_entries.len(), 1); + assert_eq!(state.tracked_requests[1].request_id, "frame"); + assert_eq!(state.har_entries.len(), 2); assert_eq!(state.har_entries[0].request_id, "active"); + assert_eq!(state.har_entries[1].request_id, "frame"); } #[tokio::test] From b8dad122f5f6cba4a1a15fcd4d21f2c2eb006c32 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:30:53 -0800 Subject: [PATCH 28/36] Prevent stale proxy credentials from reaching a new daemon --- cli/src/connection.rs | 22 ++++++++++++++++------ cli/src/native/actions.rs | 26 -------------------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/cli/src/connection.rs b/cli/src/connection.rs index dd2060f59c..ecc8de96d3 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -592,16 +592,26 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { } if let Some(p) = opts.proxy { cmd.env("AGENT_BROWSER_PROXY", p); + match opts.proxy_username { + Some(proxy_username) => { + cmd.env("AGENT_BROWSER_PROXY_USERNAME", proxy_username); + } + None => { + cmd.env_remove("AGENT_BROWSER_PROXY_USERNAME"); + } + } + match opts.proxy_password { + Some(proxy_password) => { + cmd.env("AGENT_BROWSER_PROXY_PASSWORD", proxy_password); + } + None => { + cmd.env_remove("AGENT_BROWSER_PROXY_PASSWORD"); + } + } } if let Some(pb) = opts.proxy_bypass { cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb); } - if let Some(pu) = opts.proxy_username { - cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu); - } - if let Some(pp) = opts.proxy_password { - cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp); - } if opts.ignore_https_errors { cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 4b1bb05748..174d1d8371 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -6322,18 +6322,6 @@ fn attach_settings_outcome(resp: &mut Value, outcome: &SettingsOutcome) { } } -fn should_use_env_proxy_credentials( - command_proxy: Option<&Value>, - env_proxy: Option<&str>, -) -> bool { - command_proxy.is_none_or(|proxy| { - proxy - .as_str() - .or_else(|| proxy.get("server").and_then(Value::as_str)) - .is_some_and(|server| Some(server) == env_proxy) - }) -} - async fn handle_launch_inner(cmd: &Value, state: &mut DaemonState) -> Result { let engine = cmd .get("engine") @@ -19222,20 +19210,6 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"data":{}}' assert!(!opts.restrict_webrtc); } - #[test] - fn launch_proxy_uses_env_credentials_only_for_the_same_server() { - let proxy = json!({ "server": "http://proxy.example:8080" }); - assert!(super::should_use_env_proxy_credentials( - Some(&proxy), - Some("http://proxy.example:8080") - )); - assert!(!super::should_use_env_proxy_credentials( - Some(&proxy), - Some("http://other-proxy.example:8080") - )); - assert!(super::should_use_env_proxy_credentials(None, None)); - } - #[test] fn test_launch_options_from_env_headed_flag() { let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]); From 9978f37a9c1a8f3ac3d95a4ca8d239df67052f8d Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:30:58 -0800 Subject: [PATCH 29/36] Test proxy credential isolation for daemon launches --- cli/src/connection.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cli/src/connection.rs b/cli/src/connection.rs index ecc8de96d3..e1bea5d140 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -1793,6 +1793,27 @@ mod tests { ); } + #[test] + fn daemon_proxy_credentials_are_scoped_to_the_effective_proxy() { + let mut opts = test_daemon_options(None, false, None); + opts.proxy = Some("http://new-proxy:8080"); + let mut cmd = Command::new("agent-browser"); + apply_daemon_env(&mut cmd, "session", &opts); + let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + assert_eq!( + envs.get(std::ffi::OsStr::new("AGENT_BROWSER_PROXY")), + Some(&Some(std::ffi::OsStr::new("http://new-proxy:8080"))) + ); + assert_eq!( + envs.get(std::ffi::OsStr::new("AGENT_BROWSER_PROXY_USERNAME")), + Some(&None) + ); + assert_eq!( + envs.get(std::ffi::OsStr::new("AGENT_BROWSER_PROXY_PASSWORD")), + Some(&None) + ); + } + #[test] fn test_spawn_race_loser_does_not_overwrite_winner_config() { let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_NAMESPACE"]); From a9286969b7cd2fa92f49000fe019916f6473f286 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:46:49 -0800 Subject: [PATCH 30/36] Format the Firefox request scope regression --- cli/src/native/firefox/network.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index 9b4690117d..b9d0a8f7ff 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -1856,9 +1856,9 @@ mod tests { #[tokio::test] async fn request_recording_excludes_background_tabs() { - let (mut ff, _, _) = crate::native::firefox::session::tests::fake_session_with(|_| { - json!({"bytes": {"type": "string", "value": "body"}}) - }) + let (mut ff, _, _) = crate::native::firefox::session::tests::fake_session_with( + |_| json!({"bytes": {"type": "string", "value": "body"}}), + ) .await; ff.top_context = "active".into(); ff.context = "active".into(); From 9228a854673e9ace95e2482906ca5e30f2c5e54b Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:10:13 -0800 Subject: [PATCH 31/36] Expose Firefox context parentage to network tests --- cli/src/native/firefox/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index bc891539fe..991d6564a0 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -45,7 +45,7 @@ pub struct FirefoxSession { pub top_context: String, /// Stable `t` ids keyed by BiDi context; assigned on first sight and never reused. pub tab_ids: HashMap, - context_parents: HashMap, + pub(crate) context_parents: HashMap, next_tab_id: u32, /// Durable ref ids keyed by `(context, sharedId)`; reset when a document changes. pub refs: HashMap<(String, String), i64>, From 8a0febdccf7a28e91ef6504b7eeb7b4c5988985c Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:49:32 -0800 Subject: [PATCH 32/36] Allow Firefox accessibility snapshots more time in CI --- cli/src/native/firefox/rdp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/native/firefox/rdp.rs b/cli/src/native/firefox/rdp.rs index e13245d83a..f85b7c49d2 100644 --- a/cli/src/native/firefox/rdp.rs +++ b/cli/src/native/firefox/rdp.rs @@ -10,7 +10,7 @@ use tokio::io::{ }; use tokio::sync::{broadcast, oneshot}; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(90); type Pending = Arc)>>>>; From 367ff1ed4000312743e0a32b25b3777a4d9322bc Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:09:10 -0800 Subject: [PATCH 33/36] fix(firefox): connect to DevTools once on launch The launch path opened a second RDP connection and enabled accessibility again, left over from a merge. Drop the duplicate block. --- cli/src/native/firefox/session.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/cli/src/native/firefox/session.rs b/cli/src/native/firefox/session.rs index 8e43973288..7d9ab41256 100644 --- a/cli/src/native/firefox/session.rs +++ b/cli/src/native/firefox/session.rs @@ -186,15 +186,6 @@ impl FirefoxSession { ) .await?; - let rdp = connect_devtools(&process.devtools).await?; - let root = rdp.request("root", "getRoot", json!({})).await?; - let accessibility_actor = root["parentAccessibilityActor"] - .as_str() - .ok_or("Firefox DevTools did not expose the accessibility actor")? - .to_string(); - rdp.request(&accessibility_actor, "enable", json!({})) - .await?; - let rdp = connect_devtools(&process.devtools).await?; let root = rdp.request("root", "getRoot", json!({})).await?; let accessibility_actor = root["parentAccessibilityActor"] From 1832f15da5a56d6e922b296f79c80d058b1fd589 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:39:36 -0800 Subject: [PATCH 34/36] Revert the longer Firefox accessibility timeout The 90 second RDP timeout only hid the hang caused by the duplicate DevTools connection, which is fixed. Go back to 30 seconds so a real hang still fails fast. --- cli/src/native/firefox/rdp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/native/firefox/rdp.rs b/cli/src/native/firefox/rdp.rs index f85b7c49d2..e13245d83a 100644 --- a/cli/src/native/firefox/rdp.rs +++ b/cli/src/native/firefox/rdp.rs @@ -10,7 +10,7 @@ use tokio::io::{ }; use tokio::sync::{broadcast, oneshot}; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(90); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); type Pending = Arc)>>>>; From f8aabc668f3f886de00ed6b03489a3e7255b7382 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:16:50 -0800 Subject: [PATCH 35/36] refactor(firefox): one intake for recorded events, one allowlist entry point, one emulation applier --- cli/src/native/actions.rs | 33 ++--- cli/src/native/e2e_tests.rs | 6 + cli/src/native/firefox/emulation.rs | 9 +- cli/src/native/firefox/network.rs | 135 +++++++++++------- cli/src/native/firefox/page.rs | 72 +++++++--- cli/src/native/firefox/session.rs | 61 ++++++-- cli/src/native/firefox/storage.rs | 10 +- cli/src/native/firefox/tabs.rs | 21 +-- cli/src/native/network.rs | 34 +++++ docs/content/docs/security.mdx | 2 +- skill-data/core/references/commands.md | 2 +- .../core/references/trust-boundaries.md | 2 +- 12 files changed, 261 insertions(+), 126 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 7986b32c1a..11a176390b 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -916,6 +916,11 @@ impl DaemonState { self.mouse_state = MouseState::default(); } + /// Rejects a navigation to a URL outside the allowed domains; `about:blank` stays reachable. + pub async fn ensure_navigation_allowed(&self, url: &str) -> Result<(), String> { + network::check_navigation_allowed(self.domain_filter.read().await.as_ref(), url) + } + /// Create state with an optional stream client slot and server instance /// (for daemon startup with stream server). pub fn new_with_stream( @@ -4200,18 +4205,6 @@ fn should_blank_existing_url(url: &str, filter: &DomainFilter) -> bool { .unwrap_or(false) } -pub(super) fn check_url_allowed_by_filter( - filter: Option<&DomainFilter>, - url: &str, -) -> Result<(), String> { - if let Some(filter) = filter { - if url != "about:blank" { - filter.check_url(url)?; - } - } - Ok(()) -} - fn network_controls_required(filter: Option<&DomainFilter>, handle_auth_requests: bool) -> bool { filter.is_some() || handle_auth_requests } @@ -4225,7 +4218,7 @@ fn should_defer_url_until_network_controls( return Ok(false); }; - check_url_allowed_by_filter(filter, url)?; + network::check_navigation_allowed(filter, url)?; Ok(filter.is_some_and(|filter| !filter.allowed_domains.is_empty()) || handle_auth_requests) } @@ -6463,12 +6456,7 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result Result< { let domain_filter = state.domain_filter.read().await; if let Some(url) = recording_url { - check_url_allowed_by_filter(domain_filter.as_ref(), url)?; + network::check_navigation_allowed(domain_filter.as_ref(), url)?; } } @@ -9306,7 +9294,7 @@ async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Resul { let domain_filter = state.domain_filter.read().await; if let Some(ref url) = recording_url { - check_url_allowed_by_filter(domain_filter.as_ref(), url)?; + network::check_navigation_allowed(domain_filter.as_ref(), url)?; } } @@ -13504,6 +13492,9 @@ fn unix_timestamp_millis() -> u128 { } async fn har_browser_metadata(state: &DaemonState) -> Option { + if let Some(ff) = state.firefox.as_ref() { + return Some(json!({ "name": "Firefox", "version": ff.browser_version })); + } let mgr = state.browser.as_ref()?; if !mgr.is_connection_alive().await { return None; diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 84f39ee193..48ad4b4dce 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -16523,6 +16523,12 @@ async fn e2e_firefox_allowed_domains_block_worker_and_websocket() { ) .await; assert_eq!(file["success"], false, "{file}"); + let blank = firefox_command( + &mut state, + json!({ "id": "blank", "action": "navigate", "url": "about:blank" }), + ) + .await; + assert_eq!(blank["success"], true, "{blank}"); let target = json!({ "id": "tab", "action": "tab_new", "url": &fixture.other }); assert_eq!(firefox_command(&mut state, target).await["success"], false); firefox_open(&mut state, &fixture.url).await; diff --git a/cli/src/native/firefox/emulation.rs b/cli/src/native/firefox/emulation.rs index 5be9077eea..bd9b1c6964 100644 --- a/cli/src/native/firefox/emulation.rs +++ b/cli/src/native/firefox/emulation.rs @@ -202,17 +202,20 @@ pub(crate) async fn update_tab_configuration( Ok(()) } -/// Replays every stored setting onto `context`, used for tabs the daemon creates. +/// Replays every stored setting onto `context` as it becomes the active tab, including the +/// media configuration Firefox only accepts for the selected tab. pub async fn apply_to_context(ff: &FirefoxSession, context: &str) -> Result<(), String> { let settings = ff.emulation.read().unwrap().clone(); - apply_settings_to_context(&ff.bidi, &settings, context).await?; + apply_overrides(&ff.bidi, &settings, context).await?; if let Some(ref media) = settings.media { update_tab_configuration(&ff.bidi, &ff.rdp, context, &media_config(media)?).await?; } Ok(()) } -pub(crate) async fn apply_settings_to_context( +/// Replays the per-context overrides only. The media configuration is left to `apply_to_context`, +/// because Firefox accepts it through DevTools for the selected tab alone. +pub(crate) async fn apply_overrides( bidi: &BidiClient, settings: &Settings, context: &str, diff --git a/cli/src/native/firefox/network.rs b/cli/src/native/firefox/network.rs index f66bcc5514..4d4489a0c2 100644 --- a/cli/src/native/firefox/network.rs +++ b/cli/src/native/firefox/network.rs @@ -275,7 +275,9 @@ impl LegacyDownloadIds { } } -/// Starts the task that resolves every intercepted request exactly once. +/// Starts the task that owns the protocol side of BiDi events: resolving intercepted requests, +/// tracking downloads and replaying emulation onto new tabs. It never touches daemon state; the +/// drain owns that. pub fn spawn_dispatcher( bidi: Arc, net: NetContext, @@ -283,27 +285,19 @@ pub fn spawn_dispatcher( emulation: Arc>, ) -> AbortOnDrop { AbortOnDrop(tokio::spawn(async move { - let mut emulated_contexts = HashSet::new(); let mut legacy_downloads = LegacyDownloadIds::default(); while let Some(mut e) = rx.recv().await { if e["method"] == "browsingContext.contextCreated" && e["params"]["parent"].is_null() { if let Some(context) = e["params"]["context"].as_str() { - if emulated_contexts.insert(context.to_string()) { - let settings = emulation.read().unwrap().clone(); - if let Err(error) = - super::emulation::apply_settings_to_context(&bidi, &settings, context) - .await - { - eprintln!( - "Warning: failed to apply Firefox emulation to {context}: {error}" - ); - } + let settings = emulation.read().unwrap().clone(); + if let Err(error) = + super::emulation::apply_overrides(&bidi, &settings, context).await + { + eprintln!( + "Warning: failed to apply Firefox emulation to {context}: {error}" + ); } } - } else if e["method"] == "browsingContext.contextDestroyed" { - if let Some(context) = e["params"]["context"].as_str() { - emulated_contexts.remove(context); - } } let is_download = matches!( e["method"].as_str(), @@ -496,7 +490,8 @@ fn auth_response( } } -/// Drains buffered BiDi events and applies them to the daemon's dialog, network and console state. +/// Drains buffered BiDi events into the daemon: tab bookkeeping and dialogs first, then the +/// recorders, which only see events from the active tab. pub async fn drain(state: &mut DaemonState) { if let Some(ff) = state.firefox.as_mut() { let top_context = ff.top_context.clone(); @@ -510,8 +505,33 @@ pub async fn drain(state: &mut DaemonState) { } } +/// The browsing context an event reports, when the event carries one. +fn event_context(event: &Value) -> Option<&str> { + if event["method"] == "log.entryAdded" { + event["params"]["source"]["context"].as_str() + } else { + event["params"]["context"].as_str() + } +} + +/// Whether an event belongs to the active tab, one of its frames, or a context the daemon +/// cannot place, such as a worker that has no browsing context. +fn recorded_in_active_tab(state: &DaemonState, event: &Value) -> bool { + event_context(event).is_none_or(|context| { + state + .firefox + .as_ref() + .is_some_and(|ff| ff.context_is_in_active_tab(context)) + }) +} + +/// Records buffered BiDi events. Events from background tabs are dropped here, before any +/// recorder sees them, so every recorder keeps the Chrome rule of one active page. async fn apply_events(state: &mut DaemonState, events: Vec) { for e in events { + if !recorded_in_active_tab(state, &e) { + continue; + } let p = &e["params"]; match e["method"].as_str() { Some("network.beforeRequestSent") => request_started(state, p), @@ -532,15 +552,6 @@ fn request_id(p: &Value) -> &str { } fn request_started(state: &mut DaemonState, p: &Value) { - if let Some(context) = p["context"].as_str() { - let active = state - .firefox - .as_ref() - .is_some_and(|ff| ff.context_is_in_active_tab(context)); - if !active { - return; - } - } let request = &p["request"]; let id = request_id(p).to_string(); let url = request["url"].as_str().unwrap_or("").to_string(); @@ -906,27 +917,6 @@ pub async fn request_detail(cmd: &Value, state: &mut DaemonState) -> Result bool { - fn contains(node: &Value, context: &str) -> bool { - node["context"] == context - || node["children"] - .as_array() - .is_some_and(|c| c.iter().any(|n| contains(n, context))) - } - if context == ff.top_context { - return true; - } - ff.bidi - .send("browsingContext.getTree", json!({"root": ff.top_context})) - .await - .is_ok_and(|tree| { - tree["contexts"] - .as_array() - .is_some_and(|c| c.iter().any(|n| contains(n, context))) - }) -} - /// `responsebody`: waits for the next response whose URL contains the pattern and returns its body. pub async fn responsebody(cmd: &Value, state: &mut DaemonState) -> Result { let pattern = cmd @@ -947,11 +937,14 @@ pub async fn responsebody(cmd: &Value, state: &mut DaemonState) -> Result return Err(format!("Timeout waiting for response matching '{pattern}'")), }; let p = &event["params"]; - if event["method"] == "network.responseCompleted" + let matches = event["method"] == "network.responseCompleted" && p["response"]["url"] .as_str() - .is_some_and(|u| u.contains(pattern)) - && in_active_tab(ff, p["context"].as_str().unwrap_or("")).await + .is_some_and(|u| u.contains(pattern)); + if matches + && ff + .active_tab_contains(p["context"].as_str().unwrap_or("")) + .await { let (body, _) = data(&ff.bidi, request_id(p), "response").await?; return Ok(json!({ @@ -1897,6 +1890,48 @@ mod tests { assert_eq!(state.har_entries[1].request_id, "frame"); } + #[tokio::test] + async fn log_entries_exclude_background_tabs() { + let (mut ff, _, _) = + crate::native::firefox::session::tests::fake_session_with(|_| json!({})).await; + ff.top_context = "active".into(); + ff.context = "active".into(); + ff.context_parents.insert("frame".into(), "active".into()); + let mut state = DaemonState::new(); + state.firefox = Some(ff); + + for (context, text) in [ + (json!("active"), "from-active"), + (json!("frame"), "from-frame"), + (json!("other"), "from-other"), + (Value::Null, "from-worker"), + ] { + apply_events( + &mut state, + vec![json!({ + "method": "log.entryAdded", + "params": { + "type": "console", + "method": "log", + "text": text, + "args": [], + "source": {"context": context}, + }, + })], + ) + .await; + } + + let console = state.event_tracker.get_console_json(); + let texts: Vec<&str> = console["messages"] + .as_array() + .unwrap() + .iter() + .map(|message| message["text"].as_str().unwrap()) + .collect(); + assert_eq!(texts, vec!["from-active", "from-frame", "from-worker"]); + } + #[tokio::test] async fn in_flight_set_ignores_websockets_and_clears_on_completion() { let (mut ff, tx, _) = fake_session().await; diff --git a/cli/src/native/firefox/page.rs b/cli/src/native/firefox/page.rs index 57fe6d6299..7344d3e738 100644 --- a/cli/src/native/firefox/page.rs +++ b/cli/src/native/firefox/page.rs @@ -86,6 +86,34 @@ fn reset_page_state(state: &mut DaemonState) { state.ref_map.invalidate_all_documents(); } +/// Navigates one context and returns the URL it landed on, translating Firefox failures and +/// `about:neterror` pages to Chromium error text. `None` means a prompt opened first. +pub(super) async fn open( + ff: &FirefoxSession, + context: &str, + url: &str, + wait: &str, +) -> Result, String> { + let nav = ff + .race_prompt( + context, + ff.bidi.send( + "browsingContext.navigate", + json!({"context": context, "url": url, "wait": wait}), + ), + ) + .await + .map_err(|e| format!("Navigation failed: {}", normalize_net_error_text(&e)))?; + let Some(nav) = nav else { + return Ok(None); + }; + let landed = nav["url"].as_str().unwrap_or(url); + if let Some(message) = neterror_message(landed) { + return Err(format!("Navigation failed: {message}")); + } + Ok(Some(landed.to_string())) +} + /// Navigates the active tab and returns `{url, title, targetId}` like `BrowserManager::navigate`. /// `waitUntil` maps to the BiDi `wait` state. pub async fn navigate(cmd: &Value, state: &mut DaemonState) -> Result { @@ -93,9 +121,7 @@ pub async fn navigate(cmd: &Value, state: &mut DaemonState) -> Result Result>, pub(crate) dispatcher: Option, pub visited_origins: HashSet, + /// Version Firefox reported when the session opened, used for HAR browser metadata. + pub browser_version: String, } /// Builds the BiDi `proxy` capability from a `--proxy` value such as `http://host:3128`. @@ -165,11 +167,11 @@ impl FirefoxSession { json!({"capabilities": {"alwaysMatch": caps}}), ) .await?; - check_firefox_version( - created["capabilities"]["browserVersion"] - .as_str() - .unwrap_or(""), - )?; + let browser_version = created["capabilities"]["browserVersion"] + .as_str() + .unwrap_or("") + .to_string(); + check_firefox_version(&browser_version)?; let events_to_watch: Vec<&str> = CONTEXT_EVENTS .into_iter() .chain(PROMPT_EVENTS) @@ -245,6 +247,7 @@ impl FirefoxSession { inflight: HashMap::new(), dispatcher, visited_origins: HashSet::new(), + browser_version, }; if let Some(contexts) = tree["contexts"].as_array() { for context in contexts { @@ -405,7 +408,7 @@ impl FirefoxSession { if let Some(context) = params["context"].as_str() { self.inflight.retain(|_, owner| { !owner.as_deref().is_some_and(|owner| { - context_contains(context, owner, &self.context_parents) + is_descendant_of(context, owner, &self.context_parents) }) }); } @@ -419,11 +422,32 @@ impl FirefoxSession { self.inflight .values() .filter_map(|context| context.as_deref()) - .any(|context| context_contains(&self.top_context, context, &self.context_parents)) + .any(|context| self.context_is_in_active_tab(context)) } + /// True when `context` is the active tab or one of its frames. pub(crate) fn context_is_in_active_tab(&self, context: &str) -> bool { - owner_context(context, &self.context_parents) == self.top_context + is_descendant_of(&self.top_context, context, &self.context_parents) + } + + /// Records the frame tree under the active tab so frames created since the last drain can be placed. + pub(crate) async fn refresh_frame_tree(&mut self) -> Result<(), String> { + let tree = self + .bidi + .send("browsingContext.getTree", json!({"root": self.top_context})) + .await?; + let contexts = tree["contexts"].as_array().cloned().unwrap_or_default(); + self.record_context_origins(&contexts); + Ok(()) + } + + /// True when `context` is the active tab or one of its frames, reading the live frame tree + /// when the drained events have not described it yet. + pub(crate) async fn active_tab_contains(&mut self, context: &str) -> bool { + if self.context_is_in_active_tab(context) { + return true; + } + self.refresh_frame_tree().await.is_ok() && self.context_is_in_active_tab(context) } /// Runs `fut` until it finishes or a prompt opens in `context` or its owning tab. Returns `None` for the prompt. @@ -569,7 +593,8 @@ fn owner_context<'a>(mut context: &'a str, parents: &'a HashMap) context } -fn context_contains<'a>( +/// True when `context` is `root` or a frame nested inside it. +fn is_descendant_of<'a>( root: &str, mut context: &'a str, parents: &'a HashMap, @@ -755,6 +780,7 @@ pub(crate) mod tests { intercepting: false, inflight: HashMap::new(), dispatcher: None, + browser_version: String::new(), }; (session, tx, calls) } @@ -887,6 +913,7 @@ pub(crate) mod tests { intercepting: false, inflight: HashMap::new(), dispatcher: None, + browser_version: String::new(), }; (session, tx, calls, bidi_messages, rdp_messages) } @@ -991,6 +1018,22 @@ pub(crate) mod tests { assert_eq!(state.firefox.as_ref().unwrap().context, "survivor"); } + #[tokio::test] + async fn a_frame_missing_from_drained_events_is_still_in_the_active_tab() { + let (mut ff, _, _) = fake_session_with(|v: &Value| { + if v["method"] == "browsingContext.getTree" { + json!({"contexts": [{"context": "ctx-active", "children": [{"context": "ctx-frame"}]}]}) + } else { + json!({}) + } + }) + .await; + assert!(!ff.context_is_in_active_tab("ctx-frame")); + assert!(ff.active_tab_contains("ctx-frame").await); + assert!(ff.context_is_in_active_tab("ctx-frame")); + assert!(!ff.active_tab_contains("ctx-other").await); + } + #[tokio::test] async fn drain_events_keeps_more_than_broadcast_capacity() { let (mut ff, tx, _) = fake_session().await; diff --git a/cli/src/native/firefox/storage.rs b/cli/src/native/firefox/storage.rs index 14504f350a..876e4f37ee 100644 --- a/cli/src/native/firefox/storage.rs +++ b/cli/src/native/firefox/storage.rs @@ -438,10 +438,12 @@ async fn collect_via_temp_context( let result = async { let mut found = Vec::new(); for origin in origins { - if let Some(filter) = ff.net.domain_filter.read().await.as_ref() { - if filter.check_url(origin).is_err() { - continue; - } + let allowed = { + let filter = ff.net.domain_filter.read().await; + crate::native::network::check_navigation_allowed(filter.as_ref(), origin).is_ok() + }; + if !allowed { + continue; } ff.bidi .send( diff --git a/cli/src/native/firefox/tabs.rs b/cli/src/native/firefox/tabs.rs index e5e6d1ce42..9dd1cf7ae9 100644 --- a/cli/src/native/firefox/tabs.rs +++ b/cli/src/native/firefox/tabs.rs @@ -7,7 +7,7 @@ use super::actions::session; use super::bidi::{remote_to_json, BidiClient}; use super::emulation; use super::page; -use crate::native::actions::{self as chrome, DaemonState}; +use crate::native::actions::DaemonState; use crate::native::browser::{format_tab_id, is_valid_label, TabRef}; /// Opens a tab and makes it active. Must return what Chrome's `handle_tab_new` returns, @@ -15,7 +15,7 @@ use crate::native::browser::{format_tab_id, is_valid_label, TabRef}; pub async fn tab_new(cmd: &Value, state: &mut DaemonState) -> Result { let url = cmd.get("url").and_then(Value::as_str); if let Some(url) = url { - chrome::check_url_allowed_by_filter(state.domain_filter.read().await.as_ref(), url)?; + state.ensure_navigation_allowed(url).await?; } let label = cmd.get("label").and_then(Value::as_str); if let Some(label) = label { @@ -56,17 +56,9 @@ pub async fn tab_new(cmd: &Value, state: &mut DaemonState) -> Result { - let nav = ff - .bidi - .send( - "browsingContext.navigate", - json!({"context": context, "url": url, "wait": "complete"}), - ) - .await - .map_err(|e| { - format!("Navigation failed: {}", page::normalize_net_error_text(&e)) - })?; - let landed = nav["url"].as_str().unwrap_or(url).to_string(); + let landed = page::open(ff, &context, url, "complete") + .await? + .unwrap_or_else(|| url.to_string()); ff.note_visit(&landed); landed } @@ -224,7 +216,8 @@ pub async fn tab_close(cmd: &Value, state: &mut DaemonState) -> Result Result<(), String> { + if url == "about:blank" { + return Ok(()); + } + self.check_url(url) + } +} + +/// Rejects a navigation to a URL outside the allowed domains; a missing filter allows everything. +pub(crate) fn check_navigation_allowed( + filter: Option<&DomainFilter>, + url: &str, +) -> Result<(), String> { + match filter { + Some(filter) => filter.check_navigation(url), + None => Ok(()), + } } fn parse_domain_list(input: &str) -> Vec { @@ -791,6 +810,21 @@ mod tests { assert!(filter.is_allowed("anything.com")); } + #[test] + fn navigation_allows_about_blank_and_blocks_every_other_unlisted_url() { + let filter = DomainFilter::new("example.com"); + assert!(filter.check_navigation("about:blank").is_ok()); + assert!(filter.check_navigation("https://example.com/page").is_ok()); + assert_eq!( + filter + .check_navigation("https://evil.example/") + .unwrap_err(), + "Domain 'evil.example' is not in the allowed domains list" + ); + assert!(filter.check_navigation("data:text/html,hi").is_err()); + assert!(check_navigation_allowed(None, "https://evil.example/").is_ok()); + } + #[test] fn test_domain_filter_multiple() { let filter = DomainFilter::new("example.com, *.api.io"); diff --git a/docs/content/docs/security.mdx b/docs/content/docs/security.mdx index e07c501412..6f914d1c02 100644 --- a/docs/content/docs/security.mdx +++ b/docs/content/docs/security.mdx @@ -212,7 +212,7 @@ export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked. WebSocket and EventSource connections are blocked via constructor-level patching, and `sendBeacon` returns `false` for blocked destinations. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. WebRTC peer connections are disabled entirely in supported Chromium sessions while the allowlist is active because STUN, TURN, and DNS traffic do not pass through CDP Fetch interception. Locally launched Chrome also uses `disable_non_proxied_udp` as a second layer. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Non-http(s) sub-resources such as data URIs and blobs remain allowed. When a request is blocked, the command returns an error. -Firefox also rejects non-HTTP document navigations other than `about:blank` while the allowlist is active. +Firefox also blocks non-HTTP document navigations other than `about:blank` while the allowlist is active, and disables WebRTC through the `media.peerconnection.enabled` preference. > **Note:** WebSocket and EventSource blocking is best-effort because it overrides browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore those original constructors. The WebRTC replacement is installed as a non-configurable constructor, with the Chrome UDP policy providing defense in depth for local launches. For maximum protection, deny the `eval` category via `--action-policy` and enforce operating-system egress policy when using `--allowed-domains`. diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 96344b3cee..9223adbb4d 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -534,7 +534,7 @@ Common tools include: - `agent_browser_eval` - `agent_browser_close` -Tool calls use the same config files and environment variables as the CLI by default. Each tool accepts typed arguments plus `extraArgs` for per-command options and display or emulation flags such as `--engine`; `extraArgs` cannot set `--config` or global flags that change network, file, policy, or host access. Set host-wide options in the MCP server's environment or selected config. The typed `allowedDomains` argument is rejected when the server's environment or selected config already sets allowed domains. The common `allowedDomains` array maps to `--allowed-domains`, including Firefox's rejection of non-HTTP document navigations, and activates the same WebRTC containment and launch-mode restrictions. Tool discovery is paginated and includes read-only/open-world annotations so modern MCP clients can load the large typed surface incrementally. Use the `session` tool argument or `AGENT_BROWSER_SESSION` to isolate browser state. Undeclared argument keys are rejected with an invalid-params error that names them. The typed `idleTimeout` argument maps to `--idle-timeout`. The typed `relaunch` argument maps to `--relaunch`; `open` and `tab_new` list it in their schema. +Tool calls use the same config files and environment variables as the CLI by default. Each tool accepts typed arguments plus `extraArgs` for per-command options and display or emulation flags such as `--engine`; `extraArgs` cannot set `--config` or global flags that change network, file, policy, or host access. Set host-wide options in the MCP server's environment or selected config. The typed `allowedDomains` argument is rejected when the server's environment or selected config already sets allowed domains. The common `allowedDomains` array maps to `--allowed-domains`, including Firefox blocking non-HTTP document navigations, and activates the same WebRTC containment and launch-mode restrictions. Tool discovery is paginated and includes read-only/open-world annotations so modern MCP clients can load the large typed surface incrementally. Use the `session` tool argument or `AGENT_BROWSER_SESSION` to isolate browser state. Undeclared argument keys are rejected with an invalid-params error that names them. The typed `idleTimeout` argument maps to `--idle-timeout`. The typed `relaunch` argument maps to `--relaunch`; `open` and `tab_new` list it in their schema. For eve agents, mount the `@agent-browser/eve` extension instead of hand-writing browser tools. It adds namespaced tools such as `browser__open`, `browser__snapshot`, `browser__click`, `browser__fill`, `browser__find`, and `browser__screenshot`, backed by agent-browser inside the eve sandbox. The sandbox bootstrap helpers (`installAgentBrowser`, `agentBrowserRevalidationKey`) ship in `@agent-browser/eve/sandbox`. diff --git a/skill-data/core/references/trust-boundaries.md b/skill-data/core/references/trust-boundaries.md index 9be514a4c1..7a853f5f13 100644 --- a/skill-data/core/references/trust-boundaries.md +++ b/skill-data/core/references/trust-boundaries.md @@ -63,7 +63,7 @@ The hook in particular exposes `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` to every ## Network interception and automation artifacts -- `--allowed-domains` blocks non-allowlisted HTTP traffic, non-HTTP document navigations in Firefox other than `about:blank`, WebSocket and EventSource connections, and `sendBeacon` calls. Non-HTTP sub-resources remain allowed. It also disables `RTCPeerConnection` for supported Chromium sessions because STUN, TURN, and related DNS traffic do not pass through CDP HTTP interception. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. Locally launched Chrome additionally disables non-proxied WebRTC UDP. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Treat this as browser-level containment and combine it with host or container egress controls when you need an operating-system security boundary. +- `--allowed-domains` blocks non-allowlisted HTTP traffic, non-HTTP document navigations in Firefox other than `about:blank`, WebSocket and EventSource connections, and `sendBeacon` calls. Non-HTTP sub-resources remain allowed. It also disables `RTCPeerConnection` for supported Chromium sessions and for Firefox because STUN, TURN, and related DNS traffic do not pass through CDP HTTP interception. Dedicated and shared workers are guarded with a bootstrap wrapper; if a page CSP forbids that wrapper, the worker fails closed rather than running without the allowlist guard. Locally launched Chrome additionally disables non-proxied WebRTC UDP. Pre-existing CDP sessions, auto-connect, Chrome profiles, direct-page provider plugins, agent-browser restore or state-file replay, raw Chrome args that select profiles, restore sessions, or open startup pages, iOS, and Safari reject this option because agent-browser cannot install equivalent containment before page scripts run. Treat this as browser-level containment and combine it with host or container egress controls when you need an operating-system security boundary. - `network route` can fail or mock requests. Treat it the way you treat production traffic manipulation — confirm with the user before using it against anything other than a dev server. - `har start` / `har stop` records every request and response body to disk, including auth headers and bearer tokens. Don't share HAR files without redaction. - Screenshots and videos can accidentally capture secrets (auto-filled form fields, visible tokens in URL bars, etc.). Review before sending. From 81ac6ab0c471a8edd22d2ffaae3757ad34925488 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:52:56 -0800 Subject: [PATCH 36/36] docs(firefox): authenticated proxies work, so stop listing them as rejected Update --help and the command references to describe proxy credentials as supported and keep the rejected list accurate. --- cli/src/output.rs | 7 ++++--- docs/content/docs/commands.mdx | 2 +- skill-data/core/references/commands.md | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/src/output.rs b/cli/src/output.rs index 3394e22f3f..dfbb0cf042 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -4679,9 +4679,10 @@ Options: Explicit Obscura launches validate this invocation's resolved bypass settings, even with an existing daemon; stale daemon environment values are not reused when bypass is cleared. - Firefox rejects authenticated proxies, --cdp, --auto-connect, --provider, - profiles, extensions, --args, --allow-file-access, --webgpu, --ca-cert, - --enable, --init-script, and --capture flow. + Firefox rejects --cdp, --auto-connect, --provider, profiles, extensions, + --args, --allow-file-access, --webgpu, --ca-cert, --enable, + --init-script, and --capture flow. Authenticated proxies work through + --proxy with credentials. Obscura has accessibility, iframe, and screenshot fidelity gaps. --no-auto-dialog Disable automatic dismissal of alert/beforeunload dialogs (or AGENT_BROWSER_NO_AUTO_DIALOG) --relaunch Relaunch the session's browser even when no setting changed diff --git a/docs/content/docs/commands.mdx b/docs/content/docs/commands.mdx index 41b3ef239a..5292e6d652 100644 --- a/docs/content/docs/commands.mdx +++ b/docs/content/docs/commands.mdx @@ -708,7 +708,7 @@ See [Init Scripts & Extensions](/init-scripts) for launch-time scripts, script r In a `batch`, `--relaunch` relaunches the browser once: the first command that can launch a browser carries it. Commands that never launch one, such as `read`, and commands denied by policy or waiting for confirmation pass it on to the next command. -Firefox rejects authenticated proxies, `--cdp`, `--auto-connect`, `--provider`, profiles, extensions, `--args`, `--allow-file-access`, `--webgpu`, `--ca-cert`, `--enable`, `--init-script`, and `--capture flow`. +Firefox rejects `--cdp`, `--auto-connect`, `--provider`, profiles, extensions, `--args`, `--allow-file-access`, `--webgpu`, `--ca-cert`, `--enable`, `--init-script`, and `--capture flow`. Authenticated proxies work through `--proxy` with credentials. See [Configuration](/configuration) for config files and environment variables. See [Proxy](/proxy) for proxy server, bypass, and credential settings. diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index bc3bf0c575..324aa47435 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -587,7 +587,7 @@ agent-browser --version # Show version (-V) agent-browser --help # Show detailed help for a command ``` -Firefox rejects authenticated proxies, `--cdp`, `--auto-connect`, `--provider`, profiles, extensions, `--args`, `--allow-file-access`, `--webgpu`, `--ca-cert`, `--enable`, `--init-script`, and `--capture flow`. +Firefox rejects `--cdp`, `--auto-connect`, `--provider`, profiles, extensions, `--args`, `--allow-file-access`, `--webgpu`, `--ca-cert`, `--enable`, `--init-script`, and `--capture flow`. Authenticated proxies work through `--proxy` with credentials. ## Debugging