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