diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 2cf07ea4..c1e4885d 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -3601,32 +3601,38 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result< .unwrap_or_else(|_| "about:blank".to_string()) }; - // Capture current cookies - let cookies_result = mgr - .client - .send_command_no_params("Network.getAllCookies", Some(&old_session_id)) - .await - .ok(); + // Extensions only run in the default browser context, so skip creating + // an isolated context when extensions are loaded. + let (create_target_params, cookies_result) = if mgr.has_extensions { + (json!({ "url": "about:blank" }), None) + } else { + // Capture current cookies for transfer to the new context + let cookies = mgr + .client + .send_command_no_params("Network.getAllCookies", Some(&old_session_id)) + .await + .ok(); - // Create new browser context - let ctx_result = mgr - .client - .send_command_no_params("Target.createBrowserContext", None) - .await?; - let context_id = ctx_result - .get("browserContextId") - .and_then(|v| v.as_str()) - .ok_or("Failed to get browserContextId")? - .to_string(); + // Create new browser context for isolation + let ctx_result = mgr + .client + .send_command_no_params("Target.createBrowserContext", None) + .await?; + let context_id = ctx_result + .get("browserContextId") + .and_then(|v| v.as_str()) + .ok_or("Failed to get browserContextId")? + .to_string(); + + ( + json!({ "url": "about:blank", "browserContextId": context_id }), + cookies, + ) + }; - // Create page in new context let create_result: CreateTargetResult = mgr .client - .send_command_typed( - "Target.createTarget", - &json!({ "url": "about:blank", "browserContextId": context_id }), - None, - ) + .send_command_typed("Target.createTarget", &create_target_params, None) .await?; let attach_result: AttachToTargetResult = mgr @@ -3663,7 +3669,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result< .await; } - // Transfer cookies to new context + // Transfer cookies to new context (not needed when using default context) if let Some(ref cr) = cookies_result { if let Some(cookie_arr) = cr.get("cookies").and_then(|v| v.as_array()) { if !cookie_arr.is_empty() { @@ -5538,24 +5544,26 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result Result { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; - // Create a new browser context - let context_result = mgr - .client - .send_command_no_params("Target.createBrowserContext", None) - .await?; - let context_id = context_result - .get("browserContextId") - .and_then(|v| v.as_str()) - .ok_or("Failed to create browser context")? - .to_string(); + // Extensions only run in the default browser context, so skip creating + // an isolated context when extensions are loaded. + let create_target_params = if mgr.has_extensions { + json!({ "url": "about:blank" }) + } else { + let context_result = mgr + .client + .send_command_no_params("Target.createBrowserContext", None) + .await?; + let context_id = context_result + .get("browserContextId") + .and_then(|v| v.as_str()) + .ok_or("Failed to create browser context")? + .to_string(); + json!({ "url": "about:blank", "browserContextId": context_id }) + }; let create_result: super::cdp::types::CreateTargetResult = mgr .client - .send_command_typed( - "Target.createTarget", - &json!({ "url": "about:blank", "browserContextId": context_id }), - None, - ) + .send_command_typed("Target.createTarget", &create_target_params, None) .await?; let attach: super::cdp::types::AttachToTargetResult = mgr diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 1a6468c5..d687a8b8 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -199,6 +199,11 @@ pub struct BrowserManager { default_timeout_ms: u64, /// Stored download path from launch options, re-applied to new contexts (e.g., recording) pub download_path: Option, + /// Whether the browser was launched with extensions. Extensions only run + /// in the default browser context, so callers that create new contexts + /// (e.g. recording, window_new) must skip `Target.createBrowserContext` + /// when this is `true`. + pub has_extensions: bool, } const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); @@ -235,6 +240,11 @@ impl BrowserManager { let user_agent = options.user_agent.clone(); let color_scheme = options.color_scheme.clone(); let download_path = options.download_path.clone(); + let has_extensions = options + .extensions + .as_ref() + .map(|e| !e.is_empty()) + .unwrap_or(false); let (ws_url, process) = match engine { "lightpanda" => { @@ -268,6 +278,7 @@ impl BrowserManager { active_page_index: 0, default_timeout_ms: 25_000, download_path: download_path.clone(), + has_extensions, }; manager.discover_and_attach_targets().await?; manager @@ -333,6 +344,7 @@ impl BrowserManager { active_page_index: 0, default_timeout_ms: 10_000, download_path: None, // CDP connections don't have a launch-time download path + has_extensions: false, }; manager.discover_and_attach_targets().await?; @@ -1276,6 +1288,7 @@ async fn initialize_lightpanda_manager( active_page_index: 0, default_timeout_ms: 25_000, download_path: None, + has_extensions: false, }; match discover_and_attach_lightpanda_targets(&mut manager, deadline).await { @@ -1597,6 +1610,58 @@ mod tests { assert!(!is_internal_chrome_target("about:blank")); } + // ----------------------------------------------------------------------- + // has_extensions field tests + // ----------------------------------------------------------------------- + + /// Extensions present → has_extensions must be true so that recording and + /// window_new skip creating isolated (incognito-like) browser contexts + /// where extensions are not injected. + #[test] + fn test_has_extensions_true_when_extensions_present() { + let opts = LaunchOptions { + extensions: Some(vec!["/tmp/my-ext".to_string()]), + ..Default::default() + }; + let has = opts + .extensions + .as_ref() + .map(|e| !e.is_empty()) + .unwrap_or(false); + assert!( + has, + "has_extensions should be true when extensions are provided" + ); + } + + #[test] + fn test_has_extensions_false_when_no_extensions() { + let opts = LaunchOptions::default(); + let has = opts + .extensions + .as_ref() + .map(|e| !e.is_empty()) + .unwrap_or(false); + assert!(!has, "has_extensions should be false when no extensions"); + } + + #[test] + fn test_has_extensions_false_when_empty_vec() { + let opts = LaunchOptions { + extensions: Some(vec![]), + ..Default::default() + }; + let has = opts + .extensions + .as_ref() + .map(|e| !e.is_empty()) + .unwrap_or(false); + assert!( + !has, + "has_extensions should be false for empty extensions vec" + ); + } + // ----------------------------------------------------------------------- // poll_network_idle tests // ----------------------------------------------------------------------- diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 9ef464da..42971d60 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -10,6 +10,8 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use futures_util::StreamExt; use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::test_utils::EnvGuard; @@ -3463,3 +3465,157 @@ async fn e2e_headers_case_insensitive_no_duplicates() { let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } + +// --------------------------------------------------------------------------- +// Recording + extensions: extension must be active in the recorded tab +// Regression test for vercel-labs/agent-browser#890 +// --------------------------------------------------------------------------- + +/// Creates a temporary MV3 extension that sets a `data-test-ext` attribute on +/// the document element of every page, and returns the path to the extension directory. +fn create_test_extension() -> PathBuf { + let dir = std::env::temp_dir().join(format!("agent-browser-test-ext-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).expect("create test extension dir"); + + fs::write( + dir.join("manifest.json"), + r#"{ + "manifest_version": 3, + "name": "Test Extension", + "version": "1.0", + "content_scripts": [{ + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + }] +}"#, + ) + .expect("write manifest.json"); + + fs::write( + dir.join("content.js"), + "document.documentElement.setAttribute('data-test-ext', 'true');", + ) + .expect("write content.js"); + + dir +} + +#[tokio::test] +#[ignore] +async fn e2e_record_start_loads_extension_in_recorded_tab() { + let ext_dir = create_test_extension(); + let ext_path = ext_dir.to_str().unwrap().to_string(); + let mut state = DaemonState::new(); + + // Launch headed (required for extensions) with the test extension + let resp = execute_command( + &json!({ + "id": "1", + "action": "launch", + "headless": false, + "extensions": [ext_path] + }), + &mut state, + ) + .await; + assert_success(&resp); + + // Start recording — this creates a new page for recording + let rec_path = std::env::temp_dir().join(format!("test-rec-{}.webm", uuid::Uuid::new_v4())); + let resp = execute_command( + &json!({ + "id": "2", + "action": "recording_start", + "path": rec_path.to_str().unwrap(), + "url": "https://example.com" + }), + &mut state, + ) + .await; + assert_success(&resp); + + // Wait for content script to inject + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // Evaluate in the recorded tab — extension should have set data-test-ext + let resp = execute_command( + &json!({ "id": "3", "action": "evaluate", "script": "document.documentElement.getAttribute('data-test-ext')" }), + &mut state, + ) + .await; + assert_success(&resp); + assert_eq!( + get_data(&resp)["result"], + "true", + "Extension content script was not injected into the recorded tab" + ); + + // Stop recording + let resp = execute_command( + &json!({ "id": "4", "action": "recording_stop" }), + &mut state, + ) + .await; + assert_success(&resp); + + // Cleanup + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + let _ = fs::remove_dir_all(&ext_dir); + let _ = fs::remove_file(&rec_path); +} + +#[tokio::test] +#[ignore] +async fn e2e_window_new_loads_extension() { + let ext_dir = create_test_extension(); + let ext_path = ext_dir.to_str().unwrap().to_string(); + let mut state = DaemonState::new(); + + // Launch with extension + let resp = execute_command( + &json!({ + "id": "1", + "action": "launch", + "headless": false, + "extensions": [ext_path] + }), + &mut state, + ) + .await; + assert_success(&resp); + + // Open a new window — also creates a new browser context + let resp = execute_command(&json!({ "id": "2", "action": "window_new" }), &mut state).await; + assert_success(&resp); + + // Navigate to a page so the content script fires + let resp = execute_command( + &json!({ "id": "3", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + // Wait for content script + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // Verify extension is active in the new window + let resp = execute_command( + &json!({ "id": "4", "action": "evaluate", "script": "document.documentElement.getAttribute('data-test-ext')" }), + &mut state, + ) + .await; + assert_success(&resp); + assert_eq!( + get_data(&resp)["result"], + "true", + "Extension content script was not injected into the new window" + ); + + // Cleanup + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + let _ = fs::remove_dir_all(&ext_dir); +}