use std::collections::HashMap;
use serde_json::json;
use crate::cdp::client::CdpClient;
use crate::commands;
use crate::element_ref::ElementRef;
use crate::session::{self, BrowserSession, SessionStore};
pub async fn connect_page(
http_endpoint: &str,
target_id: &str,
stealth: bool,
) -> Result<CdpClient, crate::BoxError> {
let mut last_err = String::new();
for attempt in 0..8u32 {
match crate::browser::get_page_ws_url(http_endpoint, target_id).await {
Ok(page_ws) => match CdpClient::connect(&page_ws).await {
Ok(client) => {
if let Err(e) = client.call::<_, serde_json::Value>(
"Runtime.evaluate",
json!({"expression": "1", "returnByValue": true}),
).await {
last_err = format!("Connection verify failed: {e}");
drop(client);
if attempt < 7 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
continue;
}
if let Err(e) = client.enable("Page").await {
last_err = format!("Page.enable failed: {e}");
drop(client);
if attempt < 7 {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
continue;
}
commands::console::inject(&client).await;
if stealth {
crate::setup::apply_stealth(&client).await;
} else {
let _ = client.enable("Runtime").await;
}
return Ok(client);
}
Err(e) => last_err = e.to_string(),
},
Err(e) => last_err = e.to_string(),
}
if attempt < 7 {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
}
Err(format!("Failed to connect to page after 8 attempts: {last_err}").into())
}
pub struct ActionReport {
pub inspect: bool,
pub changes: bool,
pub budget: usize,
pub max_depth: Option<usize>,
}
#[derive(Clone, Copy)]
pub struct ReportPolicy {
pub changes: bool,
pub budget: usize,
}
impl ReportPolicy {
pub const fn for_action(self, inspect: bool, max_depth: Option<usize>) -> ActionReport {
ActionReport { inspect, changes: self.changes, budget: self.budget, max_depth }
}
}
pub fn fill_value_report(outcome: &crate::element::FillOutcome) -> serde_json::Value {
let mut v = if outcome.sensitive {
json!({
"redacted": true,
"requested_length": outcome.requested.chars().count(),
"actual_length": outcome.actual.as_ref().map(|a| a.chars().count()),
"verbatim": outcome.verbatim(),
})
} else {
json!({
"requested": outcome.requested,
"actual": outcome.actual,
"verbatim": outcome.verbatim(),
})
};
v["observed_after_ms"] = json!(outcome.observed_after_ms);
if let Some(caveat) = &outcome.caveat {
v["caveat"] = json!(caveat);
}
v
}
pub async fn target_details(
client: &CdpClient,
selector: Option<&str>,
uid: Option<&str>,
) -> Option<serde_json::Value> {
let resolved = match (selector, uid) {
(Some(sel), _) => crate::element::selector_uid(client, sel).await,
(None, Some(uid)) => Some(uid.to_string()),
(None, None) => None,
};
resolved.map(|uid| json!({"uid": uid}))
}
#[must_use]
pub fn merge_details(
first: Option<serde_json::Value>,
second: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
match (first, second) {
(Some(mut a), Some(b)) => {
if let (Some(target), Some(extra)) = (a.as_object_mut(), b.as_object()) {
for (key, value) in extra {
target.insert(key.clone(), value.clone());
}
}
Some(a)
}
(Some(only), None) | (None, Some(only)) => Some(only),
(None, None) => None,
}
}
#[must_use]
pub fn bulk_fill_report(
key: &str,
outcomes: &[(String, crate::element::FillOutcome)],
) -> serde_json::Value {
serde_json::Value::Array(
outcomes
.iter()
.map(|(target, outcome)| json!({key: target, "value": fill_value_report(outcome)}))
.collect(),
)
}
#[must_use]
pub fn check_report(outcome: crate::element::CheckOutcome) -> (String, Option<serde_json::Value>) {
let details = outcome.observed_after_ms.map(|ms| json!({"observed_after_ms": ms}));
(outcome.message, details)
}
pub async fn output_action(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
msg: String,
report: &ActionReport,
json_mode: bool,
) -> Result<(), crate::BoxError> {
output_action_with(client, store, browser_name, page_name, target_id, msg, report, json_mode, None).await
}
#[allow(clippy::too_many_arguments)]
pub async fn output_action_with(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
msg: String,
report: &ActionReport,
json_mode: bool,
details: Option<serde_json::Value>,
) -> Result<(), crate::BoxError> {
let mut obj = json!({"ok": true, "message": msg});
if let Some(fields) = details.as_ref().and_then(serde_json::Value::as_object) {
for (key, value) in fields {
obj[key.as_str()] = value.clone();
}
}
let mut trailer = String::new();
let mut observation = if report.changes {
crate::verdict::Observation::NoBaseline
} else {
crate::verdict::Observation::ReportingDisabled
};
if report.inspect || report.changes {
crate::snapshot::settle(client, 100, 1000).await;
let Ok(snapshot) = commands::inspect::run(client, false, None, None, None).await else {
let assessment = crate::verdict::classify(crate::verdict::Observation::ReadFailed);
attach_verdict(&mut obj, assessment);
if json_mode {
json_output(&obj);
} else {
println!("{msg}");
println!("verdict: {} ({})", assessment.verdict, assessment.reason);
}
return Ok(());
};
if report.changes {
let previous = store
.browsers
.get(browser_name)
.and_then(|b| b.pages.get(page_name))
.map(|p| {
(
p.last_snapshot.clone(),
p.last_snapshot_frame.clone().zip(p.last_snapshot_loader.clone()),
)
});
if let Some((Some(old_text), stored)) = previous {
let identity = commands::diff::Identity::from_loader(
stored.as_ref().map(|(f, l)| (f.as_str(), l.as_str())),
snapshot.identity.as_ref().map(|(f, l)| (f.as_str(), l.as_str())),
);
let cmp = commands::diff::compare(identity, &old_text, &snapshot.text);
let body = if report.budget == 0 {
cmp.text.clone()
} else {
crate::truncate::truncate_str(
cmp.text.trim_end(),
report.budget,
"\n… truncated, run `inspect` for the rest",
)
.into_owned()
};
obj["changed"] = json!({
"added": cmp.added,
"removed": cmp.removed,
"changed": cmp.changed,
"unchanged": cmp.unchanged,
"moved": cmp.moved,
"anonymous": cmp.anonymous,
"document_changed": cmp.document_changed,
"identity_known": cmp.identity_known,
});
obj["delta"] = json!(body);
observation = crate::verdict::Observation::Compared {
document_changed: cmp.document_changed,
identity_known: cmp.identity_known,
edits: cmp.added + cmp.removed + cmp.changed,
moved: cmp.moved,
focus_moved: cmp.focus_from.is_some() || cmp.focus_to.is_some(),
};
if cmp.focus_from.is_some() || cmp.focus_to.is_some() {
obj["focus"] = json!({"from": cmp.focus_from, "to": cmp.focus_to});
}
if let Some(hint) = cmp.hint {
obj["hint"] = json!(hint);
}
trailer = body;
}
}
if report.inspect {
let shown = if report.max_depth.is_some() {
commands::inspect::run(client, false, report.max_depth, None, None)
.await
.map_or_else(|_| snapshot.text.clone(), |s| s.text)
} else {
snapshot.text.clone()
};
obj["snapshot"] = json!(shown);
trailer.clone_from(&shown);
}
if let Some(browser_s) = store.browsers.get_mut(browser_name) {
let page = session::ensure_page(browser_s, page_name, target_id);
page.last_snapshot = Some(snapshot.text);
let (f, l) = snapshot.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
page.uid_map = snapshot.uid_map;
}
}
let assessment = crate::verdict::classify(observation);
attach_verdict(&mut obj, assessment);
if json_mode {
json_output(&obj);
} else {
println!("{msg}");
if !trailer.is_empty() {
println!("{}", trailer.trim_end());
}
println!("verdict: {} ({})", assessment.verdict, assessment.reason);
}
Ok(())
}
pub fn attach_verdict(obj: &mut serde_json::Value, assessment: crate::verdict::Assessment) {
obj["verdict"] = json!(assessment.verdict.as_str());
obj["verdict_reason"] = json!(assessment.reason);
if let Some(hint) = crate::verdict::hint_for(assessment) {
obj["verdict_hint"] = json!(hint);
}
}
pub async fn output_goto(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
url: &str,
title: &str,
inspect: bool,
max_depth: Option<usize>,
json_mode: bool,
) -> Result<(), crate::BoxError> {
let browser_session = store.browsers.get_mut(browser_name)
.ok_or_else(|| format!("Browser session '{browser_name}' not found in session store"))?;
let page = session::ensure_page(
browser_session,
page_name,
target_id,
);
page.uid_map.clear();
if json_mode {
let mut obj = json!({"ok": true, "url": url, "title": title});
if inspect {
let snapshot = commands::inspect::run(client, false, max_depth, None, None).await?;
obj["snapshot"] = json!(snapshot.text);
page.last_snapshot = Some(snapshot.text);
let (f, l) = snapshot.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
page.uid_map = snapshot.uid_map;
}
json_output(&obj);
} else {
if title.is_empty() {
println!("{url}");
} else {
println!("{url} — {title}");
}
if inspect {
let snapshot = commands::inspect::run(client, false, max_depth, None, None).await?;
println!("{}", snapshot.text);
page.last_snapshot = Some(snapshot.text);
let (f, l) = snapshot.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
page.uid_map = snapshot.uid_map;
}
}
Ok(())
}
pub fn json_output(value: &serde_json::Value) {
println!("{}", serde_json::to_string(value).unwrap_or_default());
}
pub fn error_hint(msg: &str) -> Option<&'static str> {
if msg.contains("Failed to connect to page") || msg.contains("DevToolsActivePort") {
Some("Could not attach over CDP. Chrome 136+ disables remote debugging on the default profile: drop --connect to let chrome-agent launch its own dedicated profile, or relaunch your Chrome with a separate --user-data-dir.")
} else if msg.contains("Connection refused") || msg.contains("No such file") {
Some("Is Chrome running? Try: chrome-agent goto <url>")
} else if msg.contains("uid=") && msg.contains("not found") {
Some("Run `chrome-agent inspect` to refresh element uids")
} else if msg.contains("Navigation failed") {
Some("Check the URL is valid and the page is reachable")
} else if msg.contains("No snapshot") || msg.contains("No inspect") || msg.contains("uid_map is empty") {
Some("Run 'chrome-agent inspect' first")
} else if msg.contains("Timeout") || msg.contains("timeout") {
Some("Use --timeout N for slow pages")
} else if msg.contains("not interactable") || msg.contains("no visible box model") {
Some("Element may be hidden. Try: chrome-agent scroll <uid>")
} else if msg.contains("No element matches selector") {
Some("CSS selector didn't match. Check with: chrome-agent eval \"document.querySelector('...')\"")
} else if msg.contains("backendDomNodeId") || msg.contains("response parse") {
Some("Page structure issue. Try: chrome-agent click --selector or chrome-agent eval")
} else if msg.contains("may not have an article") || msg.contains("Readability") {
Some("Page has no article structure. Try: chrome-agent text or chrome-agent text --selector \"main\"")
} else if msg.contains("Provide a uid") || msg.contains("Provide --uid") {
Some("Specify what to target: uid (e.g. n47), --selector \"css\", or --xy x,y")
} else if msg.contains("Evaluation error") || msg.contains("TypeError") || msg.contains("ReferenceError") || msg.contains("SyntaxError") {
Some("JS error in page context. Check expression syntax. Use --selector to scope to an element.")
} else if msg.contains("dispatcher task exited") || msg.contains("transport closed") {
Some("Browser connection lost. Try running the command again.")
} else if msg.contains("not an <iframe>") || msg.contains("not an <IFRAME>") {
Some("Only <iframe> is supported. For <frame>/<frameset>, use eval to access frame content.")
} else if msg.contains("No child frame found") {
Some("Iframe not found. Check the selector matches an <iframe> element.")
} else if msg.contains("not a <select>") {
Some("Element is not a <select>. For custom dropdowns, click to open then click the option.")
} else if msg.contains("No option matching") {
Some("No dropdown option matched. Use inspect --uid to check available options, or try the visible text.")
} else if msg.contains("File not found") {
Some("Check the file path exists on disk.")
} else if msg.contains("expected a JSON array") {
Some("Batch expects a JSON array of commands on stdin: [{\"cmd\":\"inspect\"}, ...]")
} else {
None
}
}
pub fn get_uid_map(store: &SessionStore, browser_name: &str, page_name: &str) -> HashMap<String, ElementRef> {
store
.browsers
.get(browser_name)
.and_then(|b| b.pages.get(page_name))
.map(|p| p.uid_map.clone())
.unwrap_or_default()
}
pub async fn resolve_page_target(
client: &CdpClient,
browser_session: &mut BrowserSession,
page_name: &str,
) -> Result<String, crate::BoxError> {
if let Some(page) = browser_session.pages.get(page_name) {
return Ok(page.target_id.clone());
}
if page_name == "default" {
let result: crate::cdp::types::GetTargetsResult = client
.call("Target.getTargets", serde_json::json!({}))
.await?;
let claimed_targets: std::collections::HashSet<&str> = browser_session
.pages
.values()
.map(|p| p.target_id.as_str())
.collect();
let available = result
.target_infos
.iter()
.find(|t| t.target_type == "page" && !claimed_targets.contains(t.target_id.as_str()));
if let Some(target) = available {
let target_id = target.target_id.clone();
session::ensure_page(browser_session, page_name, &target_id);
return Ok(target_id);
}
}
let create_result: crate::cdp::types::CreateTargetResult = client
.call(
"Target.createTarget",
crate::cdp::types::CreateTargetParams {
url: "about:blank".into(),
width: None,
height: None,
new_window: None,
background: None,
},
)
.await?;
let target_id = create_result.target_id;
session::ensure_page(browser_session, page_name, &target_id);
Ok(target_id)
}
pub fn cmd_status(json_mode: bool) -> Result<(), crate::BoxError> {
let store = session::load_session()?;
let daemon_alive = session::daemon_socket_exists();
if json_mode {
let browsers: Vec<serde_json::Value> = store
.browsers
.iter()
.map(|(name, b)| {
json!({
"name": name,
"pid": b.pid,
"headless": b.headless,
"pages": b.pages.len(),
"ws": b.ws_endpoint,
})
})
.collect();
json_output(&json!({
"ok": true,
"browsers": browsers,
"daemon": if daemon_alive { "running" } else { "stopped" },
}));
} else {
if store.browsers.is_empty() {
println!("No active browser sessions.");
} else {
for (name, browser) in &store.browsers {
let status = if let Some(pid) = browser.pid {
format!("pid={pid}")
} else {
"external".into()
};
let mode = if browser.headless { "headless" } else { "headed" };
println!(
"browser={name} {status} {mode} pages={} ws={}",
browser.pages.len(),
browser.ws_endpoint
);
}
}
println!(
"daemon: {}",
if daemon_alive { "running" } else { "stopped" }
);
}
Ok(())
}
#[cfg(any(unix, test))]
const fn stop_message(reached_daemon: bool) -> &'static str {
if reached_daemon {
"Daemon stopped."
} else {
"Daemon is not running."
}
}
pub async fn cmd_stop(json_mode: bool) -> Result<(), crate::BoxError> {
#[cfg(not(unix))]
{
let msg = "Daemon is not supported on this platform.";
if json_mode { json_output(&json!({"ok": true, "message": msg})); }
else { println!("{msg}"); }
return Ok(());
}
#[cfg(unix)]
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
let socket_path = session::daemon_socket_path()?;
let stream = if socket_path.exists() {
match UnixStream::connect(&socket_path).await {
Ok(stream) => Some(stream),
Err(_) => {
let _ = std::fs::remove_file(&socket_path);
None
}
}
} else {
None
};
let Some(mut stream) = stream else {
let msg = stop_message(false);
if json_mode { json_output(&json!({"ok": true, "message": msg})); }
else { println!("{msg}"); }
return Ok(());
};
stream
.write_all(b"{\"command\":\"stop\"}\n")
.await?;
stream.shutdown().await?;
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf).await;
let msg = stop_message(true);
if json_mode { json_output(&json!({"ok": true, "message": msg})); }
else { println!("{msg}"); }
Ok(())
} }
#[must_use]
pub const fn interrupt_owns_browser(command: &crate::cli::Command) -> bool {
use crate::cli::Command as C;
!matches!(
command,
C::Daemon { .. } | C::Status | C::Stop | C::Close { .. } | C::History { .. }
)
}
#[must_use]
pub fn interrupt_kill_target(store: &SessionStore, browser_name: &str) -> Option<u32> {
store.browsers.get(browser_name).and_then(|b| b.pid)
}
#[cfg(any(unix, test))]
fn is_browser_process(comm: &str) -> bool {
let base = comm.rsplit('/').next().unwrap_or(comm).to_ascii_lowercase();
if base.contains("chrome-agent") || base.contains("chromedriver") {
return false;
}
base.contains("chrome") || base.contains("chromium") || base.contains("headless_shell")
}
pub fn kill_pid(pid: u32) {
#[cfg(unix)]
{
let comm = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
if comm.as_deref().is_some_and(|c| !c.is_empty() && is_browser_process(c)) {
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
pub fn cmd_close(browser_name: &str, purge: bool, json_mode: bool) -> Result<(), crate::BoxError> {
let mut store = session::load_session()?;
let browser = store.browsers.remove(browser_name);
let message = match browser {
Some(b) => {
if let Some(pid) = b.pid {
kill_pid(pid);
format!("Closed browser={browser_name} (pid={pid})")
} else {
format!("Removed external browser session: {browser_name}")
}
}
None => {
format!("No browser session named '{browser_name}'.")
}
};
if purge
&& let Some(home) = dirs::home_dir() {
let profile_dir = home.join(".chrome-agent").join("browsers").join(browser_name);
if profile_dir.exists() {
for _ in 0..5 {
if std::fs::remove_dir_all(&profile_dir).is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
}
let message = if purge {
format!("{message} (profile purged)")
} else {
message
};
if json_mode {
json_output(&json!({"ok": true, "message": message}));
} else {
println!("{message}");
}
session::save_session(&mut store)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kill_pid_refuses_a_pid_that_no_longer_belongs_to_a_browser() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn a stand-in for the reused pid");
kill_pid(child.id());
std::thread::sleep(std::time::Duration::from_millis(300));
let status = child.try_wait().expect("poll the stand-in");
let survived = status.is_none();
let _ = child.kill();
let _ = child.wait();
assert!(survived, "kill_pid killed an unrelated process holding a reused pid");
}
#[test]
fn a_command_that_never_opens_a_browser_has_none_to_interrupt() {
use crate::cli::Command as C;
for command in [
C::Daemon { action: crate::cli::DaemonAction::Start },
C::Status,
C::Stop,
C::Close { purge: false },
C::History { filter: None, limit: 20 },
] {
assert!(
!interrupt_owns_browser(&command),
"this command never opens a browser, so it has none to kill"
);
}
assert!(interrupt_owns_browser(&C::Tabs));
assert!(interrupt_owns_browser(&C::Pipe));
}
#[test]
fn an_interrupt_only_targets_this_invocation_s_browser() {
let mut store = SessionStore::default();
session::ensure_browser(&mut store, "agent-1", "ws://a", Some(111), true, None);
session::ensure_browser(&mut store, "agent-2", "ws://b", Some(222), true, None);
assert_eq!(interrupt_kill_target(&store, "agent-1"), Some(111));
assert_eq!(
interrupt_kill_target(&store, "agent-2"),
Some(222),
"a sibling agent's browser is never this invocation's to kill"
);
assert_eq!(interrupt_kill_target(&store, "never-launched"), None);
}
#[test]
fn browser_executables_are_recognised_and_bystanders_are_not() {
for browser in [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"chrome",
"chromium",
"chromium-browser",
"headless_shell",
"Google Chrome for Testing",
] {
assert!(is_browser_process(browser), "should recognise {browser}");
}
for bystander in [
"sleep",
"postgres",
"/usr/bin/python3",
"node",
"chrome-agent",
"/tmp/chrome-agent",
"chromedriver",
] {
assert!(!is_browser_process(bystander), "must not kill {bystander}");
}
}
#[test]
fn bug_error_hint_covers_all_cases() {
assert!(error_hint("Connection refused").is_some());
assert!(error_hint("uid=n5 not found").is_some());
assert!(error_hint("Navigation failed").is_some());
assert!(error_hint("No snapshot").is_some());
assert!(error_hint("Timeout waiting").is_some());
assert!(error_hint("not interactable").is_some());
assert!(error_hint("No element matches selector").is_some());
assert!(error_hint("response parse error").is_some());
assert!(error_hint("Readability failed").is_some());
assert!(error_hint("Provide a uid").is_some());
assert!(error_hint("Evaluation error: TypeError: foo").is_some());
assert!(error_hint("dispatcher task exited").is_some());
assert!(error_hint("Element is not an <iframe>").is_some());
assert!(error_hint("No child frame found for selector").is_some());
assert!(error_hint("Element is not a <select>").is_some());
assert!(error_hint("No option matching: foo").is_some());
assert!(error_hint("File not found: /tmp/nope").is_some());
assert!(error_hint("batch: expected a JSON array").is_some());
assert!(error_hint("something random").is_none());
}
#[test]
fn connect_failure_hints_at_chrome_136() {
for msg in [
"Failed to connect to page after 8 attempts: Connection refused",
"DevToolsActivePort file doesn't exist",
] {
let hint = error_hint(msg).expect("connect failure should have a hint");
assert!(hint.contains("136"), "hint should mention Chrome 136: {hint}");
assert!(hint.contains("--connect"), "hint should mention --connect: {hint}");
}
}
#[test]
fn plain_connection_refused_keeps_generic_hint() {
let hint = error_hint("Connection refused").unwrap();
assert!(hint.contains("Chrome running"));
assert!(!hint.contains("136"));
}
#[test]
fn stop_message_reflects_daemon_reachability() {
assert_eq!(stop_message(true), "Daemon stopped.");
assert_eq!(stop_message(false), "Daemon is not running.");
}
}