lightpanda (#646)

* lightpanda

* lightpanda benchmarks

* improvements

* fixes

* improvements
This commit is contained in:
Chris Tate
2026-03-06 11:16:37 -06:00
committed by GitHub
parent 36c2e06f89
commit 0da54c7038
25 changed files with 2190 additions and 74 deletions
+1
View File
@@ -2118,6 +2118,7 @@ mod tests {
confirm_actions: None,
confirm_interactive: false,
native: false,
engine: None,
}
}
+4
View File
@@ -234,6 +234,7 @@ pub struct DaemonOptions<'a> {
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
pub engine: Option<&'a str>,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
@@ -297,6 +298,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
if let Some(ca) = opts.confirm_actions {
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
}
if let Some(engine) = opts.engine {
cmd.env("AGENT_BROWSER_ENGINE", engine);
}
}
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
+12
View File
@@ -42,6 +42,7 @@ pub struct Config {
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
pub engine: Option<String>,
}
impl Config {
@@ -84,6 +85,7 @@ impl Config {
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
engine: other.engine.or(self.engine),
}
}
}
@@ -158,6 +160,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--engine",
];
let mut i = 0;
while i < args.len() {
@@ -236,6 +239,7 @@ pub struct Flags {
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
pub engine: Option<String>,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -342,6 +346,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.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),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -567,6 +572,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--engine" => {
if let Some(s) = args.get(i + 1) {
flags.engine = Some(s.clone());
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
@@ -628,6 +639,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--action-policy",
"--confirm-actions",
"--config",
"--engine",
];
let mut i = 0;
+12 -2
View File
@@ -272,9 +272,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");
@@ -413,6 +417,7 @@ fn main() {
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
native: flags.native,
engine: flags.engine.as_deref(),
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
Ok(result) => result,
@@ -706,7 +711,8 @@ fn main() {
|| flags.user_agent.is_some()
|| flags.allow_file_access
|| 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()
{
@@ -780,6 +786,10 @@ fn main() {
launch_cmd["allowedDomains"] = json!(domains);
}
if let Some(ref engine) = flags.engine {
launch_cmd["engine"] = json!(engine);
}
match send_command(launch_cmd, &flags.session) {
Ok(resp) if !resp.success => {
// Launch command failed (e.g., invalid state file, profile error)
+9 -2
View File
@@ -726,6 +726,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 +744,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;
@@ -936,6 +937,12 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
}
}
let engine = cmd
.get("engine")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
let options = LaunchOptions {
headless,
executable_path: cmd
@@ -1000,7 +1007,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 {
+94 -19
View File
@@ -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(())
}
/// Validates that Chrome-only options are not used with Lightpanda.
fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
if options
.extensions
.as_ref()
.map(|e| !e.is_empty())
.unwrap_or(false)
{
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();
@@ -105,37 +134,85 @@ impl WaitUntil {
}
}
pub enum BrowserProcess {
Chrome(ChromeProcess),
Lightpanda(LightpandaProcess),
}
impl BrowserProcess {
pub fn kill(&mut self) {
match self {
BrowserProcess::Chrome(p) => p.kill(),
BrowserProcess::Lightpanda(p) => p.kill(),
}
}
}
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 lp = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
.await
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
let url = lp.ws_url.clone();
(url, BrowserProcess::Lightpanda(lp))
}
_ => {
let chrome = tokio::task::spawn_blocking(move || launch_chrome(&options))
.await
.map_err(|e| format!("Chrome launch task failed: {}", e))??;
let url = chrome.ws_url.clone();
(url, BrowserProcess::Chrome(chrome))
}
};
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 +274,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,
@@ -501,15 +578,13 @@ impl BrowserManager {
}
pub async fn close(&mut self) -> Result<(), String> {
// Close the browser via CDP if possible
let _ = self
.client
.send_command_no_params("Browser.close", None)
.await;
// Kill Chrome process if we own it
if let Some(ref mut chrome) = self.chrome_process {
chrome.kill();
if let Some(ref mut process) = self.browser_process {
process.kill();
}
Ok(())
@@ -538,7 +613,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
+300
View File
@@ -0,0 +1,300 @@
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();
}
}
pub struct LightpandaLaunchOptions {
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub port: Option<u16>,
}
impl Default for LightpandaLaunchOptions {
fn default() -> Self {
Self {
executable_path: None,
proxy: None,
port: None,
}
}
}
pub fn find_lightpanda() -> Option<PathBuf> {
// Check PATH via `which`
#[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));
}
}
}
}
// Common install locations
if let Some(home) = dirs::home_dir() {
let candidates = [
home.join(".lightpanda/lightpanda"),
home.join(".local/bin/lightpanda"),
];
for c in &candidates {
if c.exists() {
return Some(c.clone());
}
}
}
// npm package binary: @lightpanda/browser installs to node_modules/.bin
// Not checked here since the user would typically have it in PATH.
None
}
pub fn launch_lightpanda(
options: &LightpandaLaunchOptions,
) -> Result<LightpandaProcess, String> {
let binary_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
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(p) => p,
None => TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
};
let port_str = port.to_string();
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
port_str,
];
if let Some(ref proxy) = options.proxy {
args.push("--http_proxy".to_string());
args.push(proxy.clone());
}
// Disable inactivity timeout so the connection stays alive during long sessions
args.push("--timeout".to_string());
args.push("0".to_string());
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))?;
// Lightpanda logs to stderr
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),
})
}
/// Parse Lightpanda's stderr for the server address.
/// Lightpanda outputs lines like:
/// INFO app : server running . . . address = 127.0.0.1:9222
///
/// Returns the address and the reader so the caller can keep the pipe alive.
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> {
// Match "address = HOST:PORT" anywhere in the line
if let Some(idx) = line.find("address = ") {
let addr = line[idx + "address = ".len()..].trim().to_string();
if !addr.is_empty() {
return Some(addr);
}
}
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(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n ")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_address_standard() {
// Lightpanda outputs the address on a separate indented line
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() {
// On most CI/dev machines Lightpanda won't be installed
// Just verify the function doesn't panic
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
View File
@@ -1,3 +1,4 @@
pub mod chrome;
pub mod client;
pub mod lightpanda;
pub mod types;
+2
View File
@@ -2448,6 +2448,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
@@ -2504,6 +2505,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):