fix: handle proxy authentication via CDP Fetch.authRequired (#1000)

* fix: handle proxy authentication via CDP Fetch.authRequired

Chrome's --proxy-server flag does not support credentials embedded in
the URL. When a proxy requires authentication, Chrome receives a 407
from the proxy but has no way to respond with credentials, resulting
in net::ERR_INVALID_AUTH_CREDENTIALS.

Fix by:
1. Parsing credentials from the proxy URL (already done by parse_proxy)
2. Storing them in DaemonState.proxy_credentials
3. Enabling Fetch.enable with handleAuthRequests: true
4. Responding to Fetch.authRequired events with Fetch.continueWithAuth
5. Passing only the server URL (without credentials) to --proxy-server
6. Forwarding credentials to the daemon via dedicated env vars

Also adds fallback to standard proxy env vars (HTTP_PROXY, HTTPS_PROXY,
ALL_PROXY, NO_PROXY) when AGENT_BROWSER_PROXY is not set.

Fixes #990

* refactor: use typed struct for parse_proxy, fix double Fetch.enable and username-only auth

- Replace serde_json::Value return from parse_proxy with a typed ParsedProxy struct
- Fix double Fetch.enable call when both proxy auth and domain filter are active
  (the second call could overwrite handleAuthRequests from the first)
- Allow username-only proxy auth (some proxies don't require a password)
- Handle empty username/password in parse_proxy as None instead of Some("")
- Use install_domain_filter_fetch in auto_launch for consistency
- Update unit tests to use typed struct fields

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-03-24 12:52:39 -05:00
committed by GitHub
co-authored by ctate
parent 23a117c5c2
commit cd1f255129
7 changed files with 266 additions and 78 deletions
+8
View File
@@ -188,6 +188,8 @@ pub struct DaemonOptions<'a> {
pub user_agent: Option<&'a str>,
pub proxy: Option<&'a str>,
pub proxy_bypass: Option<&'a str>,
pub proxy_username: Option<&'a str>,
pub proxy_password: Option<&'a str>,
pub ignore_https_errors: bool,
pub allow_file_access: bool,
pub profile: Option<&'a str>,
@@ -233,6 +235,12 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
if let Some(pb) = opts.proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if let Some(pu) = opts.proxy_username {
cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu);
}
if let Some(pp) = opts.proxy_password {
cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp);
}
if opts.ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
+12 -2
View File
@@ -353,10 +353,20 @@ pub fn parse_flags(args: &[String]) -> Flags {
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy: env::var("AGENT_BROWSER_PROXY")
.ok()
.or(config.proxy)
.or_else(|| env::var("HTTP_PROXY").ok())
.or_else(|| env::var("http_proxy").ok())
.or_else(|| env::var("HTTPS_PROXY").ok())
.or_else(|| env::var("https_proxy").ok())
.or_else(|| env::var("ALL_PROXY").ok())
.or_else(|| env::var("all_proxy").ok()),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass),
.or(config.proxy_bypass)
.or_else(|| env::var("NO_PROXY").ok())
.or_else(|| env::var("no_proxy").ok()),
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
+85 -39
View File
@@ -54,34 +54,67 @@ fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
}));
}
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
struct ParsedProxy {
server: String,
username: Option<String>,
password: Option<String>,
}
fn parse_proxy(proxy_str: &str) -> ParsedProxy {
let Some(protocol_end) = proxy_str.find("://") else {
return json!({ "server": proxy_str });
return ParsedProxy {
server: proxy_str.to_string(),
username: None,
password: None,
};
};
let protocol = &proxy_str[..protocol_end + 3];
let rest = &proxy_str[protocol_end + 3..];
let Some(at_pos) = rest.rfind('@') else {
return json!({ "server": proxy_str });
return ParsedProxy {
server: proxy_str.to_string(),
username: None,
password: None,
};
};
let creds = &rest[..at_pos];
let server_part = &rest[at_pos + 1..];
let server = format!("{}{}", protocol, server_part);
let Some(colon_pos) = creds.find(':') else {
return json!({
"server": server,
"username": creds,
"password": ""
});
let (username, password) = match creds.find(':') {
Some(colon_pos) => {
let u = &creds[..colon_pos];
let p = &creds[colon_pos + 1..];
(
if u.is_empty() {
None
} else {
Some(u.to_string())
},
if p.is_empty() {
None
} else {
Some(p.to_string())
},
)
}
None => (
if creds.is_empty() {
None
} else {
Some(creds.to_string())
},
None,
),
};
json!({
"server": server,
"username": &creds[..colon_pos],
"password": &creds[colon_pos + 1..]
})
ParsedProxy {
server,
username,
password,
}
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
@@ -330,6 +363,13 @@ fn main() {
return;
}
// Parse proxy URL to separate server from credentials for the daemon.
let (proxy_server, proxy_username, proxy_password) = if let Some(ref proxy_str) = flags.proxy {
let parsed = parse_proxy(proxy_str);
(Some(parsed.server), parsed.username, parsed.password)
} else {
(None, None, None)
};
let daemon_opts = DaemonOptions {
headed: flags.headed,
debug: flags.debug,
@@ -337,8 +377,10 @@ fn main() {
extensions: &flags.extensions,
args: flags.args.as_deref(),
user_agent: flags.user_agent.as_deref(),
proxy: flags.proxy.as_deref(),
proxy: proxy_server.as_deref(),
proxy_bypass: flags.proxy_bypass.as_deref(),
proxy_username: proxy_username.as_deref(),
proxy_password: proxy_password.as_deref(),
ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access,
profile: flags.profile.as_deref(),
@@ -694,12 +736,16 @@ fn main() {
}
if let Some(ref proxy_str) = flags.proxy {
let mut proxy_obj = parse_proxy(proxy_str);
// Add bypass if specified
let parsed = parse_proxy(proxy_str);
let mut proxy_obj = json!({ "server": parsed.server });
if let Some(ref username) = parsed.username {
proxy_obj["username"] = json!(username);
}
if let Some(ref password) = parsed.password {
proxy_obj["password"] = json!(password);
}
if let Some(ref bypass) = flags.proxy_bypass {
if let Some(obj) = proxy_obj.as_object_mut() {
obj.insert("bypass".to_string(), json!(bypass));
}
proxy_obj["bypass"] = json!(bypass);
}
cmd_obj.insert("proxy".to_string(), proxy_obj);
}
@@ -1007,55 +1053,55 @@ mod tests {
#[test]
fn test_parse_proxy_simple() {
let result = parse_proxy("http://proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert!(result.get("username").is_none());
assert!(result.get("password").is_none());
assert_eq!(result.server, "http://proxy.com:8080");
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_proxy_with_auth() {
let result = parse_proxy("http://user:pass@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "pass");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_proxy_username_only() {
let result = parse_proxy("http://user@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert!(result.password.is_none());
}
#[test]
fn test_parse_proxy_no_protocol() {
let result = parse_proxy("proxy.com:8080");
assert_eq!(result["server"], "proxy.com:8080");
assert!(result.get("username").is_none());
assert_eq!(result.server, "proxy.com:8080");
assert!(result.username.is_none());
}
#[test]
fn test_parse_proxy_socks5() {
let result = parse_proxy("socks5://proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert!(result.get("username").is_none());
assert_eq!(result.server, "socks5://proxy.com:1080");
assert!(result.username.is_none());
}
#[test]
fn test_parse_proxy_socks5_with_auth() {
let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert_eq!(result["username"], "admin");
assert_eq!(result["password"], "secret");
assert_eq!(result.server, "socks5://proxy.com:1080");
assert_eq!(result.username.as_deref(), Some("admin"));
assert_eq!(result.password.as_deref(), Some("secret"));
}
#[test]
fn test_parse_proxy_complex_password() {
let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "p@ss:w0rd");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("p@ss:w0rd"));
}
#[test]
+141 -26
View File
@@ -195,6 +195,9 @@ pub struct DaemonState {
/// Key is the origin (scheme + host + port), value is the headers map.
/// Wrapped in Arc<RwLock<>> so the background Fetch handler can read it.
pub origin_headers: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
/// Proxy authentication credentials (username, password) for handling
/// Fetch.authRequired events from authenticated proxies.
pub proxy_credentials: Arc<RwLock<Option<(String, String)>>>,
/// Background task that processes Fetch.requestPaused events in real-time,
/// handling domain filtering, route interception, and origin-scoped headers
/// without deadlocking navigation/evaluate.
@@ -242,6 +245,7 @@ impl DaemonState {
active_frame_id: None,
iframe_sessions: HashMap::new(),
origin_headers: Arc::new(RwLock::new(HashMap::new())),
proxy_credentials: Arc::new(RwLock::new(None)),
fetch_handler_task: None,
mouse_state: MouseState::default(),
pending_dialog: None,
@@ -272,8 +276,9 @@ impl DaemonState {
}
}
/// Start the background task that processes all Fetch.requestPaused events
/// in real-time (domain filtering, route interception, origin-scoped headers).
/// Start the background task that processes Fetch.requestPaused and
/// Fetch.authRequired events in real-time (domain filtering, route
/// interception, origin-scoped headers, proxy authentication).
/// Must be called after the browser is set and events are subscribed.
fn start_fetch_handler(&mut self) {
// Abort any existing handler.
@@ -290,10 +295,50 @@ impl DaemonState {
let domain_filter = self.domain_filter.clone();
let routes = self.routes.clone();
let origin_headers = self.origin_headers.clone();
let proxy_credentials = self.proxy_credentials.clone();
self.fetch_handler_task = Some(tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) if event.method == "Fetch.authRequired" => {
let request_id = event
.params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let sid = event.session_id.clone().unwrap_or_default();
let creds = proxy_credentials.read().await;
if let Some((ref user, ref pass)) = *creds {
let _ = client
.send_command(
"Fetch.continueWithAuth",
Some(json!({
"requestId": request_id,
"authChallengeResponse": {
"response": "ProvideCredentials",
"username": user,
"password": pass,
}
})),
Some(&sid),
)
.await;
} else {
let _ = client
.send_command(
"Fetch.continueWithAuth",
Some(json!({
"requestId": request_id,
"authChallengeResponse": {
"response": "CancelAuth",
}
})),
Some(&sid),
)
.await;
}
}
Ok(event) if event.method == "Fetch.requestPaused" => {
let request_id = event
.params
@@ -854,10 +899,12 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
// Install domain filter on new pages
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
let _ = network::install_domain_filter(
&mgr.client,
&attach.session_id,
&filter.allowed_domains,
has_proxy_creds,
)
.await;
}
@@ -1186,6 +1233,16 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let options = launch_options_from_env();
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
// 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(),
));
}
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
state.reset_input_state();
@@ -1213,6 +1270,16 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.update_stream_client().await;
// Enable Fetch with handleAuthRequests for proxy authentication
if has_proxy_auth {
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = network::install_domain_filter_fetch(&mgr.client, session_id, true).await;
}
}
}
try_auto_restore_state(state).await;
Ok(())
}
@@ -1234,6 +1301,8 @@ fn launch_options_from_env() -> LaunchOptions {
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
proxy: env::var("AGENT_BROWSER_PROXY").ok(),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(),
proxy_username: env::var("AGENT_BROWSER_PROXY_USERNAME").ok(),
proxy_password: env::var("AGENT_BROWSER_PROXY_PASSWORD").ok(),
profile: env::var("AGENT_BROWSER_PROFILE").ok(),
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS")
.map(|v| v == "1" || v == "true")
@@ -1437,6 +1506,18 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.and_then(|v| v.get("bypass"))
.and_then(|v| v.as_str())
.map(String::from),
proxy_username: cmd
.get("proxy")
.and_then(|v| v.get("username"))
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_PROXY_USERNAME").ok()),
proxy_password: cmd
.get("proxy")
.and_then(|v| v.get("password"))
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_PROXY_PASSWORD").ok()),
user_agent: cmd
.get("userAgent")
.and_then(|v| v.as_str())
@@ -1455,6 +1536,16 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.map(String::from),
};
// 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(),
));
}
if let Some(ref domains) = cmd
.get("allowedDomains")
.and_then(|v| v.as_str())
@@ -1470,18 +1561,34 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.start_fetch_handler();
state.update_stream_client().await;
// Enable Fetch interception (domain filtering and/or proxy auth).
// Only call Fetch.enable once to avoid overwriting handleAuthRequests.
{
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
let has_domain_filter = df.is_some();
if has_domain_filter || has_proxy_auth {
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = network::install_domain_filter(
&mgr.client,
session_id,
&filter.allowed_domains,
)
.await;
network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter).await;
if let Some(ref filter) = *df {
let _ = network::install_domain_filter(
&mgr.client,
session_id,
&filter.allowed_domains,
has_proxy_auth,
)
.await;
network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter)
.await;
} else {
// No domain filter, but proxy auth needs Fetch.enable
let _ = network::install_domain_filter_fetch(
&mgr.client,
session_id,
has_proxy_auth,
)
.await;
}
}
}
}
@@ -1635,12 +1742,13 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
// routes already enabled it. Wildcard ensures we see all requests.
if first_origin_header {
let session_id = mgr.active_session_id()?.to_string();
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
let mut params = json!({ "patterns": [{ "urlPattern": "*" }] });
if has_proxy_creds {
params["handleAuthRequests"] = json!(true);
}
mgr.client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
Some(&session_id),
)
.send_command("Fetch.enable", Some(params), Some(&session_id))
.await?;
}
}
@@ -5813,13 +5921,26 @@ async fn build_fetch_patterns(state: &DaemonState) -> Vec<Value> {
.collect();
let has_domain_filter = state.domain_filter.read().await.is_some();
let has_origin_headers = !state.origin_headers.read().await.is_empty();
if (has_domain_filter || has_origin_headers) && !patterns.iter().any(|p| p["urlPattern"] == "*")
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
if (has_domain_filter || has_origin_headers || has_proxy_creds)
&& !patterns.iter().any(|p| p["urlPattern"] == "*")
{
patterns.push(json!({ "urlPattern": "*" }));
}
patterns
}
/// Build the full Fetch.enable params object, including `handleAuthRequests`
/// when proxy credentials are configured.
async fn build_fetch_enable_params(state: &DaemonState, patterns: Vec<Value>) -> Value {
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
if has_proxy_creds {
json!({ "patterns": patterns, "handleAuthRequests": true })
} else {
json!({ "patterns": patterns })
}
}
async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
@@ -5861,12 +5982,9 @@ async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
}
let patterns = build_fetch_patterns(state).await;
let params = build_fetch_enable_params(state, patterns).await;
mgr.client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": patterns })),
Some(&session_id),
)
.send_command("Fetch.enable", Some(params), Some(&session_id))
.await?;
Ok(json!({ "routed": url_pattern }))
@@ -5896,12 +6014,9 @@ async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
.send_command("Fetch.disable", None, Some(&session_id))
.await?;
} else {
let params = build_fetch_enable_params(state, patterns).await;
mgr.client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": patterns })),
Some(&session_id),
)
.send_command("Fetch.enable", Some(params), Some(&session_id))
.await?;
}
+4
View File
@@ -67,6 +67,8 @@ pub struct LaunchOptions {
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub proxy_username: Option<String>,
pub proxy_password: Option<String>,
pub profile: Option<String>,
pub args: Vec<String>,
pub allow_file_access: bool,
@@ -85,6 +87,8 @@ impl Default for LaunchOptions {
executable_path: None,
proxy: None,
proxy_bypass: None,
proxy_username: None,
proxy_password: None,
profile: None,
args: Vec::new(),
allow_file_access: false,
+10 -8
View File
@@ -233,15 +233,16 @@ pub async fn install_domain_filter_script(
pub async fn install_domain_filter_fetch(
client: &CdpClient,
session_id: &str,
handle_auth_requests: bool,
) -> Result<(), String> {
let mut params = json!({
"patterns": [{ "urlPattern": "*" }]
});
if handle_auth_requests {
params["handleAuthRequests"] = json!(true);
}
client
.send_command(
"Fetch.enable",
Some(json!({
"patterns": [{ "urlPattern": "*" }]
})),
Some(session_id),
)
.send_command("Fetch.enable", Some(params), Some(session_id))
.await?;
Ok(())
}
@@ -253,9 +254,10 @@ pub async fn install_domain_filter(
client: &CdpClient,
session_id: &str,
allowed_domains: &[String],
handle_auth_requests: bool,
) -> Result<(), String> {
install_domain_filter_script(client, session_id, allowed_domains).await?;
install_domain_filter_fetch(client, session_id).await?;
install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
Ok(())
}
+6 -3
View File
@@ -2684,9 +2684,9 @@ Options:
--args <args> Browser launch args, comma or newline separated (or AGENT_BROWSER_ARGS)
e.g., --args "--no-sandbox,--disable-blink-features=AutomationControlled"
--user-agent <ua> Custom User-Agent (or AGENT_BROWSER_USER_AGENT)
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY)
e.g., --proxy "http://user:pass@127.0.0.1:7890"
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS)
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY, HTTP_PROXY, HTTPS_PROXY, ALL_PROXY)
Supports authenticated proxies: --proxy "http://user:pass@127.0.0.1:7890"
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS, NO_PROXY)
e.g., --proxy-bypass "localhost,*.internal.com"
--ignore-https-errors Ignore HTTPS certificate errors
--allow-file-access Allow file:// URLs to access local files (Chromium only)
@@ -2764,6 +2764,9 @@ Environment:
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
HTTP_PROXY / HTTPS_PROXY Standard proxy env vars (fallback if AGENT_BROWSER_PROXY not set)
ALL_PROXY SOCKS proxy (fallback for proxy)
NO_PROXY Bypass proxy for hosts (fallback for proxy-bypass)
AGENT_BROWSER_SCREENSHOT_DIR Default screenshot output directory
AGENT_BROWSER_SCREENSHOT_QUALITY JPEG quality 0-100
AGENT_BROWSER_SCREENSHOT_FORMAT Screenshot format: png, jpeg