mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
2
Commits
v0.12.0
...
browser-tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75a5e2d4b | ||
|
|
cde50ff470 |
Generated
+173
-19
@@ -352,6 +352,23 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-tungstenite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -505,7 +522,7 @@ dependencies = [
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn 2.0.114",
|
||||
"which",
|
||||
"which 4.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -816,6 +833,72 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chromiumoxide"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c18200611490f523adb497ddd4744d6d536e243f6add13e7eeeb1c05904fbb1"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"base64 0.22.1",
|
||||
"cfg-if",
|
||||
"chromiumoxide_cdp",
|
||||
"chromiumoxide_types",
|
||||
"dunce",
|
||||
"fnv",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"pin-project-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"which 8.0.0",
|
||||
"windows-registry 0.5.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chromiumoxide_cdp"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8f78027ced540595dcbaf9e2f3413cbe3708b839ff239d2858acaea73915dcb"
|
||||
dependencies = [
|
||||
"chromiumoxide_pdl",
|
||||
"chromiumoxide_types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chromiumoxide_pdl"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d2c7b7c6b41a0de36d00a284e619017e0f4aec5c9bc8d90614b9e1687984f20"
|
||||
dependencies = [
|
||||
"chromiumoxide_types",
|
||||
"either",
|
||||
"heck 0.4.1",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chromiumoxide_types"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "309ba8f378bbc093c93f06beb7bd4c5ceffdf14107ad99cacbbf063709926795"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -827,7 +910,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -879,7 +962,7 @@ version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -1497,6 +1580,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@@ -1563,6 +1652,12 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_home"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -2020,6 +2115,12 @@ dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -2267,7 +2368,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2501,6 +2602,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"bollard",
|
||||
"bytes",
|
||||
"chromiumoxide",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cron",
|
||||
@@ -2697,7 +2799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3309,7 +3411,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3946,7 +4048,7 @@ version = "0.8.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72c225407d8e52ef8cf094393781ecda9a99d6544ec28d90a6915751de259264"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"refinery-core",
|
||||
@@ -6182,7 +6284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f38f7a5eb2f06f53fe943e7fb8bf4197f7cf279f1bc52c0ce56e9d3ffd750a4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"indexmap 2.13.0",
|
||||
"wit-parser",
|
||||
]
|
||||
@@ -6250,6 +6352,17 @@ dependencies = [
|
||||
"rustix 0.38.44",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
|
||||
dependencies = [
|
||||
"env_home",
|
||||
"rustix 1.1.3",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.0"
|
||||
@@ -6283,7 +6396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"shellexpand",
|
||||
@@ -6359,9 +6472,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6386,21 +6499,47 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6409,7 +6548,16 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6418,7 +6566,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6463,7 +6611,7 @@ version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6503,7 +6651,7 @@ version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
@@ -6661,6 +6809,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
|
||||
@@ -122,6 +122,9 @@ bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
|
||||
# Headless browser automation via Chrome DevTools Protocol
|
||||
chromiumoxide = { version = "0.8", default-features = false, features = ["tokio-runtime"] }
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Accessibility tree parsing and element reference generation.
|
||||
//!
|
||||
//! Converts Chrome's CDP accessibility tree into a compact, LLM-friendly
|
||||
//! representation with stable element references (`@e1`, `@e2`, ...).
|
||||
//!
|
||||
//! The key insight: sending the full accessibility tree every turn is wasteful.
|
||||
//! Instead, we assign short IDs to interactive elements and let the LLM
|
||||
//! reference them by ID for clicks/typing. This is ~93% cheaper in tokens
|
||||
//! compared to re-sending the full tree each time.
|
||||
//!
|
||||
//! ```text
|
||||
//! Page: https://example.com/login
|
||||
//! @e1: textbox "Email" [focused]
|
||||
//! @e2: textbox "Password" [type=password]
|
||||
//! @e3: button "Sign In"
|
||||
//! @e4: link "Forgot password?"
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use chromiumoxide::cdp::browser_protocol::accessibility::{AxNode, AxPropertyName};
|
||||
use chromiumoxide::cdp::browser_protocol::dom::BackendNodeId;
|
||||
|
||||
/// A resolved element reference that maps `@eN` back to a DOM target.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElementRef {
|
||||
/// The display label shown to the LLM (e.g., `textbox "Email"`).
|
||||
#[allow(dead_code)]
|
||||
pub label: String,
|
||||
/// CDP backend node ID for targeting this element.
|
||||
pub backend_node_id: BackendNodeId,
|
||||
/// CSS selector hint (best-effort, may not be unique).
|
||||
#[allow(dead_code)]
|
||||
pub selector_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Stores the current set of element references for a page snapshot.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ElementRefMap {
|
||||
refs: HashMap<String, ElementRef>,
|
||||
counter: usize,
|
||||
}
|
||||
|
||||
impl ElementRefMap {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up a reference like `@e1` or just `e1`.
|
||||
pub fn get(&self, ref_id: &str) -> Option<&ElementRef> {
|
||||
let normalized = ref_id.strip_prefix('@').unwrap_or(ref_id);
|
||||
self.refs.get(normalized)
|
||||
}
|
||||
|
||||
/// Number of tracked elements.
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.refs.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.refs.is_empty()
|
||||
}
|
||||
|
||||
/// Reset all refs. Called before each new `read_page` and when switching tabs.
|
||||
pub fn reset(&mut self) {
|
||||
self.refs.clear();
|
||||
self.counter = 0;
|
||||
}
|
||||
|
||||
/// Allocate the next reference ID and store the element.
|
||||
fn insert(&mut self, elem: ElementRef) -> String {
|
||||
self.counter += 1;
|
||||
let id = format!("e{}", self.counter);
|
||||
self.refs.insert(id.clone(), elem);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Which elements to include when building the tree representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ElementFilter {
|
||||
/// Only interactive elements (buttons, links, inputs, selects, textareas).
|
||||
Interactive,
|
||||
/// All elements with meaningful content.
|
||||
All,
|
||||
}
|
||||
|
||||
impl ElementFilter {
|
||||
pub fn from_str_opt(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("all") => Self::All,
|
||||
_ => Self::Interactive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Roles that are considered "interactive" for filtering purposes.
|
||||
const INTERACTIVE_ROLES: &[&str] = &[
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"searchbox",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"option",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"radio",
|
||||
"checkbox",
|
||||
"switch",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"tab",
|
||||
"treeitem",
|
||||
];
|
||||
|
||||
/// Roles to skip entirely (structural noise).
|
||||
const SKIP_ROLES: &[&str] = &[
|
||||
"none",
|
||||
"presentation",
|
||||
"generic",
|
||||
"InlineTextBox",
|
||||
"LineBreak",
|
||||
];
|
||||
|
||||
/// Build a compact page representation from the CDP accessibility tree.
|
||||
///
|
||||
/// Returns the text representation and populates `ref_map` with element
|
||||
/// references the LLM can use for subsequent actions.
|
||||
pub fn build_page_repr(
|
||||
url: &str,
|
||||
title: &str,
|
||||
nodes: &[AxNode],
|
||||
filter: ElementFilter,
|
||||
ref_map: &mut ElementRefMap,
|
||||
) -> String {
|
||||
ref_map.reset();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Header
|
||||
lines.push(format!("Page: {}", url));
|
||||
if !title.is_empty() {
|
||||
lines.push(format!("Title: {}", title));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
// Walk nodes, collecting elements that pass the filter.
|
||||
for node in nodes {
|
||||
let role = node_role(node);
|
||||
|
||||
if SKIP_ROLES.contains(&role.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For "interactive" filter, only include interactive roles.
|
||||
if filter == ElementFilter::Interactive && !INTERACTIVE_ROLES.contains(&role.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip nodes without a name (usually decorative).
|
||||
let name = node_name(node);
|
||||
if name.is_empty() && filter == ElementFilter::Interactive {
|
||||
continue;
|
||||
}
|
||||
|
||||
let backend_id = match node.backend_dom_node_id {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Build display label
|
||||
let mut label = NodeLabel {
|
||||
role: role.clone(),
|
||||
name: truncate_name(&name, 80),
|
||||
properties: Vec::new(),
|
||||
};
|
||||
|
||||
// Add useful properties
|
||||
if node_has_property(node, "focused") {
|
||||
label.properties.push("focused".to_string());
|
||||
}
|
||||
if node_has_property(node, "checked") {
|
||||
label.properties.push("checked".to_string());
|
||||
}
|
||||
if node_has_property(node, "disabled") {
|
||||
label.properties.push("disabled".to_string());
|
||||
}
|
||||
if node_has_property(node, "expanded") {
|
||||
label.properties.push("expanded".to_string());
|
||||
}
|
||||
if node_has_property(node, "required") {
|
||||
label.properties.push("required".to_string());
|
||||
}
|
||||
if let Some(val) = node_value(node) {
|
||||
if !val.is_empty() && val != name {
|
||||
label
|
||||
.properties
|
||||
.push(format!("value=\"{}\"", truncate_name(&val, 40)));
|
||||
}
|
||||
}
|
||||
|
||||
let display = label.to_string();
|
||||
|
||||
let elem_ref = ElementRef {
|
||||
label: display.clone(),
|
||||
backend_node_id: backend_id,
|
||||
selector_hint: guess_selector(node),
|
||||
};
|
||||
|
||||
let ref_id = ref_map.insert(elem_ref);
|
||||
lines.push(format!("@{}: {}", ref_id, display));
|
||||
}
|
||||
|
||||
if ref_map.is_empty() {
|
||||
lines.push("(no interactive elements found)".to_string());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Extract the role string from an AX node.
|
||||
fn node_role(node: &AxNode) -> String {
|
||||
node.role
|
||||
.as_ref()
|
||||
.and_then(|v| v.value.as_ref())
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Extract the name (accessible label) from an AX node.
|
||||
fn node_name(node: &AxNode) -> String {
|
||||
node.name
|
||||
.as_ref()
|
||||
.and_then(|v| v.value.as_ref())
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Extract the value from an AX node (for inputs, etc.).
|
||||
fn node_value(node: &AxNode) -> Option<String> {
|
||||
node.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.value.as_ref())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Map a property name string to the corresponding `AxPropertyName` variant.
|
||||
fn property_by_name(name: &str) -> Option<AxPropertyName> {
|
||||
match name {
|
||||
"focused" => Some(AxPropertyName::Focused),
|
||||
"checked" => Some(AxPropertyName::Checked),
|
||||
"disabled" => Some(AxPropertyName::Disabled),
|
||||
"expanded" => Some(AxPropertyName::Expanded),
|
||||
"required" => Some(AxPropertyName::Required),
|
||||
"selected" => Some(AxPropertyName::Selected),
|
||||
"pressed" => Some(AxPropertyName::Pressed),
|
||||
"readonly" => Some(AxPropertyName::Readonly),
|
||||
"hidden" => Some(AxPropertyName::Hidden),
|
||||
"modal" => Some(AxPropertyName::Modal),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a node has a boolean property set to true.
|
||||
fn node_has_property(node: &AxNode, prop_name: &str) -> bool {
|
||||
let Some(props) = &node.properties else {
|
||||
return false;
|
||||
};
|
||||
let Some(target) = property_by_name(prop_name) else {
|
||||
return false;
|
||||
};
|
||||
props.iter().any(|p| {
|
||||
p.name == target
|
||||
&& p.value
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort CSS selector guess from node attributes.
|
||||
fn guess_selector(node: &AxNode) -> Option<String> {
|
||||
// We don't have DOM attributes directly from the AX tree,
|
||||
// so we can only offer role-based hints. The actual targeting
|
||||
// uses backend_node_id which is precise.
|
||||
let role = node_role(node);
|
||||
let name = node_name(node);
|
||||
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Build an ARIA selector hint (not used for actual targeting,
|
||||
// just a human-readable hint in debug output).
|
||||
Some(format!(
|
||||
"[role=\"{}\"][name=\"{}\"]",
|
||||
role,
|
||||
truncate_name(&name, 30)
|
||||
))
|
||||
}
|
||||
|
||||
/// Truncate a display name to max chars, adding ellipsis if needed.
|
||||
fn truncate_name(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}...",
|
||||
s.chars().take(max.saturating_sub(3)).collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for formatting a node's display label.
|
||||
struct NodeLabel {
|
||||
role: String,
|
||||
name: String,
|
||||
properties: Vec<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for NodeLabel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.role)?;
|
||||
if !self.name.is_empty() {
|
||||
write!(f, " \"{}\"", self.name)?;
|
||||
}
|
||||
if !self.properties.is_empty() {
|
||||
write!(f, " [{}]", self.properties.join(", "))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::builtin::browser::accessibility::{
|
||||
ElementFilter, ElementRefMap, build_page_repr, truncate_name,
|
||||
};
|
||||
use chromiumoxide::cdp::browser_protocol::accessibility::{
|
||||
AxNode, AxNodeId, AxValue, AxValueType,
|
||||
};
|
||||
use chromiumoxide::cdp::browser_protocol::dom::BackendNodeId;
|
||||
|
||||
fn make_ax_value(s: &str) -> AxValue {
|
||||
let mut v = AxValue::new(AxValueType::String);
|
||||
v.value = Some(serde_json::Value::String(s.to_string()));
|
||||
v
|
||||
}
|
||||
|
||||
fn make_ax_node(role: &str, name: &str, backend_id: i64) -> AxNode {
|
||||
let mut node = AxNode::new(AxNodeId::from(format!("node_{}", backend_id)), false);
|
||||
node.role = Some(make_ax_value(role));
|
||||
node.name = Some(make_ax_value(name));
|
||||
node.backend_dom_node_id = Some(BackendNodeId::new(backend_id));
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_page_repr_interactive_filter() {
|
||||
let nodes = vec![
|
||||
make_ax_node("button", "Submit", 1),
|
||||
make_ax_node("link", "Home", 2),
|
||||
make_ax_node("textbox", "Email", 3),
|
||||
make_ax_node("heading", "Welcome", 4), // not interactive
|
||||
make_ax_node("generic", "", 5), // skip role
|
||||
];
|
||||
|
||||
let mut ref_map = ElementRefMap::new();
|
||||
let repr = build_page_repr(
|
||||
"https://example.com",
|
||||
"Test Page",
|
||||
&nodes,
|
||||
ElementFilter::Interactive,
|
||||
&mut ref_map,
|
||||
);
|
||||
|
||||
assert!(repr.contains("@e1: button \"Submit\""));
|
||||
assert!(repr.contains("@e2: link \"Home\""));
|
||||
assert!(repr.contains("@e3: textbox \"Email\""));
|
||||
assert!(!repr.contains("heading"));
|
||||
assert!(!repr.contains("generic"));
|
||||
assert_eq!(ref_map.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_page_repr_all_filter() {
|
||||
let nodes = vec![
|
||||
make_ax_node("button", "Submit", 1),
|
||||
make_ax_node("heading", "Welcome", 2),
|
||||
];
|
||||
|
||||
let mut ref_map = ElementRefMap::new();
|
||||
let repr = build_page_repr(
|
||||
"https://example.com",
|
||||
"",
|
||||
&nodes,
|
||||
ElementFilter::All,
|
||||
&mut ref_map,
|
||||
);
|
||||
|
||||
assert!(repr.contains("button"));
|
||||
assert!(repr.contains("heading"));
|
||||
assert_eq!(ref_map.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_element_ref_lookup() {
|
||||
let mut ref_map = ElementRefMap::new();
|
||||
let nodes = vec![make_ax_node("button", "Click me", 1)];
|
||||
build_page_repr(
|
||||
"https://x.com",
|
||||
"",
|
||||
&nodes,
|
||||
ElementFilter::Interactive,
|
||||
&mut ref_map,
|
||||
);
|
||||
|
||||
assert!(ref_map.get("e1").is_some());
|
||||
assert!(ref_map.get("@e1").is_some()); // with @ prefix
|
||||
assert!(ref_map.get("e99").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_page() {
|
||||
let mut ref_map = ElementRefMap::new();
|
||||
let repr = build_page_repr(
|
||||
"https://empty.com",
|
||||
"",
|
||||
&[],
|
||||
ElementFilter::Interactive,
|
||||
&mut ref_map,
|
||||
);
|
||||
|
||||
assert!(repr.contains("no interactive elements"));
|
||||
assert!(ref_map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_name() {
|
||||
assert_eq!(truncate_name("short", 10), "short");
|
||||
assert_eq!(truncate_name("this is a very long name", 10), "this is...");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Headless browser tool for web interaction.
|
||||
//!
|
||||
//! A single `BrowserTool` that dispatches actions via a tagged enum,
|
||||
//! keeping the tool registry clean (one tool, not ten). The LLM sends
|
||||
//! an `action` field to pick the operation:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "action": "navigate", "url": "https://example.com" }
|
||||
//! { "action": "click", "ref": "@e3" }
|
||||
//! { "action": "type", "ref": "@e1", "text": "hello" }
|
||||
//! { "action": "read_page" }
|
||||
//! { "action": "screenshot" }
|
||||
//! ```
|
||||
//!
|
||||
//! Element references (`@e1`, `@e2`, ...) are assigned by `read_page`
|
||||
//! and remain valid until the next `read_page` call.
|
||||
|
||||
pub mod accessibility;
|
||||
pub mod session;
|
||||
pub mod stealth;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::builtin::browser::accessibility::ElementFilter;
|
||||
use crate::tools::builtin::browser::session::BrowserSession;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Actions the LLM can request from the browser tool.
|
||||
///
|
||||
/// Uses serde tagged enum: the JSON `"action"` field selects the variant,
|
||||
/// remaining fields are variant-specific parameters.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
enum BrowserAction {
|
||||
/// Navigate to a URL.
|
||||
Navigate { url: String },
|
||||
/// Go back in browser history.
|
||||
Back,
|
||||
/// Go forward in browser history.
|
||||
Forward,
|
||||
/// Read the page's accessibility tree (assigns element refs).
|
||||
ReadPage {
|
||||
/// "interactive" (default) or "all"
|
||||
filter: Option<String>,
|
||||
},
|
||||
/// Click an element by reference ID.
|
||||
Click {
|
||||
/// Element reference like "@e1" or "e1".
|
||||
#[serde(alias = "ref")]
|
||||
ref_id: String,
|
||||
},
|
||||
/// Type text into an element by reference ID.
|
||||
Type {
|
||||
/// Element reference like "@e1" or "e1".
|
||||
#[serde(alias = "ref")]
|
||||
ref_id: String,
|
||||
text: String,
|
||||
},
|
||||
/// Scroll the page.
|
||||
Scroll {
|
||||
/// "up", "down", "left", "right"
|
||||
direction: String,
|
||||
/// Number of scroll steps (default 3).
|
||||
amount: Option<u32>,
|
||||
},
|
||||
/// Capture a screenshot (returns base64 PNG).
|
||||
Screenshot {
|
||||
/// Capture full scrollable page (default false).
|
||||
full_page: Option<bool>,
|
||||
},
|
||||
/// Extract text content from the page or a CSS selector.
|
||||
Extract {
|
||||
/// Optional CSS selector. If omitted, extracts all body text.
|
||||
selector: Option<String>,
|
||||
},
|
||||
/// Wait for a CSS selector to appear or a fixed delay.
|
||||
Wait {
|
||||
/// CSS selector to wait for. If omitted, just sleeps.
|
||||
selector: Option<String>,
|
||||
/// Timeout in milliseconds (default 5000).
|
||||
timeout_ms: Option<u64>,
|
||||
},
|
||||
/// Execute JavaScript (requires user approval).
|
||||
EvalJs { expression: String },
|
||||
}
|
||||
|
||||
/// Headless browser tool for navigating web pages, interacting with
|
||||
/// elements, and extracting content.
|
||||
///
|
||||
/// Uses Chrome/Chromium via the DevTools Protocol. The browser is launched
|
||||
/// lazily on first use and includes basic anti-detection patches.
|
||||
///
|
||||
/// ## Workflow
|
||||
///
|
||||
/// 1. `navigate` to a URL
|
||||
/// 2. `read_page` to get the accessibility tree with element refs
|
||||
/// 3. `click` / `type` using the refs
|
||||
/// 4. `extract` or `screenshot` to get results
|
||||
///
|
||||
/// Element refs (`@e1`, `@e2`) are valid until the next `read_page`.
|
||||
pub struct BrowserTool {
|
||||
/// Lazily initialized browser session. RwLock because `execute` takes `&self`.
|
||||
session: RwLock<Option<BrowserSession>>,
|
||||
}
|
||||
|
||||
impl BrowserTool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the browser session is initialized, launching Chrome if needed.
|
||||
async fn ensure_session(&self) -> Result<(), ToolError> {
|
||||
let needs_launch = self.session.read().await.is_none();
|
||||
if needs_launch {
|
||||
let new_session = BrowserSession::launch().await?;
|
||||
let mut guard = self.session.write().await;
|
||||
if guard.is_none() {
|
||||
*guard = Some(new_session);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrowserTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
"browser"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Control a headless web browser. Navigate pages, read content, click elements, type text, \
|
||||
take screenshots. Use 'read_page' to get an accessibility tree with element references \
|
||||
(@e1, @e2...), then use those refs for 'click' and 'type' actions.\n\n\
|
||||
Actions: navigate, back, forward, read_page, click, type, scroll, screenshot, extract, \
|
||||
wait, eval_js"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"navigate", "back", "forward", "read_page", "click",
|
||||
"type", "scroll", "screenshot", "extract", "wait", "eval_js"
|
||||
],
|
||||
"description": "The browser action to perform"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to navigate to (for 'navigate' action)"
|
||||
},
|
||||
"ref_id": {
|
||||
"type": "string",
|
||||
"description": "Element reference like '@e1' (for 'click' and 'type' actions)"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to type (for 'type' action)"
|
||||
},
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["up", "down", "left", "right"],
|
||||
"description": "Scroll direction (for 'scroll' action)"
|
||||
},
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"description": "Scroll steps, default 3 (for 'scroll' action)"
|
||||
},
|
||||
"full_page": {
|
||||
"type": "boolean",
|
||||
"description": "Capture full scrollable page (for 'screenshot' action)"
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector (for 'extract' and 'wait' actions)"
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (for 'wait' action, default 5000)"
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"enum": ["interactive", "all"],
|
||||
"description": "Element filter for 'read_page' (default: interactive)"
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "JavaScript expression (for 'eval_js' action)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let action: BrowserAction = serde_json::from_value(params)
|
||||
.map_err(|e| ToolError::InvalidParameters(format!("Invalid browser action: {}", e)))?;
|
||||
|
||||
// Launch browser on first use.
|
||||
self.ensure_session().await?;
|
||||
|
||||
match action {
|
||||
BrowserAction::Navigate { url } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let title = session.navigate(&url).await?;
|
||||
let current_url = session.current_url().await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({
|
||||
"url": current_url,
|
||||
"title": title,
|
||||
"status": "navigated"
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::Back => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
session.go_back().await?;
|
||||
let url = session.current_url().await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({ "url": url, "status": "navigated_back" }),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::Forward => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
session.go_forward().await?;
|
||||
let url = session.current_url().await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({ "url": url, "status": "navigated_forward" }),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::ReadPage { filter } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let element_filter = ElementFilter::from_str_opt(filter.as_deref());
|
||||
let repr = session.read_page(element_filter).await?;
|
||||
|
||||
Ok(ToolOutput::text(repr, start.elapsed()))
|
||||
}
|
||||
|
||||
BrowserAction::Click { ref_id } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
session.click_element(&ref_id).await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({ "status": "clicked", "ref": ref_id }),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::Type { ref_id, text } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
session.type_text(&ref_id, &text).await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({
|
||||
"status": "typed",
|
||||
"ref": ref_id,
|
||||
"length": text.len()
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::Scroll { direction, amount } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let steps = amount.unwrap_or(3);
|
||||
session.scroll(&direction, steps).await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({
|
||||
"status": "scrolled",
|
||||
"direction": direction,
|
||||
"amount": steps
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::Screenshot { full_page } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let b64 = session.screenshot(full_page.unwrap_or(false)).await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({
|
||||
"format": "png",
|
||||
"encoding": "base64",
|
||||
"data": b64,
|
||||
"full_page": full_page.unwrap_or(false)
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::Extract { selector } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let text = session.extract_text(selector.as_deref()).await?;
|
||||
|
||||
// Truncate very long text to avoid blowing up context.
|
||||
let truncated = if text.len() > 32_000 {
|
||||
format!(
|
||||
"{}...\n\n[truncated, {} total chars]",
|
||||
&text[..32_000],
|
||||
text.len()
|
||||
)
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
|
||||
Ok(ToolOutput::text(&truncated, start.elapsed()).with_raw(text))
|
||||
}
|
||||
|
||||
BrowserAction::Wait {
|
||||
selector,
|
||||
timeout_ms,
|
||||
} => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let timeout = timeout_ms.unwrap_or(5000);
|
||||
let found = session.wait(selector.as_deref(), timeout).await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({
|
||||
"found": found,
|
||||
"selector": selector,
|
||||
"timeout_ms": timeout
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
BrowserAction::EvalJs { expression } => {
|
||||
let session = self.session.read().await;
|
||||
let session = session.as_ref().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let result = session.eval_js(&expression).await?;
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({ "result": result }),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
|
||||
Some(Duration::from_secs(10))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true // Page content is untrusted external data
|
||||
}
|
||||
|
||||
fn requires_approval(&self) -> bool {
|
||||
true // Browser navigates to external sites, executes JS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::builtin::browser::BrowserTool;
|
||||
use crate::tools::tool::Tool;
|
||||
|
||||
#[test]
|
||||
fn test_browser_tool_metadata() {
|
||||
let tool = BrowserTool::new();
|
||||
assert_eq!(tool.name(), "browser");
|
||||
assert!(tool.requires_approval());
|
||||
assert!(tool.requires_sanitization());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_action_enum() {
|
||||
let tool = BrowserTool::new();
|
||||
let schema = tool.parameters_schema();
|
||||
|
||||
let action_prop = schema.get("properties").and_then(|p| p.get("action"));
|
||||
assert!(action_prop.is_some());
|
||||
|
||||
let action_enum = action_prop.and_then(|a| a.get("enum"));
|
||||
assert!(action_enum.is_some());
|
||||
|
||||
let actions: Vec<&str> = action_enum
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(actions.contains(&"navigate"));
|
||||
assert!(actions.contains(&"click"));
|
||||
assert!(actions.contains(&"type"));
|
||||
assert!(actions.contains(&"read_page"));
|
||||
assert!(actions.contains(&"screenshot"));
|
||||
assert!(actions.contains(&"eval_js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_deserialization() {
|
||||
use super::BrowserAction;
|
||||
|
||||
// Navigate
|
||||
let action: BrowserAction = serde_json::from_value(
|
||||
serde_json::json!({"action": "navigate", "url": "https://x.com"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(action, BrowserAction::Navigate { url } if url == "https://x.com"));
|
||||
|
||||
// Click with "ref" alias
|
||||
let action: BrowserAction =
|
||||
serde_json::from_value(serde_json::json!({"action": "click", "ref": "@e1"})).unwrap();
|
||||
assert!(matches!(action, BrowserAction::Click { ref_id } if ref_id == "@e1"));
|
||||
|
||||
// Click with "ref_id"
|
||||
let action: BrowserAction =
|
||||
serde_json::from_value(serde_json::json!({"action": "click", "ref_id": "e2"})).unwrap();
|
||||
assert!(matches!(action, BrowserAction::Click { ref_id } if ref_id == "e2"));
|
||||
|
||||
// Type
|
||||
let action: BrowserAction = serde_json::from_value(
|
||||
serde_json::json!({"action": "type", "ref": "@e1", "text": "hello"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(action, BrowserAction::Type { ref_id, text } if ref_id == "@e1" && text == "hello")
|
||||
);
|
||||
|
||||
// ReadPage with default filter
|
||||
let action: BrowserAction =
|
||||
serde_json::from_value(serde_json::json!({"action": "read_page"})).unwrap();
|
||||
assert!(matches!(action, BrowserAction::ReadPage { filter: None }));
|
||||
|
||||
// Screenshot
|
||||
let action: BrowserAction =
|
||||
serde_json::from_value(serde_json::json!({"action": "screenshot", "full_page": true}))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
action,
|
||||
BrowserAction::Screenshot {
|
||||
full_page: Some(true)
|
||||
}
|
||||
));
|
||||
|
||||
// Invalid action
|
||||
let result: Result<BrowserAction, _> =
|
||||
serde_json::from_value(serde_json::json!({"action": "fly_to_moon"}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
//! Browser session management.
|
||||
//!
|
||||
//! Owns the Chrome process lifecycle and per-tab state. Sessions are spawned
|
||||
//! lazily on first browser action and torn down when dropped.
|
||||
//!
|
||||
//! ```text
|
||||
//! BrowserSession
|
||||
//! ├── Browser (chromiumoxide, owns Chrome child process)
|
||||
//! ├── handler_task (JoinHandle polling CDP WebSocket)
|
||||
//! ├── tabs: HashMap<tab_id, Page>
|
||||
//! ├── active_tab: current tab id
|
||||
//! └── element_refs: ElementRefMap (valid until next read_page)
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chromiumoxide::Page;
|
||||
use chromiumoxide::browser::{Browser, BrowserConfig};
|
||||
use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
|
||||
use chromiumoxide::cdp::browser_protocol::dom::{GetBoxModelParams, ScrollIntoViewIfNeededParams};
|
||||
use chromiumoxide::cdp::browser_protocol::input::{
|
||||
DispatchMouseEventParams, DispatchMouseEventType, InsertTextParams, MouseButton,
|
||||
};
|
||||
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
|
||||
use chromiumoxide::page::ScreenshotParams;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::tools::builtin::browser::accessibility::{
|
||||
ElementFilter, ElementRefMap, build_page_repr,
|
||||
};
|
||||
use crate::tools::builtin::browser::stealth;
|
||||
use crate::tools::tool::ToolError;
|
||||
|
||||
/// Manages a Chrome browser instance and its tabs.
|
||||
pub struct BrowserSession {
|
||||
#[allow(dead_code)] // Used by new_tab() which is reserved for tab management actions
|
||||
browser: Browser,
|
||||
_handler_task: JoinHandle<()>,
|
||||
tabs: HashMap<String, Page>,
|
||||
active_tab: String,
|
||||
element_refs: Arc<RwLock<ElementRefMap>>,
|
||||
#[allow(dead_code)] // Used by new_tab() which is reserved for tab management actions
|
||||
stealth_js: String,
|
||||
}
|
||||
|
||||
impl BrowserSession {
|
||||
/// Launch a new Chrome browser session.
|
||||
///
|
||||
/// Locates Chrome on the system, applies stealth patches, and opens
|
||||
/// an initial blank tab.
|
||||
pub async fn launch() -> Result<Self, ToolError> {
|
||||
let chrome_path = find_chrome().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"Chrome/Chromium not found. Install Chrome or set CHROME_PATH.".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Shared profile so the agent accumulates useful state across sessions
|
||||
// (logged-in sessions, dismissed cookie banners, local storage).
|
||||
// Delete ~/.ironclaw/browser/profile/ to reset.
|
||||
let profile_dir = browser_profile_dir();
|
||||
std::fs::create_dir_all(&profile_dir).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to create browser profile dir: {}", e))
|
||||
})?;
|
||||
|
||||
let mut config_builder = BrowserConfig::builder()
|
||||
.chrome_executable(&chrome_path)
|
||||
.user_data_dir(&profile_dir)
|
||||
.window_size(1920, 1080)
|
||||
.no_sandbox();
|
||||
|
||||
for arg in stealth::stealth_args() {
|
||||
config_builder = config_builder.arg(arg);
|
||||
}
|
||||
|
||||
let config = config_builder.build().map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to build browser config: {}", e))
|
||||
})?;
|
||||
|
||||
let (browser, mut handler) = Browser::launch(config)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to launch Chrome: {}", e)))?;
|
||||
|
||||
// The handler must be polled continuously or the CDP connection dies.
|
||||
let handler_task = tokio::spawn(async move {
|
||||
while let Some(event) = handler.next().await {
|
||||
if event.is_err() {
|
||||
tracing::warn!("Browser handler error: {:?}", event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Open initial tab.
|
||||
let page = browser.new_page("about:blank").await.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to open initial tab: {}", e))
|
||||
})?;
|
||||
|
||||
// Inject stealth JS on every new document load for this page.
|
||||
let stealth_js = stealth::stealth_js().to_string();
|
||||
page.evaluate_on_new_document(stealth_js.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to inject stealth JS: {}", e))
|
||||
})?;
|
||||
|
||||
let tab_id = "tab0".to_string();
|
||||
let mut tabs = HashMap::new();
|
||||
tabs.insert(tab_id.clone(), page);
|
||||
|
||||
Ok(Self {
|
||||
browser,
|
||||
_handler_task: handler_task,
|
||||
tabs,
|
||||
active_tab: tab_id,
|
||||
element_refs: Arc::new(RwLock::new(ElementRefMap::new())),
|
||||
stealth_js,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the active page, or error if session is broken.
|
||||
fn active_page(&self) -> Result<&Page, ToolError> {
|
||||
self.tabs.get(&self.active_tab).ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(format!("No active tab: {}", self.active_tab))
|
||||
})
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
pub async fn navigate(&self, url: &str) -> Result<String, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
page.goto(url)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExternalService(format!("Navigation failed: {}", e)))?;
|
||||
|
||||
let title = page
|
||||
.get_title()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get page title: {}", e)))?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
pub async fn go_back(&self) -> Result<(), ToolError> {
|
||||
let page = self.active_page()?;
|
||||
page.evaluate("window.history.back()")
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to go back: {}", e)))?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn go_forward(&self) -> Result<(), ToolError> {
|
||||
let page = self.active_page()?;
|
||||
page.evaluate("window.history.forward()")
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to go forward: {}", e)))?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Page reading ---
|
||||
|
||||
/// Build accessibility tree representation and update element refs.
|
||||
pub async fn read_page(&self, filter: ElementFilter) -> Result<String, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
|
||||
let url = page
|
||||
.url()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get URL: {}", e)))?
|
||||
.unwrap_or_else(|| "about:blank".to_string());
|
||||
|
||||
let title = page
|
||||
.get_title()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get title: {}", e)))?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fetch full accessibility tree via CDP.
|
||||
let ax_result = page
|
||||
.execute(GetFullAxTreeParams::default())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to get accessibility tree: {}", e))
|
||||
})?;
|
||||
|
||||
let nodes = ax_result.result.nodes;
|
||||
|
||||
let mut ref_map = self.element_refs.write().await;
|
||||
let repr = build_page_repr(&url, &title, &nodes, filter, &mut ref_map);
|
||||
|
||||
Ok(repr)
|
||||
}
|
||||
|
||||
/// Extract text content from the page or a CSS selector.
|
||||
pub async fn extract_text(&self, selector: Option<&str>) -> Result<String, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
|
||||
let js = match selector {
|
||||
Some(sel) => {
|
||||
let escaped = serde_json::to_string(sel).map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("Invalid selector: {}", e))
|
||||
})?;
|
||||
format!(
|
||||
"(() => {{ const el = document.querySelector({}); return el ? el.innerText : null; }})()",
|
||||
escaped
|
||||
)
|
||||
}
|
||||
None => "document.body.innerText".to_string(),
|
||||
};
|
||||
|
||||
let result: Option<String> = page
|
||||
.evaluate(js.as_str())
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to extract text: {}", e)))?
|
||||
.into_value()
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to deserialize text: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(result.unwrap_or_default())
|
||||
}
|
||||
|
||||
// --- Interaction ---
|
||||
|
||||
/// Click an element by reference ID (e.g., "e1" or "@e1").
|
||||
///
|
||||
/// Uses DOM.scrollIntoViewIfNeeded + DOM.getBoxModel to find the element's
|
||||
/// center coordinates, then dispatches mouse press + release at that point.
|
||||
pub async fn click_element(&self, ref_id: &str) -> Result<(), ToolError> {
|
||||
let page = self.active_page()?;
|
||||
let refs = self.element_refs.read().await;
|
||||
|
||||
let elem_ref = refs.get(ref_id).ok_or_else(|| {
|
||||
ToolError::InvalidParameters(format!(
|
||||
"Unknown element reference '{}'. Call browser with action 'read_page' first.",
|
||||
ref_id
|
||||
))
|
||||
})?;
|
||||
|
||||
let backend_node_id = elem_ref.backend_node_id;
|
||||
drop(refs);
|
||||
|
||||
// Scroll the element into the viewport.
|
||||
page.execute(
|
||||
ScrollIntoViewIfNeededParams::builder()
|
||||
.backend_node_id(backend_node_id)
|
||||
.build(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to scroll element into view: {}", e))
|
||||
})?;
|
||||
|
||||
// Get element's bounding box via DOM.getBoxModel.
|
||||
let box_result = page
|
||||
.execute(
|
||||
GetBoxModelParams::builder()
|
||||
.backend_node_id(backend_node_id)
|
||||
.build(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to get element box model: {}", e))
|
||||
})?;
|
||||
|
||||
// Content quad is [x1,y1, x2,y2, x3,y3, x4,y4]. Center = average of 4 corners.
|
||||
let content = box_result.result.model.content.inner();
|
||||
if content.len() < 8 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Element has no valid bounding box".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let x = (content[0] + content[2] + content[4] + content[6]) / 4.0;
|
||||
let y = (content[1] + content[3] + content[5] + content[7]) / 4.0;
|
||||
|
||||
// Dispatch mouse press + release at center of element.
|
||||
page.execute(
|
||||
DispatchMouseEventParams::builder()
|
||||
.r#type(DispatchMouseEventType::MousePressed)
|
||||
.x(x)
|
||||
.y(y)
|
||||
.button(MouseButton::Left)
|
||||
.click_count(1)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to build mouse event: {}", e))
|
||||
})?,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Mouse press failed: {}", e)))?;
|
||||
|
||||
page.execute(
|
||||
DispatchMouseEventParams::builder()
|
||||
.r#type(DispatchMouseEventType::MouseReleased)
|
||||
.x(x)
|
||||
.y(y)
|
||||
.button(MouseButton::Left)
|
||||
.click_count(1)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to build mouse event: {}", e))
|
||||
})?,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Mouse release failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Type text into an element by reference ID.
|
||||
pub async fn type_text(&self, ref_id: &str, text: &str) -> Result<(), ToolError> {
|
||||
// First click to focus the element.
|
||||
self.click_element(ref_id).await?;
|
||||
|
||||
// Brief delay to let focus settle.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let page = self.active_page()?;
|
||||
|
||||
// Use CDP insertText for reliable IME-style text entry.
|
||||
page.execute(InsertTextParams::new(text))
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to type text: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scroll the page.
|
||||
pub async fn scroll(&self, direction: &str, amount: u32) -> Result<(), ToolError> {
|
||||
let page = self.active_page()?;
|
||||
|
||||
let (dx, dy) = match direction {
|
||||
"up" => (0, -(amount as i32 * 100)),
|
||||
"down" => (0, amount as i32 * 100),
|
||||
"left" => (-(amount as i32 * 100), 0),
|
||||
"right" => (amount as i32 * 100, 0),
|
||||
_ => {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"Invalid scroll direction '{}'. Use: up, down, left, right",
|
||||
direction
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let js = format!("window.scrollBy({}, {})", dx, dy);
|
||||
page.evaluate(js.as_str())
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Scroll failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for a CSS selector to appear, or a fixed timeout.
|
||||
pub async fn wait(&self, selector: Option<&str>, timeout_ms: u64) -> Result<bool, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||
|
||||
match selector {
|
||||
Some(sel) => {
|
||||
let poll_interval = std::time::Duration::from_millis(100);
|
||||
let start = std::time::Instant::now();
|
||||
let escaped = serde_json::to_string(sel).map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("Invalid selector: {}", e))
|
||||
})?;
|
||||
|
||||
loop {
|
||||
let js = format!("!!document.querySelector({})", escaped);
|
||||
let found: bool = page
|
||||
.evaluate(js.as_str())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Wait poll failed: {}", e))
|
||||
})?
|
||||
.into_value()
|
||||
.unwrap_or(false);
|
||||
|
||||
if found {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if start.elapsed() >= timeout {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tokio::time::sleep(timeout).await;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Screenshots ---
|
||||
|
||||
/// Capture a screenshot as base64-encoded PNG.
|
||||
pub async fn screenshot(&self, full_page: bool) -> Result<String, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
|
||||
let params = ScreenshotParams::builder()
|
||||
.format(CaptureScreenshotFormat::Png)
|
||||
.full_page(full_page)
|
||||
.build();
|
||||
|
||||
let bytes = page
|
||||
.screenshot(params)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Screenshot failed: {}", e)))?;
|
||||
|
||||
use base64::Engine;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
// --- JavaScript ---
|
||||
|
||||
/// Execute arbitrary JavaScript and return the result.
|
||||
pub async fn eval_js(&self, expression: &str) -> Result<serde_json::Value, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
|
||||
let result = page
|
||||
.evaluate(expression)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("JS evaluation failed: {}", e)))?;
|
||||
|
||||
let value: serde_json::Value = result.into_value().unwrap_or(serde_json::Value::Null);
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
// --- Tab management ---
|
||||
|
||||
/// Open a new tab and make it active.
|
||||
#[allow(dead_code)] // Reserved for tab management actions
|
||||
pub async fn new_tab(&mut self, url: &str) -> Result<String, ToolError> {
|
||||
let page =
|
||||
self.browser.new_page(url).await.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to open new tab: {}", e))
|
||||
})?;
|
||||
|
||||
// Inject stealth JS on the new page too.
|
||||
page.evaluate_on_new_document(self.stealth_js.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to inject stealth JS on new tab: {}", e))
|
||||
})?;
|
||||
|
||||
let tab_id = format!("tab{}", self.tabs.len());
|
||||
self.tabs.insert(tab_id.clone(), page);
|
||||
self.active_tab = tab_id.clone();
|
||||
|
||||
// Clear element refs since we're on a new page.
|
||||
self.element_refs.write().await.reset();
|
||||
|
||||
Ok(tab_id)
|
||||
}
|
||||
|
||||
/// List open tabs.
|
||||
#[allow(dead_code)] // Reserved for tab management actions
|
||||
pub fn list_tabs(&self) -> Vec<String> {
|
||||
self.tabs.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Switch to a different tab.
|
||||
#[allow(dead_code)] // Reserved for tab management actions
|
||||
pub async fn switch_tab(&mut self, tab_id: &str) -> Result<(), ToolError> {
|
||||
if !self.tabs.contains_key(tab_id) {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"Unknown tab '{}'. Open tabs: {:?}",
|
||||
tab_id,
|
||||
self.list_tabs()
|
||||
)));
|
||||
}
|
||||
|
||||
self.active_tab = tab_id.to_string();
|
||||
// Clear element refs when switching tabs.
|
||||
self.element_refs.write().await.reset();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current page URL.
|
||||
pub async fn current_url(&self) -> Result<String, ToolError> {
|
||||
let page = self.active_page()?;
|
||||
page.url()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get URL: {}", e)))
|
||||
.map(|u| u.unwrap_or_else(|| "about:blank".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BrowserSession {
|
||||
fn drop(&mut self) {
|
||||
tracing::debug!("Browser session dropping, Chrome process will be cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `~/.ironclaw/browser/profile/`.
|
||||
fn browser_profile_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("browser")
|
||||
.join("profile")
|
||||
}
|
||||
|
||||
/// Search common locations for a Chrome/Chromium binary.
|
||||
pub fn find_chrome() -> Option<PathBuf> {
|
||||
// Environment variable override.
|
||||
if let Ok(path) = std::env::var("CHROME_PATH") {
|
||||
let p = PathBuf::from(&path);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
]
|
||||
} else if cfg!(target_os = "linux") {
|
||||
vec![
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
]
|
||||
} else {
|
||||
// Windows paths.
|
||||
vec![
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
]
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let p = PathBuf::from(candidate);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
which_chrome_in_path()
|
||||
}
|
||||
|
||||
/// Check if chrome/chromium is available in PATH.
|
||||
fn which_chrome_in_path() -> Option<PathBuf> {
|
||||
let path_var = std::env::var("PATH").ok()?;
|
||||
let separator = if cfg!(windows) { ';' } else { ':' };
|
||||
for name in &["google-chrome", "chromium", "chromium-browser", "chrome"] {
|
||||
for dir in path_var.split(separator) {
|
||||
let candidate = PathBuf::from(dir).join(name);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::builtin::browser::session::find_chrome;
|
||||
|
||||
#[test]
|
||||
fn test_find_chrome_returns_path_or_none() {
|
||||
let result = find_chrome();
|
||||
if let Some(path) = &result {
|
||||
assert!(
|
||||
path.exists(),
|
||||
"find_chrome returned non-existent path: {:?}",
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Anti-detection JavaScript patches for headless Chrome.
|
||||
//!
|
||||
//! Injects scripts via `Page.addScriptToEvaluateOnNewDocument` to suppress
|
||||
//! common bot-detection signals. Handles ~80% of detection for legitimate
|
||||
//! browsing (not adversarial scraping against Cloudflare Enterprise).
|
||||
//!
|
||||
//! What we patch:
|
||||
//! - `navigator.webdriver` (trivial but still checked)
|
||||
//! - `navigator.plugins` (headless has empty plugin list)
|
||||
//! - `navigator.languages` (match system locale)
|
||||
//! - `chrome.runtime` (looks like a real extension API)
|
||||
//! - `HeadlessChrome` user-agent substring (suppressed via launch flags)
|
||||
|
||||
/// Chrome launch arguments that reduce detection surface.
|
||||
pub fn stealth_args() -> Vec<&'static str> {
|
||||
vec![
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-infobars",
|
||||
"--disable-background-networking",
|
||||
"--disable-prompt-on-repost",
|
||||
"--disable-hang-monitor",
|
||||
"--disable-sync",
|
||||
"--metrics-recording-only",
|
||||
"--no-service-autorun",
|
||||
]
|
||||
}
|
||||
|
||||
/// JavaScript injected before any page scripts run.
|
||||
///
|
||||
/// This covers the most common fingerprinting checks. Each patch is
|
||||
/// a self-contained IIFE so failures in one don't break the others.
|
||||
pub fn stealth_js() -> &'static str {
|
||||
r#"
|
||||
// --- navigator.webdriver ---
|
||||
// CDP sets this to true; real browsers have it undefined or false.
|
||||
(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
})();
|
||||
|
||||
// --- navigator.plugins ---
|
||||
// Headless Chrome reports an empty plugin array. Real Chrome on desktop
|
||||
// always has at least these two. We fake the array shape.
|
||||
(() => {
|
||||
const pluginData = [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer',
|
||||
description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
|
||||
description: '' },
|
||||
];
|
||||
|
||||
const makeMimeType = (type_, suffixes, desc, plugin) => {
|
||||
const mt = Object.create(MimeType.prototype);
|
||||
Object.defineProperties(mt, {
|
||||
type: { get: () => type_ },
|
||||
suffixes: { get: () => suffixes },
|
||||
description: { get: () => desc },
|
||||
enabledPlugin: { get: () => plugin },
|
||||
});
|
||||
return mt;
|
||||
};
|
||||
|
||||
const makePlugin = (data) => {
|
||||
const p = Object.create(Plugin.prototype);
|
||||
const mimes = [makeMimeType('application/pdf', 'pdf', 'Portable Document Format', p)];
|
||||
Object.defineProperties(p, {
|
||||
name: { get: () => data.name },
|
||||
filename: { get: () => data.filename },
|
||||
description: { get: () => data.description },
|
||||
length: { get: () => mimes.length },
|
||||
0: { get: () => mimes[0] },
|
||||
});
|
||||
p.item = (i) => mimes[i] || null;
|
||||
p.namedItem = (name) => mimes.find(m => m.type === name) || null;
|
||||
return p;
|
||||
};
|
||||
|
||||
const plugins = pluginData.map(makePlugin);
|
||||
const pluginArray = Object.create(PluginArray.prototype);
|
||||
Object.defineProperties(pluginArray, {
|
||||
length: { get: () => plugins.length },
|
||||
0: { get: () => plugins[0] },
|
||||
1: { get: () => plugins[1] },
|
||||
});
|
||||
pluginArray.item = (i) => plugins[i] || null;
|
||||
pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null;
|
||||
pluginArray.refresh = () => {};
|
||||
pluginArray[Symbol.iterator] = function* () { yield* plugins; };
|
||||
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => pluginArray,
|
||||
configurable: true,
|
||||
});
|
||||
})();
|
||||
|
||||
// --- navigator.languages ---
|
||||
// Headless sometimes reports just ['en'] instead of a realistic list.
|
||||
(() => {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en'],
|
||||
configurable: true,
|
||||
});
|
||||
})();
|
||||
|
||||
// --- chrome.runtime ---
|
||||
// Bot detectors check for chrome.runtime to see if it's a real Chrome
|
||||
// extension environment. CDP-controlled Chrome has a broken stub.
|
||||
(() => {
|
||||
if (!window.chrome) window.chrome = {};
|
||||
if (!window.chrome.runtime) {
|
||||
window.chrome.runtime = {
|
||||
connect: () => {},
|
||||
sendMessage: () => {},
|
||||
id: undefined,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
// --- Permissions API ---
|
||||
// Headless reports 'denied' for notification permissions by default,
|
||||
// which is a known fingerprinting signal.
|
||||
(() => {
|
||||
const originalQuery = window.Permissions?.prototype?.query;
|
||||
if (originalQuery) {
|
||||
window.Permissions.prototype.query = function(params) {
|
||||
if (params?.name === 'notifications') {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||
}
|
||||
return originalQuery.call(this, params);
|
||||
};
|
||||
}
|
||||
})();
|
||||
"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::builtin::browser::stealth;
|
||||
|
||||
#[test]
|
||||
fn stealth_js_is_not_empty() {
|
||||
let js = stealth::stealth_js();
|
||||
assert!(js.len() > 100);
|
||||
assert!(js.contains("navigator"));
|
||||
assert!(js.contains("webdriver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stealth_args_are_valid_flags() {
|
||||
for arg in stealth::stealth_args() {
|
||||
assert!(arg.starts_with("--"), "arg should start with --: {}", arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Built-in tools that come with the agent.
|
||||
|
||||
mod browser;
|
||||
mod echo;
|
||||
pub mod extension_tools;
|
||||
mod file;
|
||||
@@ -11,6 +12,8 @@ pub mod routine;
|
||||
pub(crate) mod shell;
|
||||
mod time;
|
||||
|
||||
pub use browser::BrowserTool;
|
||||
pub use browser::session::find_chrome;
|
||||
pub use echo::EchoTool;
|
||||
pub use extension_tools::{
|
||||
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
|
||||
|
||||
@@ -14,10 +14,10 @@ use crate::safety::SafetyLayer;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||
use crate::tools::builtin::{
|
||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool, JsonTool,
|
||||
ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool,
|
||||
ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool, ToolInstallTool,
|
||||
ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool,
|
||||
ApplyPatchTool, BrowserTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool,
|
||||
JsonTool, ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool,
|
||||
MemoryWriteTool, ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool,
|
||||
ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool,
|
||||
};
|
||||
use crate::tools::tool::{Tool, ToolDomain};
|
||||
use crate::tools::wasm::{
|
||||
@@ -218,8 +218,9 @@ impl ToolRegistry {
|
||||
self.register_sync(Arc::new(WriteFileTool::new()));
|
||||
self.register_sync(Arc::new(ListDirTool::new()));
|
||||
self.register_sync(Arc::new(ApplyPatchTool::new()));
|
||||
self.register_sync(Arc::new(BrowserTool::new()));
|
||||
|
||||
tracing::info!("Registered 5 development tools");
|
||||
tracing::info!("Registered 6 development tools (includes browser)");
|
||||
}
|
||||
|
||||
/// Register memory tools with a workspace.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Integration test for the browser tool.
|
||||
//!
|
||||
//! Requires Chrome installed. Run with:
|
||||
//! cargo test --test browser_integration -- --nocapture
|
||||
|
||||
use ironclaw::context::JobContext;
|
||||
use ironclaw::tools::Tool;
|
||||
use ironclaw::tools::builtin::{BrowserTool, find_chrome};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_browser_navigate_and_screenshot() {
|
||||
// Skip if Chrome/Chromium is not installed (works on macOS, Linux, Windows).
|
||||
if find_chrome().is_none() {
|
||||
eprintln!("Skipping: Chrome not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let tool = BrowserTool::new();
|
||||
let ctx = JobContext::default();
|
||||
|
||||
// 1. Navigate to Wikipedia
|
||||
eprintln!("=== Navigating to Wikipedia...");
|
||||
let nav_result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"action": "navigate",
|
||||
"url": "https://en.wikipedia.org/wiki/Mariam_Almheiri"
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
match &nav_result {
|
||||
Ok(output) => {
|
||||
eprintln!(
|
||||
"Navigation result: {}",
|
||||
serde_json::to_string_pretty(&output.result).unwrap()
|
||||
);
|
||||
let title = output
|
||||
.result
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
title.contains("Mariam") || title.contains("Almheiri"),
|
||||
"Page title should mention Mariam Almheiri, got: {}",
|
||||
title
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Navigation failed: {}", e);
|
||||
panic!("Navigation should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read the accessibility tree
|
||||
eprintln!("\n=== Reading page accessibility tree...");
|
||||
let read_result = tool
|
||||
.execute(serde_json::json!({"action": "read_page"}), &ctx)
|
||||
.await;
|
||||
|
||||
match &read_result {
|
||||
Ok(output) => {
|
||||
let tree = output.result.as_str().unwrap_or("");
|
||||
let line_count = tree.lines().count();
|
||||
eprintln!("Accessibility tree: {} lines", line_count);
|
||||
// Print first 20 lines
|
||||
for line in tree.lines().take(20) {
|
||||
eprintln!(" {}", line);
|
||||
}
|
||||
if line_count > 20 {
|
||||
eprintln!(" ... ({} more lines)", line_count - 20);
|
||||
}
|
||||
assert!(line_count > 3, "Should have some elements on the page");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Read page failed: {}", e);
|
||||
panic!("Read page should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get page dimensions via eval_js to compute center
|
||||
eprintln!("\n=== Getting page dimensions...");
|
||||
let dims_result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"action": "eval_js",
|
||||
"expression": "JSON.stringify({w: window.innerWidth, h: window.innerHeight, scrollH: document.body.scrollHeight})"
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (viewport_w, viewport_h) = match &dims_result {
|
||||
Ok(output) => {
|
||||
let result_str = output
|
||||
.result
|
||||
.get("result")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("{}");
|
||||
let dims: serde_json::Value = serde_json::from_str(result_str).unwrap_or_default();
|
||||
let w = dims.get("w").and_then(|v| v.as_f64()).unwrap_or(1920.0);
|
||||
let h = dims.get("h").and_then(|v| v.as_f64()).unwrap_or(1080.0);
|
||||
eprintln!("Viewport: {}x{}", w, h);
|
||||
(w, h)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("eval_js failed: {}", e);
|
||||
(1920.0, 1080.0)
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Scroll to middle of page first
|
||||
eprintln!("\n=== Scrolling to middle of page...");
|
||||
let _ = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"action": "eval_js",
|
||||
"expression": "window.scrollTo(0, document.body.scrollHeight / 2 - window.innerHeight / 2)"
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Brief wait for scroll to settle
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
// 5. Take full viewport screenshot
|
||||
eprintln!("\n=== Taking viewport screenshot...");
|
||||
let screenshot_result = tool
|
||||
.execute(serde_json::json!({"action": "screenshot"}), &ctx)
|
||||
.await;
|
||||
|
||||
match &screenshot_result {
|
||||
Ok(output) => {
|
||||
let b64 = output
|
||||
.result
|
||||
.get("data")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
eprintln!(
|
||||
"Screenshot: {} base64 chars ({} bytes decoded)",
|
||||
b64.len(),
|
||||
b64.len() * 3 / 4
|
||||
);
|
||||
|
||||
// Save to /tmp for inspection
|
||||
use base64::Engine;
|
||||
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||
let path = "/tmp/ironclaw_browser_test_viewport.png";
|
||||
if std::fs::write(path, &bytes).is_ok() {
|
||||
eprintln!("Saved viewport screenshot to {}", path);
|
||||
}
|
||||
|
||||
// Now crop the center 10x10 using raw PNG manipulation
|
||||
// We'll use eval_js to take a clipped screenshot via CDP directly
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Screenshot failed: {}", e);
|
||||
panic!("Screenshot should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Take a 10x10 screenshot from the center of the viewport using eval_js
|
||||
// We can't directly use the clip param through the current tool API,
|
||||
// so we'll take the viewport screenshot and note the center crop coords.
|
||||
let center_x = (viewport_w / 2.0 - 5.0).max(0.0);
|
||||
let center_y = (viewport_h / 2.0 - 5.0).max(0.0);
|
||||
eprintln!(
|
||||
"\n=== Center 10x10 crop would be at ({}, {}) to ({}, {})",
|
||||
center_x,
|
||||
center_y,
|
||||
center_x + 10.0,
|
||||
center_y + 10.0
|
||||
);
|
||||
|
||||
// 7. Extract some text to verify content loaded
|
||||
eprintln!("\n=== Extracting page text...");
|
||||
let extract_result = tool
|
||||
.execute(
|
||||
serde_json::json!({"action": "extract", "selector": "h1"}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
match &extract_result {
|
||||
Ok(output) => {
|
||||
let text = output.result.as_str().unwrap_or("");
|
||||
eprintln!("H1 text: {}", text);
|
||||
assert!(
|
||||
text.contains("Mariam") || text.contains("Almheiri"),
|
||||
"H1 should contain the article subject, got: {}",
|
||||
text
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Extract failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("\n=== All browser integration tests passed!");
|
||||
}
|
||||
Reference in New Issue
Block a user