feat(sync): 同步 upstream 改动并升级到 0.16.3-fork.5
This commit is contained in:
+72
-40
@@ -167,13 +167,14 @@ impl DaemonState {
|
||||
if let Ok(te) =
|
||||
serde_json::from_value::<TargetCreatedEvent>(event.params.clone())
|
||||
{
|
||||
if te.target_info.target_type == "page"
|
||||
if (te.target_info.target_type == "page"
|
||||
|| te.target_info.target_type == "webview")
|
||||
&& !te.target_info.url.is_empty()
|
||||
{
|
||||
let already_tracked = self
|
||||
.browser
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.has_target(&te.target_info.target_id));
|
||||
.is_none_or(|b| b.has_target(&te.target_info.target_id));
|
||||
if !already_tracked {
|
||||
new_targets.push(te);
|
||||
}
|
||||
@@ -443,6 +444,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
session_id: attach.session_id,
|
||||
url: te.target_info.url.clone(),
|
||||
title: te.target_info.title.clone(),
|
||||
target_type: te.target_info.target_type.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -549,16 +551,16 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
}
|
||||
|
||||
// WebDriver backend: reject unsupported CDP-only actions
|
||||
if matches!(state.backend_type, BackendType::WebDriver) {
|
||||
if WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) {
|
||||
return error_response(
|
||||
&id,
|
||||
&format!(
|
||||
"Action '{}' is not supported on the WebDriver backend",
|
||||
action
|
||||
),
|
||||
);
|
||||
}
|
||||
if matches!(state.backend_type, BackendType::WebDriver)
|
||||
&& WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
|
||||
{
|
||||
return error_response(
|
||||
&id,
|
||||
&format!(
|
||||
"Action '{}' is not supported on the WebDriver backend",
|
||||
action
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let result = match action {
|
||||
@@ -726,6 +728,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
|
||||
async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
let options = launch_options_from_env();
|
||||
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
|
||||
|
||||
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
|
||||
let mgr = BrowserManager::connect_cdp(&cdp).await?;
|
||||
@@ -743,7 +746,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mgr = BrowserManager::launch(options).await?;
|
||||
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
|
||||
state.browser = Some(mgr);
|
||||
state.subscribe_to_browser_events();
|
||||
try_auto_restore_state(state).await;
|
||||
@@ -835,20 +838,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.get("autoConnect")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let engine = cmd
|
||||
.get("engine")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
|
||||
|
||||
// Relaunch logic: check if we can reuse the existing connection
|
||||
let needs_relaunch = if let Some(ref mgr) = state.browser {
|
||||
let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some();
|
||||
let was_cdp = mgr.is_cdp_connection();
|
||||
if has_cdp_arg != was_cdp {
|
||||
true
|
||||
} else if has_cdp_arg && !mgr.is_connection_alive().await {
|
||||
true
|
||||
} else if auto_connect && !mgr.is_connection_alive().await {
|
||||
true
|
||||
} else {
|
||||
!mgr.is_connection_alive().await
|
||||
}
|
||||
has_cdp_arg != was_cdp || !mgr.is_connection_alive().await
|
||||
} else {
|
||||
true
|
||||
};
|
||||
@@ -1000,7 +1000,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.domain_filter = Some(DomainFilter::new(domains));
|
||||
}
|
||||
|
||||
state.browser = Some(BrowserManager::launch(options).await?);
|
||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
|
||||
if let Some(ref filter) = state.domain_filter {
|
||||
@@ -2469,6 +2469,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
|
||||
session_id: new_session_id.clone(),
|
||||
url: nav_url.clone(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
|
||||
// Navigate to URL
|
||||
@@ -3225,12 +3226,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
.send_command_no_params("Page.getFrameTree", Some(&session_id))
|
||||
.await?;
|
||||
|
||||
fn find_frame(
|
||||
tree: &Value,
|
||||
selector: Option<&str>,
|
||||
name: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Option<String> {
|
||||
fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
|
||||
let frame = tree.get("frame")?;
|
||||
let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@@ -3249,7 +3245,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
if let Some(id) = find_frame(child, selector, name, url) {
|
||||
if let Some(id) = find_frame(child, name, url) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
@@ -3274,13 +3270,13 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
);
|
||||
let result = mgr.evaluate(&js, None).await?;
|
||||
let frame_name = result.as_str().ok_or("Could not find frame for selector")?;
|
||||
if let Some(frame_id) = find_frame(frame_tree, None, Some(frame_name), None) {
|
||||
if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) {
|
||||
state.active_frame_id = Some(frame_id);
|
||||
return Ok(json!({ "frame": frame_name }));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame_id) = find_frame(frame_tree, selector, name, url) {
|
||||
if let Some(frame_id) = find_frame(frame_tree, name, url) {
|
||||
let label = name.or(url).unwrap_or("frame");
|
||||
state.active_frame_id = Some(frame_id);
|
||||
return Ok(json!({ "frame": label }));
|
||||
@@ -4008,14 +4004,13 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
|
||||
Ok(Ok(event)) => {
|
||||
if event.method == "Page.downloadProgress"
|
||||
&& event.session_id.as_deref() == Some(&session_id)
|
||||
&& event.params.get("state").and_then(|v| v.as_str()) == Some("completed")
|
||||
{
|
||||
if event.params.get("state").and_then(|v| v.as_str()) == Some("completed") {
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("download");
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("download");
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => return Err("Event stream closed".to_string()),
|
||||
@@ -4064,6 +4059,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
session_id: attach.session_id,
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
|
||||
if let Some(viewport) = cmd.get("viewport") {
|
||||
@@ -5134,6 +5130,38 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success_response_structure() {
|
||||
let resp = success_response("cmd-1", json!({"url": "https://example.com"}));
|
||||
@@ -5174,7 +5202,10 @@ mod tests {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
||||
_guard.set("AGENT_BROWSER_HEADED", "1");
|
||||
let opts = launch_options_from_env();
|
||||
assert!(!opts.headless, "AGENT_BROWSER_HEADED=1 should set headless=false");
|
||||
assert!(
|
||||
!opts.headless,
|
||||
"AGENT_BROWSER_HEADED=1 should set headless=false"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5226,6 +5257,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_roundtrip_via_actions() {
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let set_cmd = json!({
|
||||
|
||||
@@ -215,16 +215,15 @@ fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
return serde_json::from_str(&json_str)
|
||||
.map_err(|e| format!("Invalid profile data: {}", e));
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
|
||||
+116
-19
@@ -7,6 +7,7 @@ use super::cdp::chrome::{
|
||||
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
|
||||
};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
|
||||
use super::cdp::types::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +56,34 @@ pub fn validate_launch_options(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
|
||||
if options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|exts| !exts.is_empty())
|
||||
{
|
||||
return Err("Extensions are not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.profile.is_some() {
|
||||
return Err("Profiles are not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.storage_state.is_some() {
|
||||
return Err("Storage state is not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.allow_file_access {
|
||||
return Err("File access is not supported with Lightpanda".to_string());
|
||||
}
|
||||
if !options.headless {
|
||||
return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
|
||||
}
|
||||
if !options.args.is_empty() {
|
||||
return Err(
|
||||
"Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts common error messages into AI-friendly, actionable descriptions.
|
||||
pub fn to_ai_friendly_error(error: &str) -> String {
|
||||
let lower = error.to_lowercase();
|
||||
@@ -86,6 +115,7 @@ pub struct PageInfo {
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub target_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -105,37 +135,72 @@ impl WaitUntil {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BrowserProcess {
|
||||
Chrome(ChromeProcess),
|
||||
Lightpanda(LightpandaProcess),
|
||||
}
|
||||
|
||||
pub struct BrowserManager {
|
||||
pub client: CdpClient,
|
||||
chrome_process: Option<ChromeProcess>,
|
||||
browser_process: Option<BrowserProcess>,
|
||||
pages: Vec<PageInfo>,
|
||||
active_page_index: usize,
|
||||
default_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl BrowserManager {
|
||||
pub async fn launch(options: LaunchOptions) -> Result<Self, String> {
|
||||
validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?;
|
||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||
let engine = engine.unwrap_or("chrome");
|
||||
|
||||
match engine {
|
||||
"chrome" => validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?,
|
||||
"lightpanda" => validate_lightpanda_options(&options)?,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unknown engine '{}'. Supported engines: chrome, lightpanda",
|
||||
engine
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let ignore_https_errors = options.ignore_https_errors;
|
||||
let user_agent = options.user_agent.clone();
|
||||
let color_scheme = options.color_scheme.clone();
|
||||
let download_path = options.download_path.clone();
|
||||
|
||||
let chrome = launch_chrome(&options)?;
|
||||
let ws_url = chrome.ws_url.clone();
|
||||
let (ws_url, process) = match engine {
|
||||
"lightpanda" => {
|
||||
let lp_options = LightpandaLaunchOptions {
|
||||
executable_path: options.executable_path.clone(),
|
||||
proxy: options.proxy.clone(),
|
||||
port: None,
|
||||
};
|
||||
let process = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
|
||||
.await
|
||||
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
|
||||
let ws_url = process.ws_url.clone();
|
||||
(ws_url, BrowserProcess::Lightpanda(process))
|
||||
}
|
||||
_ => {
|
||||
let process = tokio::task::spawn_blocking(move || launch_chrome(&options))
|
||||
.await
|
||||
.map_err(|e| format!("Chrome launch task failed: {}", e))??;
|
||||
let ws_url = process.ws_url.clone();
|
||||
(ws_url, BrowserProcess::Chrome(process))
|
||||
}
|
||||
};
|
||||
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
chrome_process: Some(chrome),
|
||||
browser_process: Some(process),
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 25_000,
|
||||
@@ -197,7 +262,7 @@ impl BrowserManager {
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
chrome_process: None,
|
||||
browser_process: None,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 10_000,
|
||||
@@ -229,7 +294,9 @@ impl BrowserManager {
|
||||
let page_targets: Vec<TargetInfo> = result
|
||||
.target_infos
|
||||
.into_iter()
|
||||
.filter(|t| t.target_type == "page" && !t.url.is_empty())
|
||||
.filter(|t| {
|
||||
(t.target_type == "page" || t.target_type == "webview") && !t.url.is_empty()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if page_targets.is_empty() {
|
||||
@@ -262,6 +329,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
@@ -284,6 +352,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: target.url.clone(),
|
||||
title: target.title.clone(),
|
||||
target_type: target.target_type.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -507,10 +576,11 @@ impl BrowserManager {
|
||||
.send_command_no_params("Browser.close", None)
|
||||
.await;
|
||||
|
||||
if let Some(mut chrome) = self.chrome_process.take() {
|
||||
if let Some(process) = self.browser_process.take() {
|
||||
let timeout = std::time::Duration::from_secs(5);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
chrome.wait_or_kill(timeout);
|
||||
let _ = tokio::task::spawn_blocking(move || match process {
|
||||
BrowserProcess::Chrome(mut chrome) => chrome.wait_or_kill(timeout),
|
||||
BrowserProcess::Lightpanda(mut lightpanda) => lightpanda.kill(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -541,7 +611,7 @@ impl BrowserManager {
|
||||
|
||||
/// Returns true if this manager was connected via CDP (as opposed to local launch).
|
||||
pub fn is_cdp_connection(&self) -> bool {
|
||||
self.chrome_process.is_none()
|
||||
self.browser_process.is_none()
|
||||
}
|
||||
|
||||
/// Ensures the browser has at least one page. If `pages` is empty, creates a new
|
||||
@@ -579,6 +649,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
@@ -611,6 +682,7 @@ impl BrowserManager {
|
||||
"index": i,
|
||||
"title": p.title,
|
||||
"url": p.url,
|
||||
"type": p.target_type,
|
||||
"active": i == self.active_page_index,
|
||||
})
|
||||
})
|
||||
@@ -651,6 +723,7 @@ impl BrowserManager {
|
||||
session_id: attach.session_id,
|
||||
url: target_url.to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = index;
|
||||
|
||||
@@ -1068,6 +1141,30 @@ mod tests {
|
||||
assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_rejects_extensions() {
|
||||
let opts = LaunchOptions {
|
||||
extensions: Some(vec!["/tmp/ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_lightpanda_options(&opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_rejects_headed() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_lightpanda_options(&opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_valid() {
|
||||
let opts = LaunchOptions::default();
|
||||
assert!(validate_lightpanda_options(&opts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_ai_friendly_error_strict_mode() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -123,7 +123,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
let has_extensions = options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.map_or(false, |exts| !exts.is_empty());
|
||||
.is_some_and(|exts| !exts.is_empty());
|
||||
|
||||
// Extensions require headed mode in native Chrome (content scripts are not
|
||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
||||
@@ -144,8 +144,8 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
args.push(format!("--user-data-dir={}", expanded));
|
||||
None
|
||||
} else {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
|
||||
args.push(format!("--user-data-dir={}", dir.display()));
|
||||
@@ -216,14 +216,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
|
||||
})?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
"Failed to capture Chrome stderr".to_string()
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
"Failed to capture Chrome stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let ws_url = match wait_for_ws_url(reader) {
|
||||
@@ -515,10 +512,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
|
||||
// Generic container detection: cgroup contains docker/kubepods/lxc
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker")
|
||||
|| cgroup.contains("kubepods")
|
||||
|| cgroup.contains("lxc")
|
||||
{
|
||||
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -662,10 +656,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_generic() {
|
||||
let lines = vec![
|
||||
"info line".to_string(),
|
||||
"another info line".to_string(),
|
||||
];
|
||||
let lines = vec!["info line".to_string(), "another info line".to_string()];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
@@ -686,10 +677,7 @@ mod tests {
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
@@ -748,14 +736,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--window-size=1920,1080"));
|
||||
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_stderr_drain: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(port) => port,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|listener| listener.local_addr())
|
||||
.map(|addr| addr.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
|
||||
if let Some(ref proxy) = options.proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.clone());
|
||||
}
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let (address, reader) = match wait_for_address(reader) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let ws_url = format!("ws://{}", address);
|
||||
let drain = std::thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_stderr_drain: Some(drain),
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_address(
|
||||
mut reader: BufReader<std::process::ChildStderr>,
|
||||
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Timeout waiting for Lightpanda server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) => {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Lightpanda exited before providing server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
Ok(_) => {
|
||||
let line = buf.trim_end().to_string();
|
||||
if let Some(address) = extract_address(&line) {
|
||||
return Ok((address, reader));
|
||||
}
|
||||
stderr_lines.push(line);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to read Lightpanda stderr: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_address(line: &str) -> Option<String> {
|
||||
if let Some(idx) = line.find("address = ") {
|
||||
let address = line[idx + "address = ".len()..].trim().to_string();
|
||||
if !address.is_empty() {
|
||||
return Some(address);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
||||
if stderr_lines.is_empty() {
|
||||
return format!("{} (no stderr output from Lightpanda)", message);
|
||||
}
|
||||
|
||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
||||
format!(
|
||||
"{}\nLightpanda stderr (last {} lines):\n {}",
|
||||
message,
|
||||
last_lines.len(),
|
||||
last_lines
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|line| line.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_standard() {
|
||||
assert_eq!(
|
||||
extract_address(" address = 127.0.0.1:9222"),
|
||||
Some("127.0.0.1:9222".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_inline() {
|
||||
assert_eq!(
|
||||
extract_address("INFO app : server running address = 127.0.0.1:4567"),
|
||||
Some("127.0.0.1:4567".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_no_match() {
|
||||
assert_eq!(extract_address("INFO app : starting up..."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_stderr() {
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &[]);
|
||||
assert!(msg.contains("no stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let lines = vec![
|
||||
"INFO starting up".to_string(),
|
||||
"ERROR bind failed: address in use".to_string(),
|
||||
];
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &lines);
|
||||
assert!(msg.contains("bind failed"));
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
|
||||
@@ -532,6 +532,7 @@ pub struct BrowserVersionInfo {
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
|
||||
@@ -56,13 +56,11 @@ pub async fn set_cookies(
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
if c.get("url").is_none() && c.get("domain").is_none() {
|
||||
if let Some(url) = current_url {
|
||||
c.as_object_mut()
|
||||
.map(|m| m.insert("url".to_string(), Value::String(url.to_string())));
|
||||
}
|
||||
}
|
||||
c
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -24,6 +24,16 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
let meta_path = socket_dir.join(format!("{}.meta.json", session));
|
||||
if let Ok(current_exe) = env::current_exe() {
|
||||
let daemon_path = current_exe.canonicalize().unwrap_or(current_exe);
|
||||
let cli_version = env::var("AGENT_BROWSER_CLI_VERSION").unwrap_or_default();
|
||||
let meta = json!({
|
||||
"daemonPath": daemon_path.to_string_lossy(),
|
||||
"cliVersion": cli_version,
|
||||
});
|
||||
let _ = fs::write(&meta_path, meta.to_string());
|
||||
}
|
||||
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
@@ -43,6 +53,7 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&meta_path);
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
@@ -185,8 +196,7 @@ async fn handle_connection<S>(
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
activity_tx: UnboundedSender<()>,
|
||||
active_commands: std::sync::Arc<AtomicUsize>,
|
||||
)
|
||||
where
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
@@ -566,6 +566,7 @@ async fn e2e_tabs() {
|
||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||
assert_eq!(tabs.len(), 1);
|
||||
assert_eq!(tabs[0]["active"], true);
|
||||
assert_eq!(tabs[0]["type"], "page");
|
||||
|
||||
// Open new tab
|
||||
let resp = execute_command(
|
||||
@@ -582,6 +583,7 @@ async fn e2e_tabs() {
|
||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||
assert_eq!(tabs.len(), 2);
|
||||
assert_eq!(tabs[1]["active"], true);
|
||||
assert_eq!(tabs[1]["type"], "page");
|
||||
|
||||
// Switch to first tab
|
||||
let resp = execute_command(
|
||||
|
||||
@@ -374,13 +374,22 @@ fn minimal_command(action: &str, id: &str) -> Value {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let result = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(250),
|
||||
execute_command(&cmd, &mut state),
|
||||
)
|
||||
.await;
|
||||
|
||||
let Ok(result) = result else {
|
||||
continue;
|
||||
};
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
|
||||
@@ -65,6 +65,7 @@ const STRUCTURAL_ROLES: &[&str] = &[
|
||||
"RootWebArea",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SnapshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub interactive: bool,
|
||||
@@ -73,18 +74,6 @@ pub struct SnapshotOptions {
|
||||
pub cursor: bool,
|
||||
}
|
||||
|
||||
impl Default for SnapshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
interactive: false,
|
||||
compact: false,
|
||||
depth: None,
|
||||
cursor: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TreeNode {
|
||||
role: String,
|
||||
name: String,
|
||||
@@ -364,8 +353,7 @@ async fn find_cursor_interactive_elements(
|
||||
let escaped = text
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', " ")
|
||||
.replace('\r', " ");
|
||||
.replace(['\n', '\r'], " ");
|
||||
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
|
||||
}
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
|
||||
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user