* Native Rust rewrite of agent-browser daemon

Single-binary Rust implementation replacing the Node.js/Playwright daemon
with direct CDP (Chrome DevTools Protocol) communication. Includes full
command parity, WebDriver/Safari/iOS backend routing, request tracking,
frame context management, CDP protocol codegen, and comprehensive tests.

* improvements

* fix ci

* fixes

* faster builds
This commit is contained in:
Chris Tate
2026-03-03 15:15:57 -06:00
committed by GitHub
parent 857c0b25df
commit 51f5fa484c
52 changed files with 55160 additions and 278 deletions
+95 -49
View File
@@ -41,6 +41,7 @@ pub struct Config {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
}
impl Config {
@@ -82,6 +83,7 @@ impl Config {
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
}
}
}
@@ -180,8 +182,7 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
});
if let Some((source, maybe_path)) = explicit {
let path_str =
maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path = PathBuf::from(&path_str);
if !path.exists() {
return Err(format!("config file not found: {}", path_str));
@@ -234,6 +235,7 @@ pub struct Flags {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -273,66 +275,72 @@ pub fn parse_flags(args: &[String]) -> Flags {
};
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON")
|| config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL")
|| config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED")
|| config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG")
|| config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION").ok()
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION")
.ok()
.or(config.session)
.unwrap_or_else(|| "default".to_string()),
headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
.ok()
.or(config.executable_path),
cdp: config.cdp,
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok()
.or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok()
.or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok()
.or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok()
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass),
args: env::var("AGENT_BROWSER_ARGS").ok()
.or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok()
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
.or(config.user_agent),
provider: env::var("AGENT_BROWSER_PROVIDER").ok()
.or(config.provider),
provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|| config.ignore_https_errors.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok()
.or(config.device),
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),
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok()
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name),
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE")
|| config.annotate.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok()
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
.ok()
.or(config.color_scheme),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
.ok()
.or(config.download_path),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT").ok()
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
.ok()
.and_then(|s| s.parse().ok())
.or(config.max_output),
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok()
.map(|s| s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect())
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
.ok()
.map(|s| {
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
})
.or(config.allowed_domains),
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY").ok()
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
.ok()
.or(config.action_policy),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
.ok()
.or(config.confirm_actions),
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),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -352,22 +360,30 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--json" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.json = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--full" | "-f" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.full = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--headed" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--debug" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.debug = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--session" => {
if let Some(s) = args.get(i + 1) {
@@ -452,13 +468,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--ignore-https-errors" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.ignore_https_errors = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--allow-file-access" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.allow_file_access = val;
flags.cli_allow_file_access = true;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--device" => {
if let Some(d) = args.get(i + 1) {
@@ -469,7 +489,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.auto_connect = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--session-name" => {
if let Some(s) = args.get(i + 1) {
@@ -481,7 +503,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
let (val, consumed) = parse_bool_arg(args, i);
flags.annotate = val;
flags.cli_annotate = true;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--color-scheme" => {
if let Some(s) = args.get(i + 1) {
@@ -499,7 +523,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--max-output" => {
if let Some(s) = args.get(i + 1) {
@@ -512,7 +538,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--allowed-domains" => {
if let Some(s) = args.get(i + 1) {
flags.allowed_domains = Some(
s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect()
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect(),
);
i += 1;
}
@@ -532,7 +561,16 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
if consumed {
i += 1;
}
}
"--config" => {
// Already handled by load_config(); skip the value
@@ -561,6 +599,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -835,7 +874,10 @@ mod tests {
assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app"));
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()]));
assert_eq!(
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
@@ -1144,7 +1186,11 @@ mod tests {
let merged = user.merge(project);
assert_eq!(
merged.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()])
Some(vec![
"/ext1".to_string(),
"/ext2".to_string(),
"/ext3".to_string()
])
);
}