feat(sync): 同步 upstream 改动并升级到 0.16.3-fork.5
This commit is contained in:
@@ -2075,6 +2075,8 @@ mod tests {
|
||||
allow_file_access: false,
|
||||
device: None,
|
||||
auto_connect: false,
|
||||
native: false,
|
||||
engine: None,
|
||||
session_name: None,
|
||||
parallel: None,
|
||||
cli_executable_path: false,
|
||||
@@ -2087,6 +2089,8 @@ mod tests {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_native: false,
|
||||
cli_engine: false,
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
|
||||
+61
-25
@@ -396,11 +396,18 @@ pub fn ensure_daemon(
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
native: bool,
|
||||
engine: Option<&str>,
|
||||
download_path: Option<&str>,
|
||||
tab_group: Option<&str>,
|
||||
tab_group_plugin_id: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
let daemon_path = resolve_daemon_path()?;
|
||||
let daemon_path = if native {
|
||||
let exe = env::current_exe().map_err(|e| e.to_string())?;
|
||||
exe.canonicalize().unwrap_or(exe)
|
||||
} else {
|
||||
resolve_daemon_path()?
|
||||
};
|
||||
|
||||
// Project policy: the default runtime channel is a singleton control plane.
|
||||
// Before touching it, reap all non-default channels to avoid stale daemon reuse.
|
||||
@@ -482,16 +489,21 @@ pub fn ensure_daemon(
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path)
|
||||
.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
let mut cmd = if native {
|
||||
Command::new(&daemon_path)
|
||||
} else {
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path);
|
||||
cmd
|
||||
};
|
||||
cmd.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
@@ -544,6 +556,9 @@ pub fn ensure_daemon(
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
@@ -573,7 +588,13 @@ pub fn ensure_daemon(
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
.map_err(|e| {
|
||||
if native {
|
||||
format!("Failed to start native daemon: {}", e)
|
||||
} else {
|
||||
format!("Failed to start daemon: {}", e)
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -581,18 +602,24 @@ pub fn ensure_daemon(
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
|
||||
// and automatically quotes arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path)
|
||||
.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
let mut cmd = if native {
|
||||
Command::new(&daemon_path)
|
||||
} else {
|
||||
// On Windows, call node directly. Command::new handles PATH
|
||||
// resolution (node.exe or node.cmd) and automatically quotes
|
||||
// arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path);
|
||||
cmd
|
||||
};
|
||||
cmd.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
@@ -645,6 +672,9 @@ pub fn ensure_daemon(
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
@@ -670,7 +700,13 @@ pub fn ensure_daemon(
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
.map_err(|e| {
|
||||
if native {
|
||||
format!("Failed to start native daemon: {}", e)
|
||||
} else {
|
||||
format!("Failed to start daemon: {}", e)
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct Config {
|
||||
pub allow_file_access: Option<bool>,
|
||||
pub cdp: Option<String>,
|
||||
pub auto_connect: Option<bool>,
|
||||
pub native: Option<bool>,
|
||||
pub engine: Option<String>,
|
||||
pub headers: Option<String>,
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
@@ -72,6 +74,8 @@ impl Config {
|
||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||
cdp: other.cdp.or(self.cdp),
|
||||
auto_connect: other.auto_connect.or(self.auto_connect),
|
||||
native: other.native.or(self.native),
|
||||
engine: other.engine.or(self.engine),
|
||||
headers: other.headers.or(self.headers),
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
@@ -152,6 +156,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
"--parallel",
|
||||
"--engine",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -222,6 +227,9 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub native: bool,
|
||||
/// Browser engine for native local launches. `chrome` is the default.
|
||||
pub engine: Option<String>,
|
||||
// Defaults to "default" when unset in default runtime mode.
|
||||
// In --parallel mode, defaults to None unless explicitly provided on CLI.
|
||||
pub session_name: Option<String>,
|
||||
@@ -251,6 +259,8 @@ pub struct Flags {
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_native: bool,
|
||||
pub cli_engine: bool,
|
||||
pub cli_tab_group: bool,
|
||||
pub cli_tab_group_plugin_id: bool,
|
||||
pub cli_session_name: bool,
|
||||
@@ -314,6 +324,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(false),
|
||||
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
|
||||
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -348,6 +360,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_native: false,
|
||||
cli_engine: false,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
cli_session_name: false,
|
||||
@@ -488,6 +502,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--native" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.native = val;
|
||||
flags.cli_native = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--engine" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.engine = Some(s.clone());
|
||||
flags.cli_engine = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
@@ -595,6 +624,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
"--native",
|
||||
"--annotate",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
@@ -621,6 +651,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--wait-until",
|
||||
"--parallel",
|
||||
"--config",
|
||||
"--engine",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -1244,6 +1275,12 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_engine() {
|
||||
let cleaned = clean_args(&args("--engine lightpanda open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_with_config_flag() {
|
||||
use std::io::Write;
|
||||
@@ -1353,6 +1390,57 @@ mod tests {
|
||||
assert!(!flags.auto_connect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_false() {
|
||||
let flags = parse_flags(&args("--native false open example.com"));
|
||||
assert!(!flags.native);
|
||||
assert!(flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_flag() {
|
||||
let flags = parse_flags(&args("--engine lightpanda open example.com"));
|
||||
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
|
||||
assert!(flags.cli_engine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_from_env() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_ENGINE"]);
|
||||
env::set_var("AGENT_BROWSER_ENGINE", "lightpanda");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
|
||||
assert!(!flags.cli_engine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--native open example.com"));
|
||||
assert!(flags.native);
|
||||
assert!(flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_from_env_sets_native_without_cli_marker() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_NATIVE"]);
|
||||
env::set_var("AGENT_BROWSER_NATIVE", "1");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(flags.native);
|
||||
assert!(!flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserializes_native() {
|
||||
let config: Config = serde_json::from_str(r#"{"native": true}"#).unwrap();
|
||||
assert_eq!(config.native, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserializes_engine() {
|
||||
let config: Config = serde_json::from_str(r#"{"engine": "lightpanda"}"#).unwrap();
|
||||
assert_eq!(config.engine.as_deref(), Some("lightpanda"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--full open example.com"));
|
||||
|
||||
+29
-2
@@ -3,6 +3,7 @@ mod commands;
|
||||
mod connection;
|
||||
mod flags;
|
||||
mod install;
|
||||
mod native;
|
||||
mod output;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
@@ -86,6 +87,17 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
|
||||
}
|
||||
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(native::daemon::run_daemon(&session));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore SIGPIPE to prevent panic when piping to head/tail
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
@@ -93,9 +105,13 @@ fn main() {
|
||||
}
|
||||
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let flags = parse_flags(&args);
|
||||
let mut flags = parse_flags(&args);
|
||||
let clean = clean_args(&args);
|
||||
|
||||
if flags.engine.is_some() && !flags.native {
|
||||
flags.native = true;
|
||||
}
|
||||
|
||||
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
|
||||
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
|
||||
|
||||
@@ -262,6 +278,8 @@ fn main() {
|
||||
flags.device.as_deref(),
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.native,
|
||||
flags.engine.as_deref(),
|
||||
flags.download_path.as_deref(),
|
||||
flags.tab_group.as_deref(),
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
@@ -316,6 +334,8 @@ fn main() {
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
flags.cli_native.then_some("--native"),
|
||||
flags.cli_engine.then_some("--engine"),
|
||||
flags.cli_tab_group.then_some("--tab-group"),
|
||||
flags
|
||||
.cli_tab_group_plugin_id
|
||||
@@ -407,6 +427,9 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref engine) = flags.engine {
|
||||
launch_cmd["engine"] = json!(engine);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
@@ -505,6 +528,9 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref engine) = flags.engine {
|
||||
launch_cmd["engine"] = json!(engine);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
@@ -655,7 +681,8 @@ fn main() {
|
||||
|| flags.allow_file_access
|
||||
|| flags.debug
|
||||
|| flags.color_scheme.is_some()
|
||||
|| flags.download_path.is_some())
|
||||
|| flags.download_path.is_some()
|
||||
|| flags.engine.is_some())
|
||||
&& flags.cdp.is_none()
|
||||
&& flags.provider.is_none()
|
||||
&& !attached_to_existing_browser
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -2458,7 +2458,7 @@ Options:
|
||||
--json JSON output
|
||||
--full, -f Full page screenshot
|
||||
--annotate Annotated screenshot with numbered labels and legend
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED=1/true)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
|
||||
@@ -2480,6 +2480,7 @@ Options:
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
|
||||
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
|
||||
--engine <name> Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE)
|
||||
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
@@ -2520,7 +2521,7 @@ Environment:
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
||||
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless)
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless; accepts 1 or true)
|
||||
AGENT_BROWSER_JSON JSON output
|
||||
AGENT_BROWSER_FULL Full page screenshot
|
||||
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend
|
||||
@@ -2550,6 +2551,7 @@ Environment:
|
||||
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
|
||||
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
|
||||
|
||||
Install (recommended, fastest - native Rust CLI):
|
||||
|
||||
Reference in New Issue
Block a user