use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct BrowserOptions {
pub name: String,
pub headless: bool,
pub ignore_https_errors: bool,
pub stealth: bool,
pub connect: Option<String>,
pub proxy_server: Option<String>,
pub copy_cookies: bool,
}
impl Default for BrowserOptions {
fn default() -> Self {
Self {
name: "default".into(),
headless: false,
ignore_https_errors: false,
stealth: false,
connect: None,
proxy_server: None,
copy_cookies: false,
}
}
}
pub struct BrowserConnection {
pub ws_endpoint: String,
pub http_endpoint: Option<String>,
pub pid: Option<u32>,
}
pub async fn get_page_ws_url(
http_endpoint: &str,
target_id: &str,
) -> Result<String, BrowserError> {
let url = format!("{}/json/list", http_endpoint.trim_end_matches('/'));
let mut last_err = BrowserError::NotFound("No attempts made".into());
for _ in 0..5 {
match http_get_json(&url, Duration::from_secs(2)).await {
Ok(list) => {
if let Some(pages) = list.as_array() {
for page in pages {
let id = page.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id == target_id
&& let Some(ws) = page.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
return Ok(ws.to_string());
}
}
last_err = BrowserError::NotFound(format!(
"Target {target_id} not found in /json/list"
));
}
}
Err(e) => {
last_err = e;
}
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
Err(last_err)
}
pub fn validate_browser_name(name: &str) -> Result<(), BrowserError> {
if name.is_empty() {
return Err(BrowserError::Launch("Browser name cannot be empty".into()));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(BrowserError::Launch(
"Browser name must contain only alphanumeric characters, hyphens, and underscores".into(),
));
}
Ok(())
}
pub fn validate_proxy_server(value: &str) -> Result<String, BrowserError> {
let invalid = || {
BrowserError::Launch(
"Invalid proxy server <redacted-proxy>: expected http(s)://host:port or socks4/5://host:port"
.into(),
)
};
if value.trim() != value || value.chars().any(char::is_whitespace) {
return Err(invalid());
}
let (scheme, remainder) = value.split_once("://").ok_or_else(invalid)?;
let scheme = scheme.to_ascii_lowercase();
if !matches!(scheme.as_str(), "http" | "https" | "socks4" | "socks5") {
return Err(invalid());
}
if remainder.contains(['?', '#', '@']) {
return Err(invalid());
}
let authority = remainder.strip_suffix('/').unwrap_or(remainder);
if authority.contains('/') || authority.is_empty() {
return Err(invalid());
}
let (host, port) = if let Some(bracketed) = authority.strip_prefix('[') {
let (host, suffix) = bracketed.split_once(']').ok_or_else(invalid)?;
if host.parse::<std::net::Ipv6Addr>().is_err() {
return Err(invalid());
}
let port = suffix.strip_prefix(':').ok_or_else(invalid)?;
(format!("[{}]", host.to_ascii_lowercase()), port)
} else {
let (host, port) = authority.rsplit_once(':').ok_or_else(invalid)?;
if host.contains(':') {
return Err(invalid());
}
(host.to_ascii_lowercase(), port)
};
if host.is_empty() || port.parse::<u16>().ok().is_none_or(|port| port == 0) {
return Err(invalid());
}
Ok(format!("{scheme}://{host}:{port}"))
}
pub fn normalized_proxy_option(
connect: Option<&str>,
proxy_server: Option<&str>,
) -> Result<Option<String>, BrowserError> {
if connect.is_some() && proxy_server.is_some() {
return Err(BrowserError::Launch(
"--proxy-server applies only when chrome-agent launches Chrome; configure the attached browser's proxy before using --connect"
.into(),
));
}
proxy_server.map(validate_proxy_server).transpose()
}
pub async fn resolve_browser(opts: &BrowserOptions) -> Result<BrowserConnection, BrowserError> {
validate_browser_name(&opts.name)?;
let mut resolved = opts.clone();
resolved.proxy_server = normalized_proxy_option(
resolved.connect.as_deref(),
resolved.proxy_server.as_deref(),
)?;
if let Some(endpoint) = &opts.connect {
if endpoint == "auto" {
return auto_discover().await;
}
if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
return Ok(BrowserConnection {
ws_endpoint: endpoint.clone(),
http_endpoint: Some(extract_http_endpoint(endpoint)),
pid: None,
});
}
return resolve_http_endpoint(endpoint).await;
}
launch_browser(&resolved).await
}
async fn launch_browser(opts: &BrowserOptions) -> Result<BrowserConnection, BrowserError> {
let profile_dir = browser_profile_dir(&opts.name)?;
std::fs::create_dir_all(&profile_dir).map_err(|e| {
BrowserError::Launch(format!("Failed to create profile dir: {e}"))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700));
}
let port_file = profile_dir.join("DevToolsActivePort");
let existing = try_reconnect_existing(&port_file).await;
if should_copy_cookies(opts.copy_cookies, existing.is_some()) {
copy_chrome_cookies(&profile_dir)?;
}
if let Some(conn) = existing {
return Ok(conn);
}
let chromium_path = find_chromium()?;
let mut cmd = Command::new(&chromium_path);
cmd.args(managed_launch_args(&profile_dir, opts));
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
let mut child = cmd.spawn().map_err(|e| {
BrowserError::Launch(format!("Failed to launch {}: {e}", chromium_path.display()))
})?;
let pid = child.id();
let port_file = profile_dir.join("DevToolsActivePort");
let ws_endpoint = match wait_for_devtools_port(&port_file, Duration::from_secs(10)).await {
Ok(ws) => ws,
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
};
let http_endpoint = extract_http_endpoint(&ws_endpoint);
Ok(BrowserConnection {
ws_endpoint,
http_endpoint: Some(http_endpoint),
pid: Some(pid),
})
}
fn managed_launch_args(profile_dir: &Path, opts: &BrowserOptions) -> Vec<String> {
let mut args = vec![
format!("--user-data-dir={}", profile_dir.display()),
"--remote-debugging-port=0".into(),
"--no-first-run".into(),
"--no-default-browser-check".into(),
"--disable-background-timer-throttling".into(),
"--disable-backgrounding-occluded-windows".into(),
"--disable-renderer-backgrounding".into(),
];
if let Some(proxy_server) = &opts.proxy_server {
args.push(format!("--proxy-server={proxy_server}"));
}
if opts.headless {
args.push("--headless=new".into());
}
if opts.ignore_https_errors {
args.push("--ignore-certificate-errors".into());
}
if opts.stealth {
args.push("--disable-infobars".into());
args.push("--disable-component-extensions-with-background-pages".into());
}
args
}
const fn should_copy_cookies(copy_requested: bool, reconnecting_to_live: bool) -> bool {
copy_requested && !reconnecting_to_live
}
async fn try_reconnect_existing(port_file: &Path) -> Option<BrowserConnection> {
if !port_file.exists() {
return None;
}
if let Some(ws) = read_devtools_active_port(port_file) {
let http = extract_http_endpoint(&ws);
if http_get_json(&format!("{http}/json/version"), Duration::from_secs(1))
.await
.is_ok()
{
return Some(BrowserConnection {
ws_endpoint: ws,
http_endpoint: Some(http),
pid: None,
});
}
}
let _ = std::fs::remove_file(port_file);
None
}
async fn auto_discover() -> Result<BrowserConnection, BrowserError> {
for candidate in devtools_active_port_candidates() {
if let Some(ws) = read_devtools_active_port(&candidate)
&& probe_ws_endpoint(&ws).await {
return Ok(BrowserConnection {
http_endpoint: Some(extract_http_endpoint(&ws)),
ws_endpoint: ws,
pid: None,
});
}
}
for port in DISCOVERY_PORTS {
if let Ok(ws) = fetch_ws_endpoint(&format!("http://127.0.0.1:{port}")).await {
return Ok(BrowserConnection {
http_endpoint: Some(format!("http://127.0.0.1:{port}")),
ws_endpoint: ws,
pid: None,
});
}
}
Err(BrowserError::NotFound(auto_connect_error_message()))
}
async fn resolve_http_endpoint(endpoint: &str) -> Result<BrowserConnection, BrowserError> {
let ws = fetch_ws_endpoint(endpoint).await.map_err(|_| {
BrowserError::NotFound(format!(
"Could not resolve CDP WebSocket from {endpoint}. \
If Chrome uses built-in remote debugging, run `chrome-agent --connect` \
without a URL for auto-discovery."
))
})?;
Ok(BrowserConnection {
http_endpoint: Some(endpoint.trim_end_matches('/').to_string()),
ws_endpoint: ws,
pid: None,
})
}
pub fn extract_http_from_ws(ws_url: &str) -> String {
extract_http_endpoint(ws_url)
}
fn extract_http_endpoint(ws_url: &str) -> String {
let without_scheme = ws_url
.strip_prefix("ws://")
.or_else(|| ws_url.strip_prefix("wss://"))
.unwrap_or(ws_url);
let host_port = without_scheme.split('/').next().unwrap_or(without_scheme);
format!("http://{host_port}")
}
async fn fetch_ws_endpoint(base_url: &str) -> Result<String, BrowserError> {
let url = format!(
"{}/json/version",
base_url.trim_end_matches('/')
);
let response = http_get_json(&url, Duration::from_secs(2)).await?;
let ws_url = response
.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.ok_or_else(|| BrowserError::NotFound("No webSocketDebuggerUrl in /json/version".into()))?;
Ok(ws_url.to_string())
}
async fn http_get_json(
url: &str,
timeout: Duration,
) -> Result<serde_json::Value, BrowserError> {
let url = url.to_string();
tokio::task::spawn_blocking(move || {
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(timeout))
.timeout_recv_body(Some(timeout))
.build()
.new_agent();
let body = agent
.get(&url)
.header("Accept", "application/json")
.call()
.map_err(|e| BrowserError::NotFound(format!("HTTP request failed: {e}")))?
.body_mut()
.read_to_string()
.map_err(|e| BrowserError::NotFound(format!("Failed to read body: {e}")))?;
serde_json::from_str(&body)
.map_err(|e| BrowserError::NotFound(format!("Invalid JSON: {e}")))
})
.await
.map_err(|e| BrowserError::NotFound(format!("Task failed: {e}")))?
}
async fn probe_ws_endpoint(ws_url: &str) -> bool {
tokio::time::timeout(
Duration::from_millis(500),
tokio_tungstenite::connect_async(ws_url),
)
.await
.is_ok_and(|r| r.is_ok())
}
async fn wait_for_devtools_port(
path: &Path,
timeout: Duration,
) -> Result<String, BrowserError> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Some(ws) = read_devtools_active_port(path) {
return Ok(ws);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(BrowserError::Launch(format!(
"DevToolsActivePort did not appear at {} within {}s",
path.display(),
timeout.as_secs()
)))
}
fn read_devtools_active_port(path: &Path) -> Option<String> {
let contents = std::fs::read_to_string(path).ok()?;
let mut lines = contents.lines();
let port: u16 = lines.next()?.trim().parse().ok()?;
let ws_path = lines.next()?.trim();
if port == 0 || !ws_path.starts_with("/devtools/browser/") {
return None;
}
Some(format!("ws://127.0.0.1:{port}{ws_path}"))
}
fn devtools_active_port_candidates() -> Vec<PathBuf> {
let Some(home) = dirs::home_dir() else {
return vec![];
};
if cfg!(target_os = "macos") {
let base = home.join("Library").join("Application Support");
vec![
base.join("Google/Chrome/DevToolsActivePort"),
base.join("Google/Chrome Canary/DevToolsActivePort"),
base.join("Chromium/DevToolsActivePort"),
base.join("BraveSoftware/Brave-Browser/DevToolsActivePort"),
]
} else if cfg!(target_os = "linux") {
let config = home.join(".config");
vec![
config.join("google-chrome/DevToolsActivePort"),
config.join("chromium/DevToolsActivePort"),
config.join("google-chrome-beta/DevToolsActivePort"),
config.join("google-chrome-unstable/DevToolsActivePort"),
config.join("BraveSoftware/Brave-Browser/DevToolsActivePort"),
]
} else if cfg!(target_os = "windows") {
let local = home.join("AppData").join("Local");
vec![
local.join("Google/Chrome/User Data/DevToolsActivePort"),
local.join("Google/Chrome Beta/User Data/DevToolsActivePort"),
local.join("Google/Chrome SxS/User Data/DevToolsActivePort"),
local.join("Chromium/User Data/DevToolsActivePort"),
local.join("BraveSoftware/Brave-Browser/User Data/DevToolsActivePort"),
]
} else {
vec![]
}
}
const DISCOVERY_PORTS: &[u16] = &[9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229];
fn find_chromium() -> Result<PathBuf, BrowserError> {
if let Some(home) = dirs::home_dir() {
let managed = home
.join(".chrome-agent")
.join("chromium");
if cfg!(target_os = "macos") {
let app = managed.join("Chromium.app/Contents/MacOS/Chromium");
if app.exists() {
return Ok(app);
}
let cft = managed.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if cft.exists() {
return Ok(cft);
}
let cft_x64 = managed.join("chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if cft_x64.exists() {
return Ok(cft_x64);
}
} else if cfg!(target_os = "linux") {
let bin = managed.join("chrome");
if bin.exists() {
return Ok(bin);
}
let cft = managed.join("chrome-linux64/chrome");
if cft.exists() {
return Ok(cft);
}
}
}
let system_candidates: &[&str] = if cfg!(target_os = "macos") {
&[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
]
} else if cfg!(target_os = "linux") {
&[
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
]
} else if cfg!(target_os = "windows") {
&[
"chrome.exe",
]
} else {
&[]
};
for candidate in system_candidates {
let path = PathBuf::from(candidate);
if path.exists() {
return Ok(path);
}
if cfg!(target_os = "linux")
&& let Ok(output) = Command::new("which").arg(candidate).output()
&& output.status.success() {
let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !found.is_empty() {
return Ok(PathBuf::from(found));
}
}
}
Err(BrowserError::NotFound(
"Could not find Chrome or Chromium. Install Chrome and ensure it's on your PATH."
.into(),
))
}
fn copy_chrome_cookies(profile_dir: &Path) -> Result<(), BrowserError> {
let chrome_default = chrome_default_profile_dir()?;
let cookies_src = chrome_default.join("Cookies");
if !cookies_src.exists() {
return Err(BrowserError::Launch(
"Chrome cookies file not found. Is Chrome installed?".into(),
));
}
let cookies_dst = profile_dir.join("Default");
std::fs::create_dir_all(&cookies_dst).map_err(|e| {
BrowserError::Launch(format!("Failed to create Default dir: {e}"))
})?;
std::fs::copy(&cookies_src, cookies_dst.join("Cookies")).map_err(|e| {
BrowserError::Launch(format!("Failed to copy Cookies: {e}"))
})?;
for ext in ["Cookies-journal", "Cookies-wal", "Cookies-shm"] {
let src = chrome_default.join(ext);
if src.exists() {
let _ = std::fs::copy(&src, cookies_dst.join(ext));
}
}
let local_state_src = chrome_default.parent().map(|p| p.join("Local State"));
if let Some(src) = local_state_src
&& src.exists() {
let dst = profile_dir.join("Local State");
let _ = std::fs::copy(&src, dst);
}
eprintln!("Copied cookies from Chrome profile");
Ok(())
}
fn chrome_default_profile_dir() -> Result<PathBuf, BrowserError> {
let base = if cfg!(target_os = "macos") {
dirs::home_dir().map(|h| h.join("Library/Application Support/Google/Chrome/Default"))
} else if cfg!(target_os = "windows") {
dirs::data_local_dir().map(|d| d.join("Google/Chrome/User Data/Default"))
} else {
dirs::config_dir().map(|c| c.join("google-chrome/Default"))
};
base.ok_or_else(|| BrowserError::Launch("Could not locate Chrome profile directory".into()))
}
fn browser_profile_dir(name: &str) -> Result<PathBuf, BrowserError> {
let home = dirs::home_dir().ok_or_else(|| {
BrowserError::Launch("Could not determine home directory".into())
})?;
Ok(home.join(".chrome-agent").join("browsers").join(name).join("chromium-profile"))
}
fn auto_connect_error_message() -> String {
let launch_cmd = if cfg!(target_os = "macos") {
"/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222"
} else if cfg!(target_os = "windows") {
"chrome.exe --remote-debugging-port=9222"
} else {
"google-chrome --remote-debugging-port=9222"
};
format!(
"Could not auto-discover Chrome with remote debugging enabled.\n\
Enable at chrome://inspect/#remote-debugging\n\
or launch with: {launch_cmd}"
)
}
#[derive(Debug, thiserror::Error)]
pub enum BrowserError {
#[error("{0}")]
Launch(String),
#[error("{0}")]
NotFound(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_browser_name_accepts_valid() {
assert!(validate_browser_name("default").is_ok());
assert!(validate_browser_name("my-browser").is_ok());
assert!(validate_browser_name("test_123").is_ok());
}
#[test]
fn validate_browser_name_rejects_traversal() {
assert!(validate_browser_name("../../etc").is_err());
assert!(validate_browser_name("").is_err());
assert!(validate_browser_name("foo bar").is_err());
assert!(validate_browser_name("foo/bar").is_err());
}
#[test]
fn extract_http_from_ws_works() {
assert_eq!(
extract_http_from_ws("ws://127.0.0.1:9222/devtools/browser/abc"),
"http://127.0.0.1:9222"
);
assert_eq!(
extract_http_from_ws("wss://host:443/path"),
"http://host:443"
);
}
#[test]
fn read_devtools_active_port_parses_correctly() {
let dir = std::env::temp_dir().join("chrome-agent_test_devtools");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("DevToolsActivePort");
std::fs::write(&path, "9222\n/devtools/browser/abc-123\n").unwrap();
let result = read_devtools_active_port(&path);
assert_eq!(
result,
Some("ws://127.0.0.1:9222/devtools/browser/abc-123".into())
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_devtools_active_port_rejects_invalid() {
let dir = std::env::temp_dir().join("chrome-agent_test_devtools_bad");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("DevToolsActivePort");
std::fs::write(&path, "not_a_number\n").unwrap();
assert!(read_devtools_active_port(&path).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn should_copy_cookies_only_on_fresh_spawn() {
assert!(should_copy_cookies(true, false));
assert!(!should_copy_cookies(true, true));
assert!(!should_copy_cookies(false, false));
assert!(!should_copy_cookies(false, true));
}
#[test]
fn validates_and_normalizes_supported_proxy_urls() {
assert_eq!(
validate_proxy_server("HTTP://Proxy.Example:8080/").unwrap(),
"http://proxy.example:8080"
);
assert_eq!(
validate_proxy_server("socks5://127.0.0.1:1080").unwrap(),
"socks5://127.0.0.1:1080"
);
assert_eq!(
validate_proxy_server("http://[2001:DB8::1]:3128").unwrap(),
"http://[2001:db8::1]:3128"
);
}
#[test]
fn rejects_unsafe_proxy_urls_without_echoing_credentials() {
for value in [
"http://user:secret@proxy.example:8080",
"ftp://proxy.example:21",
"http://proxy.example",
"http://proxy.example:8080/path",
"http://proxy.example:8080?token=secret",
"http://proxy.example:8080#secret",
"http://[]:8080",
"http://[proxy.example]:8080",
] {
let error = validate_proxy_server(value).unwrap_err().to_string();
assert!(error.contains("<redacted-proxy>"));
assert!(!error.contains("secret"));
}
}
#[test]
fn managed_launch_args_include_one_proxy_flag() {
let opts = BrowserOptions {
proxy_server: Some("http://127.0.0.1:8080".into()),
..BrowserOptions::default()
};
let args = managed_launch_args(Path::new("/tmp/chrome-profile"), &opts);
assert_eq!(
args.iter()
.filter(|arg| arg.starts_with("--proxy-server="))
.collect::<Vec<_>>(),
vec![&"--proxy-server=http://127.0.0.1:8080".to_string()]
);
}
#[test]
fn attached_browser_rejects_launch_proxy() {
let error = normalized_proxy_option(
Some("http://127.0.0.1:9222"),
Some("http://127.0.0.1:8080"),
)
.unwrap_err()
.to_string();
assert!(error.contains("applies only when chrome-agent launches Chrome"));
}
#[tokio::test]
async fn try_reconnect_existing_none_when_absent() {
let dir = std::env::temp_dir().join("chrome-agent_test_reconnect_absent");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("DevToolsActivePort");
std::fs::remove_file(&path).ok();
assert!(try_reconnect_existing(&path).await.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn try_reconnect_existing_removes_stale_file() {
let dir = std::env::temp_dir().join("chrome-agent_test_reconnect_stale");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("DevToolsActivePort");
std::fs::write(&path, "59321\n/devtools/browser/dead-target\n").unwrap();
let result = try_reconnect_existing(&path).await;
assert!(result.is_none(), "unreachable port must not reconnect");
assert!(!path.exists(), "stale port file should be removed");
std::fs::remove_dir_all(&dir).ok();
}
}