Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 46 additions & 38 deletions cli/src/native/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -5538,24 +5544,26 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
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
Expand Down
65 changes: 65 additions & 0 deletions cli/src/native/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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);
Expand Down Expand Up @@ -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" => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down
156 changes: 156 additions & 0 deletions cli/src/native/e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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": ["<all_urls>"],
"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);
}