Files
chrome-use/cli/src/main.rs
T
leeguooooo 61060486f4
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
rebrand: agent-browser-stealth → chrome-use, de-fork, reset to v1.0.0
Standalone product rename across the whole repo (issue: project identity):

- Binary/package/repo/skill/docs: agent-browser[-stealth] → chrome-use
  (single binary name `chrome-use`; old aliases agent-browser/abs dropped).
- Version: 0.27.0-fork.51 → 1.0.0 (drop the upstream-fork counter).
- Native-messaging host: com.agent_browser.connect → com.leeguoo.chrome_use
  (CLI + ab-connect extension in lockstep — this is a breaking handshake change,
  extension bumped 0.4.2 → 0.5.0, needs a Web Store republish).
- Config dir: ~/.agent-browser → ~/.chrome-use.
- README/zh: reframed from "stealth fork of agent-browser" to a standalone
  product with a small `originally based on vercel-labs/agent-browser` credit.
- Kept AGENT_BROWSER_* env vars working (63 vars across the codebase; renaming
  them would break every existing script/skill for no user-facing gain).

Build green, 802 unit tests pass, fmt + clippy clean. Upstream attribution to
vercel-labs/agent-browser preserved.
2026-06-12 12:56:21 +09:00

1607 lines
53 KiB
Rust

mod chat;
mod color;
mod commands;
mod connect;
mod connection;
mod doctor;
mod findurl;
mod flags;
mod install;
mod native;
mod output;
mod skills;
#[cfg(test)]
mod test_utils;
mod upgrade;
mod validation;
use serde_json::json;
use std::env;
use std::fs;
use std::process::exit;
#[cfg(windows)]
use windows_sys::Win32::Foundation::CloseHandle;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::OpenProcess;
use commands::{gen_id, parse_command, ParseError};
use connection::{
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, send_command, walk_daemons,
DaemonOptions,
};
use flags::{clean_args, parse_flags, Flags};
use install::run_install;
use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use upgrade::run_upgrade;
fn serialize_json_value(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| {
r#"{"success":false,"error":"Failed to serialize JSON response"}"#.to_string()
})
}
fn print_json_value(value: serde_json::Value) {
println!("{}", serialize_json_value(&value));
}
fn print_json_error(message: impl AsRef<str>) {
print_json_value(json!({
"success": false,
"error": message.as_ref(),
}));
}
fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
print_json_value(json!({
"success": false,
"error": message.as_ref(),
"type": error_type,
}));
}
fn should_send_hide_scrollbars_launch_option(
cli_hide_scrollbars: bool,
hide_scrollbars: bool,
) -> bool {
cli_hide_scrollbars || !hide_scrollbars
}
fn apply_hide_scrollbars_launch_option(
launch_cmd: &mut serde_json::Value,
cli_hide_scrollbars: bool,
hide_scrollbars: bool,
) {
if should_send_hide_scrollbars_launch_option(cli_hide_scrollbars, hide_scrollbars) {
launch_cmd["hideScrollbars"] = json!(hide_scrollbars);
}
}
struct ParsedProxy {
server: String,
username: Option<String>,
password: Option<String>,
}
fn parse_proxy(proxy_str: &str) -> ParsedProxy {
let Some(protocol_end) = proxy_str.find("://") else {
return ParsedProxy {
server: proxy_str.to_string(),
username: None,
password: None,
};
};
let protocol = &proxy_str[..protocol_end + 3];
let rest = &proxy_str[protocol_end + 3..];
let Some(at_pos) = rest.rfind('@') else {
return ParsedProxy {
server: proxy_str.to_string(),
username: None,
password: None,
};
};
let creds = &rest[..at_pos];
let server_part = &rest[at_pos + 1..];
let server = format!("{}{}", protocol, server_part);
let (username, password) = match creds.find(':') {
Some(colon_pos) => {
let u = &creds[..colon_pos];
let p = &creds[colon_pos + 1..];
(
if u.is_empty() {
None
} else {
Some(u.to_string())
},
if p.is_empty() {
None
} else {
Some(p.to_string())
},
)
}
None => (
if creds.is_empty() {
None
} else {
Some(creds.to_string())
},
None,
),
};
ParsedProxy {
server,
username,
password,
}
}
fn run_profiles(json_mode: bool) {
use crate::native::cdp::chrome::{find_chrome_user_data_dir, list_chrome_profiles};
let user_data_dir = match find_chrome_user_data_dir() {
Some(dir) => dir,
None => {
if json_mode {
print_json_error("No Chrome user data directory found");
} else {
eprintln!("{}", color::red("No Chrome user data directory found"));
}
exit(1);
}
};
let profiles = list_chrome_profiles(&user_data_dir);
if profiles.is_empty() {
if json_mode {
print_json_value(json!({
"success": true,
"data": []
}));
} else {
println!("No Chrome profiles found");
}
return;
}
if json_mode {
let items: Vec<serde_json::Value> = profiles
.iter()
.map(|p| {
json!({
"directory": p.directory,
"name": p.name
})
})
.collect();
print_json_value(json!({
"success": true,
"data": items
}));
} else {
println!(
"{} ({}):\n",
color::bold("Chrome profiles"),
user_data_dir.display()
);
for p in &profiles {
println!(
" {} {}",
color::bold(&p.directory),
color::dim(&format!("({})", p.name))
);
}
}
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
match subcommand {
Some("list") => {
let sessions: Vec<String> = walk_daemons()
.sessions
.into_iter()
.map(|s| s.name)
.collect();
if json_mode {
println!(
r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
serde_json::to_string(&sessions).unwrap_or_default()
);
} else if sessions.is_empty() {
println!("No active sessions");
} else {
println!("Active sessions:");
for s in &sessions {
let marker = if s == session {
color::cyan("→")
} else {
" ".to_string()
};
println!("{} {}", marker, s);
}
}
}
None | Some(_) => {
// Just show current session
if json_mode {
print_json_value(json!({
"success": true,
"data": {
"session": session,
},
}));
} else {
println!("{}", session);
}
}
}
}
fn get_dashboard_pid_path() -> std::path::PathBuf {
get_socket_dir().join("dashboard.pid")
}
fn run_dashboard_start(port: u16, json_mode: bool) {
let pid_path = get_dashboard_pid_path();
// Check if already running
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
if is_pid_alive(pid) {
if json_mode {
print_json_value(json!({
"success": true,
"data": { "port": port, "pid": pid, "already_running": true },
}));
} else {
println!("Dashboard already running at http://localhost:{}", port);
}
return;
}
}
let _ = fs::remove_file(&pid_path);
}
let socket_dir = get_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
}
let exe_path = match env::current_exe() {
Ok(p) => p.canonicalize().unwrap_or(p),
Err(e) => {
if json_mode {
print_json_error(format!("Failed to get executable path: {}", e));
} else {
eprintln!(
"{} Failed to get executable path: {}",
color::error_indicator(),
e
);
}
exit(1);
}
};
let mut cmd = std::process::Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DASHBOARD", "1")
.env("AGENT_BROWSER_DASHBOARD_PORT", port.to_string());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
}
match cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => {
let pid = child.id();
let _ = fs::write(&pid_path, pid.to_string());
if json_mode {
print_json_value(json!({
"success": true,
"data": { "port": port, "pid": pid },
}));
} else {
println!("Dashboard started at http://localhost:{}", port);
}
}
Err(e) => {
if json_mode {
print_json_error(format!("Failed to start dashboard: {}", e));
} else {
eprintln!(
"{} Failed to start dashboard: {}",
color::error_indicator(),
e
);
}
exit(1);
}
}
}
fn run_dashboard_stop(json_mode: bool) {
let pid_path = get_dashboard_pid_path();
let pid_str = match fs::read_to_string(&pid_path) {
Ok(s) => s,
Err(_) => {
if json_mode {
print_json_value(
json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
);
} else {
println!("Dashboard is not running");
}
return;
}
};
let pid: u32 = match pid_str.trim().parse() {
Ok(p) => p,
Err(_) => {
let _ = fs::remove_file(&pid_path);
if json_mode {
print_json_value(
json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }),
);
} else {
println!("Dashboard is not running");
}
return;
}
};
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
#[cfg(windows)]
{
unsafe {
let handle = OpenProcess(1, 0, pid); // PROCESS_TERMINATE = 1
if handle != 0 {
windows_sys::Win32::System::Threading::TerminateProcess(handle, 0);
CloseHandle(handle);
}
}
}
let _ = fs::remove_file(&pid_path);
if json_mode {
print_json_value(json!({ "success": true, "data": { "stopped": true } }));
} else {
println!("{} Dashboard stopped", color::green("✓"));
}
}
fn run_close_all(flags: &Flags) {
// walk_daemons auto-cleans stale .pid / .sock / .stream sidecar files and
// separates out the standalone dashboard. We only want to send `close` to
// real session daemons; the dashboard has its own `dashboard stop`.
let inventory = walk_daemons();
let sessions: Vec<(String, u32)> = inventory
.sessions
.iter()
.map(|s| (s.name.clone(), s.pid))
.collect();
if sessions.is_empty() {
if flags.json {
print_json_value(json!({
"success": true,
"data": { "closed": 0, "sessions": [] },
}));
} else {
println!("No active sessions");
}
return;
}
let mut closed: Vec<String> = Vec::new();
let mut failed: Vec<(String, String)> = Vec::new();
for (session, pid) in &sessions {
let cmd = json!({ "id": gen_id(), "action": "close" });
match send_command(cmd, session) {
Ok(resp) if resp.success => closed.push(session.clone()),
Ok(resp) => {
let err = resp.error.unwrap_or_else(|| "Unknown error".to_string());
failed.push((session.clone(), err));
}
Err(_) => {
// Daemon is unreachable despite its process existing.
// Force-kill the process and clean up stale files so future
// sessions are not poisoned.
#[cfg(unix)]
unsafe {
libc::kill(*pid as i32, libc::SIGKILL);
}
#[cfg(windows)]
unsafe {
let handle = OpenProcess(1, 0, *pid); // PROCESS_TERMINATE = 1
if handle != 0 {
windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
CloseHandle(handle);
}
}
cleanup_stale_files(session);
closed.push(session.clone());
}
}
}
if flags.json {
print_json_value(json!({
"success": failed.is_empty(),
"data": {
"closed": closed.len(),
"sessions": closed,
"failed": failed.iter().map(|(s, e)| json!({"session": s, "error": e})).collect::<Vec<_>>(),
},
}));
} else {
for s in &closed {
println!("{} Closed session: {}", color::green("✓"), s);
}
for (s, e) in &failed {
eprintln!("{} Failed to close {}: {}", color::error_indicator(), s, e);
}
if closed.is_empty() && !failed.is_empty() {
exit(1);
}
}
if !failed.is_empty() {
exit(1);
}
}
fn main() {
// Rust ignores SIGPIPE by default, causing println! to panic on broken pipes.
// Reset to SIG_DFL so the OS terminates the process cleanly instead.
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// Prevent MSYS/Git Bash path translation from mangling arguments
#[cfg(windows)]
{
env::set_var("MSYS_NO_PATHCONV", "1");
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native-messaging host mode: Chrome launches `chrome-use __nm-host
// <extension-origin> [...]` for the ab-connect extension. Must run before
// ANY stdout write — stdout is the Chrome native-messaging channel.
if env::args().nth(1).as_deref() == Some("__nm-host") {
connect::run_nm_host();
return;
}
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
// Ignore SIGPIPE so the daemon isn't killed when the parent drops
// the piped stderr handle after confirming the daemon is ready.
#[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;
}
// Standalone dashboard server mode
if env::var("AGENT_BROWSER_DASHBOARD").is_ok() {
let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(4848);
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::stream::run_dashboard_server(port));
return;
}
let args: Vec<String> = env::args().skip(1).collect();
let mut flags = parse_flags(&args);
let mut clean = clean_args(&args);
// Loudly warn when launching a fresh browser with no profile: it gets a
// temporary EMPTY profile (no cookies / no login). For logged-in sites the
// user almost always wants --profile auto (their real Chrome profile).
// Skipped under CI (force_launch is implicit there and login isn't expected).
if flags.force_launch && flags.profile.is_none() && env::var("CI").is_err() {
eprintln!(
"⚠ --launch opens a fresh, isolated test profile (no cookies, no login, no \
extensions). The window is labelled `chrome-use (<session>)` in Chrome's \
profile menu so you can tell it apart from your real browser.\n \
• reuse your real Chrome (cookies/login/extensions): `--profile auto` \
(or set AGENT_BROWSER_PROFILE=auto once)\n \
• load an unpacked extension into the test profile: \
`--args \"--load-extension=<dir>\"`"
);
}
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
if has_help {
if let Some(cmd) = clean.first() {
if print_command_help(cmd) {
return;
}
}
print_help();
return;
}
if has_version {
print_version();
return;
}
if clean.is_empty() {
print_help();
return;
}
// Handle install separately
if clean.first().map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
run_install(with_deps);
return;
}
// Handle upgrade separately
if clean.first().map(|s| s.as_str()) == Some("upgrade") {
run_upgrade();
return;
}
// Handle doctor separately (doesn't need daemon; spawns its own scratch
// session for the live launch test).
if clean.first().map(|s| s.as_str()) == Some("doctor") {
let opts = doctor::DoctorOptions {
offline: args.iter().any(|a| a == "--offline"),
quick: args.iter().any(|a| a == "--quick"),
fix: args.iter().any(|a| a == "--fix"),
json: flags.json,
};
exit(doctor::run_doctor(opts));
}
// Handle dashboard subcommand
if clean.first().map(|s| s.as_str()) == Some("dashboard") {
match clean.get(1).map(|s| s.as_str()) {
Some("start") | None => {
let port = clean
.iter()
.position(|a| a == "--port")
.and_then(|i| clean.get(i + 1))
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(4848);
run_dashboard_start(port, flags.json);
return;
}
Some("stop") => {
run_dashboard_stop(flags.json);
return;
}
Some(unknown) => {
eprintln!(
"{} Unknown dashboard subcommand: {}",
color::error_indicator(),
unknown
);
exit(1);
}
}
}
// Handle profiles command (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("profiles") {
run_profiles(flags.json);
return;
}
// Handle skills command (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("skills") {
skills::run_skills(&clean, flags.json);
return;
}
// Handle find-url (doesn't need daemon): search local bookmarks
if matches!(
clean.first().map(|s| s.as_str()),
Some("find-url") | Some("findurl")
) {
findurl::run_find_url(&clean, flags.json);
return;
}
// Handle extension: native-messaging host install/status, and
// `extension connect` which attaches to the live relay (auto-discovers the
// CDP url the host wrote) by rewriting into the normal `connect <url>` flow.
// (`connect <port>` stays the plain CDP-attach command.)
if clean.first().map(|s| s.as_str()) == Some("extension") {
if clean.get(1).map(|s| s.as_str()) == Some("connect") {
match connect::relay_url() {
Some(url) => {
// The connect path reads `flags.cdp` (parsed from the original
// argv, which was `extension connect` → None), NOT `clean`.
// Without this the relay URL is dropped and we fall through to
// auto-connect, grabbing some other Chrome (stale :9222) or
// popping the remote-debug prompt. Point the daemon at the
// relay explicitly.
flags.cdp = Some(url.clone());
flags.auto_connect = false;
clean = vec!["connect".to_string(), url];
}
None => {
eprintln!(
"{} extension not connected. Run `chrome-use extension install`, load the\n ab-connect extension in Chrome (chrome://extensions → Developer mode →\n Load unpacked → extensions/ab-connect), then retry.",
color::error_indicator()
);
exit(1);
}
}
} else {
connect::run_connect(&clean, flags.json);
return;
}
}
// Handle session separately (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("session") {
run_session(&clean, &flags.session, flags.json);
return;
}
// Handle close --all: close all active sessions
if matches!(
clean.first().map(|s| s.as_str()),
Some("close") | Some("quit") | Some("exit")
) && clean.iter().any(|a| a == "--all")
{
run_close_all(&flags);
return;
}
// Handle chat command
if clean.first().map(|s| s.as_str()) == Some("chat") {
let message = if clean.len() > 1 {
Some(clean[1..].join(" "))
} else {
None
};
chat::run_chat(&flags, message);
return;
}
let mut cmd = match parse_command(&clean, &flags) {
Ok(c) => c,
Err(e) => {
if flags.json {
let error_type = match &e {
ParseError::UnknownCommand { .. } => "unknown_command",
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
ParseError::MissingArguments { .. } => "missing_arguments",
ParseError::InvalidValue { .. } => "invalid_value",
ParseError::InvalidSessionName { .. } => "invalid_session_name",
};
print_json_error_with_type(e.format(), error_type);
} else {
eprintln!("{}", color::red(&e.format()));
}
exit(1);
}
};
// Handle --password-stdin for auth save
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
if cmd.get("password").is_some() {
eprintln!(
"{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.",
color::warning_indicator()
);
}
if cmd
.get("passwordStdin")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let mut pass = String::new();
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
eprintln!(
"{} Failed to read password from stdin",
color::error_indicator()
);
exit(1);
}
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
if pass.is_empty() {
eprintln!("{} Password from stdin is empty", color::error_indicator());
exit(1);
}
cmd["password"] = json!(pass);
cmd.as_object_mut().unwrap().remove("passwordStdin");
}
}
// Validate session name before starting daemon
if let Some(ref name) = flags.session_name {
if !validation::is_valid_session_name(name) {
let msg = validation::session_name_error(name);
if flags.json {
print_json_error_with_type(msg, "invalid_session_name");
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
// Handle state management commands locally — these are pure file operations
// that don't need a daemon, avoiding an unnecessary daemon startup that
// would lack runtime config like session_name.
if let Some(result) = native::state::dispatch_state_command(&cmd) {
let action = cmd.get("action").and_then(|v| v.as_str());
let resp = match result {
Ok(data) => connection::Response {
success: true,
data: Some(data),
error: None,
warning: None,
},
Err(e) => connection::Response {
success: false,
data: None,
error: Some(e),
warning: None,
},
};
let output_opts = OutputOptions::from_flags(&flags);
output::print_response_with_opts(&resp, action, &output_opts);
if !resp.success {
exit(1);
}
return;
}
// Parse proxy URL to separate server from credentials for the daemon.
let (proxy_server, proxy_username, proxy_password) = if let Some(ref proxy_str) = flags.proxy {
let parsed = parse_proxy(proxy_str);
(Some(parsed.server), parsed.username, parsed.password)
} else {
(None, None, None)
};
let daemon_opts = DaemonOptions {
headed: flags.headed,
debug: flags.debug,
executable_path: flags.executable_path.as_deref(),
extensions: &flags.extensions,
init_scripts: &flags.init_scripts,
enable: &flags.enable,
args: flags.args.as_deref(),
user_agent: flags.user_agent.as_deref(),
proxy: proxy_server.as_deref(),
proxy_bypass: flags.proxy_bypass.as_deref(),
proxy_username: proxy_username.as_deref(),
proxy_password: proxy_password.as_deref(),
ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access,
hide_scrollbars: flags.hide_scrollbars,
profile: flags.profile.as_deref(),
state: flags.state.as_deref(),
provider: flags.provider.as_deref(),
device: flags.device.as_deref(),
session_name: flags.session_name.as_deref(),
download_path: flags.download_path.as_deref(),
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
engine: flags.engine.as_deref(),
auto_connect: flags.auto_connect,
force_launch: flags.force_launch,
idle_timeout: flags.idle_timeout.as_deref(),
default_timeout: flags.default_timeout,
cdp: flags.cdp.as_deref(),
no_auto_dialog: flags.no_auto_dialog,
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
Ok(result) => result,
Err(e) => {
if flags.json {
print_json_error(e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
};
// Warn if launch-time options were explicitly passed via CLI but daemon was already running
// Only warn about flags that were passed on the command line, not those set via environment
// variables (since the daemon already uses the env vars when it starts).
if daemon_result.already_running {
let ignored_flags: Vec<&str> = [
if flags.cli_executable_path {
Some("--executable-path")
} else {
None
},
if flags.cli_extensions {
Some("--extension")
} else {
None
},
if flags.cli_profile {
Some("--profile")
} else {
None
},
if flags.cli_state {
Some("--state")
} else {
None
},
if flags.cli_args { Some("--args") } else { None },
if flags.cli_user_agent {
Some("--user-agent")
} else {
None
},
if flags.cli_proxy {
Some("--proxy")
} else {
None
},
if flags.cli_proxy_bypass {
Some("--proxy-bypass")
} else {
None
},
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_hide_scrollbars.then_some("--hide-scrollbars"),
flags.cli_download_path.then_some("--download-path"),
flags.cli_headed.then_some("--headed"),
]
.into_iter()
.flatten()
.collect();
if !ignored_flags.is_empty() && !flags.json {
// Special case: --headed is irrelevant in CDP-attach mode
// (your existing Chrome is always already visible). The
// "chrome-use close + reopen" advice doesn't help because
// the new daemon will attach right back to the same Chrome.
// Don't suggest a useless workaround.
if ignored_flags == ["--headed"] {
eprintln!(
"{} --headed has no effect when attached to your running Chrome (it's already visible). \
Pass --launch to spawn a separate browser if you need to control headedness.",
color::warning_indicator(),
);
} else {
eprintln!(
"{} {} ignored: daemon already running. Use 'chrome-use close' first to restart with new options.",
color::warning_indicator(),
ignored_flags.join(", ")
);
}
}
}
// Validate mutually exclusive options
if flags.cdp.is_some() && flags.provider.is_some() {
let msg = "Cannot use --cdp and -p/--provider together";
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
// Explicit --cdp or --provider disables auto-connect (they specify the connection)
if flags.cdp.is_some() || flags.provider.is_some() {
flags.auto_connect = false;
}
if flags.provider.is_some() && !flags.extensions.is_empty() {
let msg = "Cannot use --extension with -p/--provider (extensions require local browser)";
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if flags.cdp.is_some() && !flags.extensions.is_empty() {
let msg = "Cannot use --extension with --cdp (extensions require local browser)";
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
// Auto-connect to existing browser.
// Skip when the daemon was already running — it already holds the connection
// from a previous auto-connect launch, so re-sending the launch command would
// redundantly probe Chrome and may trigger repeated permission prompts (#962).
if flags.auto_connect && !daemon_result.already_running {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"autoConnect": true
});
if flags.ignore_https_errors {
launch_cmd["ignoreHTTPSErrors"] = json!(true);
}
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "Auto-connect failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
// Connect via CDP if --cdp flag is set
// Accepts either a port number (e.g., "9222") or a full URL (e.g., "ws://..." or "wss://...")
// Skip when daemon already running — it already holds the CDP connection.
if let Some(ref cdp_value) = flags.cdp {
// Validate CDP value eagerly (even when daemon is already running) so
// the user gets an immediate error for bad input instead of a silent no-op.
let launch_cmd = if cdp_value.starts_with("ws://")
|| cdp_value.starts_with("wss://")
|| cdp_value.starts_with("http://")
|| cdp_value.starts_with("https://")
{
// It's a URL - use cdpUrl field
json!({
"id": gen_id(),
"action": "launch",
"cdpUrl": cdp_value
})
} else {
// It's a port number - validate and use cdpPort field
let cdp_port: u16 = match cdp_value.parse::<u32>() {
Ok(0) => {
let msg = "Invalid CDP port: port must be greater than 0".to_string();
if flags.json {
print_json_error(&msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
Ok(p) if p > 65535 => {
let msg = format!(
"Invalid CDP port: {} is out of range (valid range: 1-65535)",
p
);
if flags.json {
print_json_error(&msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
Ok(p) => p as u16,
Err(_) => {
let msg = format!(
"Invalid CDP value: '{}' is not a valid port number or URL",
cdp_value
);
if flags.json {
print_json_error(&msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
};
json!({
"id": gen_id(),
"action": "launch",
"cdpPort": cdp_port
})
};
if !daemon_result.already_running {
let mut launch_cmd = launch_cmd;
if flags.ignore_https_errors {
launch_cmd["ignoreHTTPSErrors"] = json!(true);
}
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "CDP connection failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
}
// Launch with cloud provider if -p flag is set
// Skip when daemon already running — it already holds the provider connection.
if let Some(ref provider) = flags.provider {
if !daemon_result.already_running {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"provider": provider
});
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "Provider connection failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
}
// Launch headed browser or configure browser options (without CDP or provider)
if (flags.headed
|| flags.cli_headed // User explicitly set --headed (even if false)
|| flags.executable_path.is_some()
|| flags.profile.is_some()
|| flags.state.is_some()
|| flags.proxy.is_some()
|| flags.args.is_some()
|| flags.user_agent.is_some()
|| flags.allow_file_access
|| should_send_hide_scrollbars_launch_option(
flags.cli_hide_scrollbars,
flags.hide_scrollbars,
)
|| flags.color_scheme.is_some()
|| flags.download_path.is_some()
|| flags.engine.is_some()
|| !flags.extensions.is_empty())
&& flags.cdp.is_none()
&& flags.provider.is_none()
&& (flags.force_launch || !flags.auto_connect)
{
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"headless": !flags.headed
});
let cmd_obj = launch_cmd
.as_object_mut()
.expect("json! macro guarantees object type");
// Add executable path if specified
if let Some(ref exec_path) = flags.executable_path {
cmd_obj.insert("executablePath".to_string(), json!(exec_path));
}
// Add profile path if specified
if let Some(ref profile_path) = flags.profile {
cmd_obj.insert("profile".to_string(), json!(profile_path));
}
// Add state path if specified
if let Some(ref state_path) = flags.state {
cmd_obj.insert("storageState".to_string(), json!(state_path));
}
if let Some(ref proxy_str) = flags.proxy {
let parsed = parse_proxy(proxy_str);
let mut proxy_obj = json!({ "server": parsed.server });
if let Some(ref username) = parsed.username {
proxy_obj["username"] = json!(username);
}
if let Some(ref password) = parsed.password {
proxy_obj["password"] = json!(password);
}
if let Some(ref bypass) = flags.proxy_bypass {
proxy_obj["bypass"] = json!(bypass);
}
cmd_obj.insert("proxy".to_string(), proxy_obj);
}
if let Some(ref ua) = flags.user_agent {
cmd_obj.insert("userAgent".to_string(), json!(ua));
}
if let Some(ref a) = flags.args {
// Parse args (comma or newline separated)
let args_vec: Vec<String> = a
.split(&[',', '\n'][..])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
cmd_obj.insert("args".to_string(), json!(args_vec));
}
if !flags.extensions.is_empty() {
cmd_obj.insert("extensions".to_string(), json!(&flags.extensions));
}
if flags.ignore_https_errors {
launch_cmd["ignoreHTTPSErrors"] = json!(true);
}
if flags.allow_file_access {
launch_cmd["allowFileAccess"] = json!(true);
}
apply_hide_scrollbars_launch_option(
&mut launch_cmd,
flags.cli_hide_scrollbars,
flags.hide_scrollbars,
);
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref domains) = flags.allowed_domains {
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)
let error_msg = resp
.error
.unwrap_or_else(|| "Browser launch failed".to_string());
if flags.json {
print_json_error(error_msg);
} else {
eprintln!("{} {}", color::error_indicator(), error_msg);
}
exit(1);
}
Err(e) => {
if flags.json {
print_json_error(e);
} else {
eprintln!(
"{} Could not configure browser: {}",
color::error_indicator(),
e
);
}
exit(1);
}
Ok(_) => {
// Launch succeeded
}
}
}
// Handle batch command: from args or stdin
if cmd.get("action").and_then(|v| v.as_str()) == Some("batch") {
let bail = cmd.get("bail").and_then(|v| v.as_bool()).unwrap_or(false);
let arg_commands = cmd.get("commands").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(commands::shell_words_split)
.collect::<Vec<Vec<String>>>()
});
run_batch(&flags, bail, arg_commands);
return;
}
let output_opts = OutputOptions::from_flags(&flags);
match send_command(cmd.clone(), &flags.session) {
Ok(resp) => {
let success = resp.success;
// Handle interactive confirmation
if flags.confirm_interactive {
if let Some(data) = &resp.data {
if data
.get("confirmation_required")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let desc = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("unknown action");
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let cid = data
.get("confirmation_id")
.and_then(|v| v.as_str())
.unwrap_or("");
eprintln!("[chrome-use] Action requires confirmation:");
eprintln!(" {}: {}", category, desc);
eprint!(" Allow? [y/N]: ");
let mut input = String::new();
let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
std::io::stdin().read_line(&mut input).is_ok()
&& matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
} else {
false
};
let confirm_cmd = if approved {
json!({ "id": gen_id(), "action": "confirm", "confirmationId": cid })
} else {
json!({ "id": gen_id(), "action": "deny", "confirmationId": cid })
};
match send_command(confirm_cmd, &flags.session) {
Ok(r) => {
if !approved {
eprintln!("{} Action denied", color::error_indicator());
exit(1);
}
print_response_with_opts(&r, None, &output_opts);
}
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
return;
}
}
}
// Extract action for context-specific output handling
let action = cmd.get("action").and_then(|v| v.as_str());
print_response_with_opts(&resp, action, &output_opts);
if !success {
exit(1);
}
}
Err(e) => {
if flags.json {
print_json_error(e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn run_batch(flags: &Flags, bail: bool, arg_commands: Option<Vec<Vec<String>>>) {
let commands: Vec<Vec<String>> = if let Some(cmds) = arg_commands {
cmds
} else {
use std::io::Read as _;
let mut input = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
if flags.json {
print_json_error(format!("Failed to read stdin: {}", e));
} else {
eprintln!("{} Failed to read stdin: {}", color::error_indicator(), e);
}
exit(1);
}
match serde_json::from_str(&input) {
Ok(c) => c,
Err(e) => {
if flags.json {
print_json_error(format!(
"Invalid JSON input: {}. Expected an array of string arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]",
e
));
} else {
eprintln!(
"{} Invalid JSON input: {}. Expected an array of string arrays.",
color::error_indicator(),
e
);
}
exit(1);
}
}
};
if commands.is_empty() {
if flags.json {
println!("[]");
}
return;
}
let output_opts = OutputOptions::from_flags(flags);
let mut results: Vec<serde_json::Value> = Vec::new();
let mut had_error = false;
for (i, cmd_args) in commands.iter().enumerate() {
if cmd_args.is_empty() {
continue;
}
let parsed = match parse_command(cmd_args, flags) {
Ok(c) => c,
Err(e) => {
had_error = true;
if flags.json {
results.push(json!({
"command": cmd_args,
"success": false,
"error": e.format(),
}));
if bail {
break;
}
} else {
eprintln!(
"{} Command {}: {}",
color::error_indicator(),
i + 1,
e.format()
);
if bail {
exit(1);
}
}
continue;
}
};
let action = parsed
.get("action")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match send_command(parsed, &flags.session) {
Ok(resp) => {
if flags.json {
results.push(json!({
"command": cmd_args,
"success": resp.success,
"result": resp.data,
"error": resp.error,
}));
} else {
if i > 0 {
println!();
}
print_response_with_opts(&resp, action.as_deref(), &output_opts);
}
if !resp.success {
had_error = true;
if bail {
if !flags.json {
exit(1);
}
break;
}
}
}
Err(e) => {
had_error = true;
if flags.json {
results.push(json!({
"command": cmd_args,
"success": false,
"error": e.to_string(),
}));
if bail {
break;
}
} else {
eprintln!("{} Command {}: {}", color::error_indicator(), i + 1, e);
if bail {
exit(1);
}
}
}
}
}
if flags.json {
println!(
"{}",
serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string())
);
}
if had_error {
exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_proxy_simple() {
let result = parse_proxy("http://proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_proxy_with_auth() {
let result = parse_proxy("http://user:pass@proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_proxy_username_only() {
let result = parse_proxy("http://user@proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert!(result.password.is_none());
}
#[test]
fn test_parse_proxy_no_protocol() {
let result = parse_proxy("proxy.com:8080");
assert_eq!(result.server, "proxy.com:8080");
assert!(result.username.is_none());
}
#[test]
fn test_parse_proxy_socks5() {
let result = parse_proxy("socks5://proxy.com:1080");
assert_eq!(result.server, "socks5://proxy.com:1080");
assert!(result.username.is_none());
}
#[test]
fn test_parse_proxy_socks5_with_auth() {
let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
assert_eq!(result.server, "socks5://proxy.com:1080");
assert_eq!(result.username.as_deref(), Some("admin"));
assert_eq!(result.password.as_deref(), Some("secret"));
}
#[test]
fn test_parse_proxy_complex_password() {
let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("p@ss:w0rd"));
}
#[test]
fn test_serialize_json_value_escapes_control_characters() {
let payload = serialize_json_value(&json!({
"success": false,
"error": "Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m",
}));
let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(parsed["success"], false);
assert_eq!(
parsed["error"],
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
);
}
#[test]
fn test_hide_scrollbars_launch_option_serialization() {
assert!(!should_send_hide_scrollbars_launch_option(false, true));
assert!(should_send_hide_scrollbars_launch_option(false, false));
assert!(should_send_hide_scrollbars_launch_option(true, true));
let mut default_cmd = json!({ "action": "launch" });
apply_hide_scrollbars_launch_option(&mut default_cmd, false, true);
assert!(default_cmd.get("hideScrollbars").is_none());
let mut config_false_cmd = json!({ "action": "launch" });
apply_hide_scrollbars_launch_option(&mut config_false_cmd, false, false);
assert_eq!(config_false_cmd["hideScrollbars"], false);
let mut cli_true_cmd = json!({ "action": "launch" });
apply_hide_scrollbars_launch_option(&mut cli_true_cmd, true, true);
assert_eq!(cli_true_cmd["hideScrollbars"], true);
}
}