From 536072b32311246d1428b4ceeec97959b18c8c8b Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Sun, 3 May 2026 17:32:38 +0200 Subject: [PATCH] fix(serve): plumb --user-agent through CDP server (#71) (#76) obscura serve --user-agent only logged the value via tracing::info! and never passed it to the CDP server, so every navigation kept using the default Chrome UA string and httpbin.org/user-agent reported the default rather than the override. Plumb the option end-to-end: - BrowserContext::with_full_options(id, proxy, stealth, user_agent) initializes both BrowserContext::user_agent and the underlying ObscuraHttpClient::user_agent RwLock at construction time so the first navigation already sees the override. - CdpContext::new_with_full_options forwards the UA to the default BrowserContext. - obscura_cdp::start_with_full_options accepts the UA and passes it through cdp_processor. - main.rs Serve arm calls start_with_full_options with the parsed UA. - run_multi_worker_serve forwards --user-agent when spawning worker subprocesses so multi-worker mode honors the flag too. Existing two-arg start_with_options and three-arg with_options stay as thin shims (call the full variant with None) so older callers and the public crate API remain source-compatible. Three new unit tests in obscura-browser cover the propagation, the Chrome default fallback, and the legacy with_options path. Generated by Ora Studio Vibe coded by ousamabenyounes Co-authored-by: Ora Studio --- crates/obscura-browser/src/context.rs | 58 ++++++++++++++++++++++++++- crates/obscura-cdp/src/dispatch.rs | 11 ++++- crates/obscura-cdp/src/lib.rs | 2 +- crates/obscura-cdp/src/server.rs | 14 ++++++- crates/obscura-cli/src/main.rs | 8 +++- 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/obscura-browser/src/context.rs b/crates/obscura-browser/src/context.rs index dace8cb..b539f0f 100644 --- a/crates/obscura-browser/src/context.rs +++ b/crates/obscura-browser/src/context.rs @@ -30,6 +30,15 @@ impl BrowserContext { } pub fn with_options(id: String, proxy_url: Option, stealth: bool) -> Self { + Self::with_full_options(id, proxy_url, stealth, None) + } + + pub fn with_full_options( + id: String, + proxy_url: Option, + stealth: bool, + user_agent: Option, + ) -> Self { let cookie_jar = Arc::new(CookieJar::new()); let mut client = ObscuraHttpClient::with_options( cookie_jar.clone(), @@ -38,12 +47,21 @@ impl BrowserContext { if stealth { client.block_trackers = true; } + let resolved_ua = user_agent.unwrap_or_else(|| { + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string() + }); + // Sync the http client's UA at construction so navigation requests pick it + // up before any async setup runs. The lock has no other holders here, so + // try_write always succeeds; we fall back silently if it ever fails. + if let Ok(mut guard) = client.user_agent.try_write() { + *guard = resolved_ua.clone(); + } let http_client = Arc::new(client); BrowserContext { id, cookie_jar, http_client, - user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string(), + user_agent: resolved_ua, proxy_url, robots_cache: Arc::new(RobotsCache::new()), obey_robots: false, @@ -55,3 +73,41 @@ impl BrowserContext { Self::with_options(id, proxy_url, false) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn with_full_options_propagates_user_agent_to_http_client() { + let ctx = BrowserContext::with_full_options( + "test".to_string(), + None, + false, + Some("Custom-UA/1.0".to_string()), + ); + assert_eq!(ctx.user_agent, "Custom-UA/1.0"); + let client_ua = ctx.http_client.user_agent.read().await.clone(); + assert_eq!(client_ua, "Custom-UA/1.0"); + } + + #[tokio::test(flavor = "current_thread")] + async fn with_full_options_falls_back_to_chrome_default() { + let ctx = BrowserContext::with_full_options( + "test".to_string(), + None, + false, + None, + ); + assert!(ctx.user_agent.contains("Chrome")); + let client_ua = ctx.http_client.user_agent.read().await.clone(); + assert!(client_ua.contains("Chrome")); + assert_eq!(ctx.user_agent, client_ua); + } + + #[tokio::test(flavor = "current_thread")] + async fn with_options_keeps_default_user_agent() { + let ctx = BrowserContext::with_options("test".to_string(), None, false); + assert!(ctx.user_agent.contains("Chrome")); + } +} diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index 35d2463..b5a168b 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -39,10 +39,19 @@ impl CdpContext { } pub fn new_with_options(proxy: Option, stealth: bool) -> Self { - let default_context = Arc::new(BrowserContext::with_options( + Self::new_with_full_options(proxy, stealth, None) + } + + pub fn new_with_full_options( + proxy: Option, + stealth: bool, + user_agent: Option, + ) -> Self { + let default_context = Arc::new(BrowserContext::with_full_options( "default".to_string(), proxy, stealth, + user_agent, )); CdpContext { pages: Vec::new(), diff --git a/crates/obscura-cdp/src/lib.rs b/crates/obscura-cdp/src/lib.rs index 2bafcdf..271ec96 100644 --- a/crates/obscura-cdp/src/lib.rs +++ b/crates/obscura-cdp/src/lib.rs @@ -3,4 +3,4 @@ pub mod dispatch; pub mod types; pub mod domains; -pub use server::{start, start_with_options}; +pub use server::{start, start_with_full_options, start_with_options}; diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index a454306..ecef399 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -31,6 +31,15 @@ pub async fn start_with_options( port: u16, proxy: Option, stealth: bool, +) -> anyhow::Result<()> { + start_with_full_options(port, proxy, stealth, None).await +} + +pub async fn start_with_full_options( + port: u16, + proxy: Option, + stealth: bool, + user_agent: Option, ) -> anyhow::Result<()> { let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = TcpListener::bind(&addr).await?; @@ -46,7 +55,7 @@ pub async fn start_with_options( .run_until(async { let (msg_tx, msg_rx) = mpsc::unbounded_channel::(); - let processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy, stealth)); + let processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy, stealth, user_agent)); loop { match listener.accept().await { @@ -72,8 +81,9 @@ async fn cdp_processor( mut rx: mpsc::UnboundedReceiver, proxy: Option, stealth: bool, + user_agent: Option, ) { - let mut ctx = CdpContext::new_with_options(proxy, stealth); + let mut ctx = CdpContext::new_with_full_options(proxy, stealth, user_agent); let (itx, irx) = mpsc::unbounded_channel::(); ctx.intercept_tx = Some(itx); let mut intercept_rx: Option> = Some(irx); diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 315c994..d19b34b 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -152,9 +152,9 @@ async fn main() -> anyhow::Result<()> { if workers > 1 { tracing::info!("{} worker processes", workers); - run_multi_worker_serve(port, workers, proxy, stealth).await?; + run_multi_worker_serve(port, workers, proxy, stealth, user_agent).await?; } else { - obscura_cdp::start_with_options(port, proxy, stealth).await?; + obscura_cdp::start_with_full_options(port, proxy, stealth, user_agent).await?; } } Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, quiet }) => { @@ -180,6 +180,7 @@ async fn run_multi_worker_serve( workers: u16, proxy: Option, stealth: bool, + user_agent: Option, ) -> anyhow::Result<()> { use tokio::net::TcpListener; use tokio::io::AsyncWriteExt as _; @@ -194,6 +195,9 @@ async fn run_multi_worker_serve( if let Some(ref p) = proxy { cmd.arg("--proxy").arg(p); } + if let Some(ref ua) = user_agent { + cmd.arg("--user-agent").arg(ua); + } if stealth { cmd.arg("--stealth"); }