use crate::error::{Error, Result};
use crate::model::{ContentBlock, TextContent};
use crate::tools::{Tool, ToolEffects, ToolOutput, ToolUpdate};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use uuid::Uuid;
const MIN_VALID_PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82,
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserTabInfo {
pub name: String,
pub url: String,
pub title: String,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserElementRef {
pub ref_id: String,
pub tag: String,
pub role: String,
pub text: String,
pub selector: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserSnapshot {
pub url: String,
pub title: String,
pub elements: Vec<BrowserElementRef>,
pub summary: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BrowserSettings {
#[serde(alias = "enableBrowser")]
pub enable_browser: Option<bool>,
#[serde(alias = "executablePath")]
pub executable_path: Option<String>,
#[serde(alias = "remoteDebuggingPort")]
pub remote_debugging_port: Option<u16>,
pub headless: Option<bool>,
#[serde(alias = "userAgent")]
pub user_agent: Option<String>,
#[serde(alias = "domainAllowlist")]
pub domain_allowlist: Option<Vec<String>>,
}
pub struct BrowserTool {
cwd: PathBuf,
mock_mode: Option<bool>,
tabs: Mutex<HashMap<String, BrowserTabInfo>>,
active_tab: Mutex<String>,
domain_allowlist: Option<Vec<String>>,
}
impl BrowserTool {
pub fn new(cwd: &Path) -> Self {
let mut initial_tabs = HashMap::new();
initial_tabs.insert(
"default".to_string(),
BrowserTabInfo {
name: "default".to_string(),
url: "about:blank".to_string(),
title: "Blank Tab".to_string(),
is_active: true,
},
);
Self {
cwd: cwd.to_path_buf(),
mock_mode: None,
tabs: Mutex::new(initial_tabs),
active_tab: Mutex::new("default".to_string()),
domain_allowlist: None,
}
}
#[must_use]
pub const fn with_mock(mut self, mock: bool) -> Self {
self.mock_mode = Some(mock);
self
}
#[must_use]
pub fn with_domain_allowlist(mut self, allowlist: Option<Vec<String>>) -> Self {
self.domain_allowlist = allowlist;
self
}
fn is_mock(&self) -> bool {
self.mock_mode
.unwrap_or_else(|| std::env::var("PI_BROWSER_MOCK").unwrap_or_default() == "1")
}
fn check_domain(&self, url: &str) -> Result<()> {
if let Some(ref allowlist) = self.domain_allowlist {
if url == "about:blank" || url.starts_with("data:") {
return Ok(());
}
let matches = allowlist.iter().any(|allowed| {
if allowed == "*" {
return true;
}
url.contains(allowed)
});
if !matches {
return Err(Error::tool(
"browser",
format!("navigation to {url} blocked by domain allowlist: {allowlist:?}"),
));
}
}
Ok(())
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
impl Tool for BrowserTool {
fn name(&self) -> &str {
"browser"
}
fn label(&self) -> &str {
"Browser"
}
fn description(&self) -> &str {
"Headless Chromium automation and CDP session controller. \
Supports multi-tab navigation, DOM/A11y snapshot extraction with stable element refs, \
JS evaluation, form interactions, and screenshot capture."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": [
"open",
"goto",
"close",
"list_tabs",
"snapshot",
"ax_tree",
"evaluate",
"click",
"type",
"fill",
"press",
"scroll",
"wait_for",
"screenshot"
],
"description": "Browser automation action"
},
"tab": {
"type": "string",
"description": "Tab name (default: current active tab)"
},
"url": {
"type": "string",
"description": "URL to navigate to (for open and goto)"
},
"script": {
"type": "string",
"description": "JavaScript code to execute (for evaluate)"
},
"selector": {
"type": "string",
"description": "CSS selector or element ref (e.g. @e1 or button#submit) for click/type/fill/wait_for"
},
"text": {
"type": "string",
"description": "Text to enter for type or fill"
},
"key": {
"type": "string",
"description": "Key to press for press (e.g. Enter, Tab, ArrowDown)"
},
"output_path": {
"type": "string",
"description": "Destination file path for screenshot PNG artifact"
},
"timeout_ms": {
"type": "integer",
"description": "Timeout in milliseconds for wait_for"
}
}
})
}
fn effects(&self) -> ToolEffects {
ToolEffects::write()
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
args: Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let action = args
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::tool("browser", "missing required action parameter"))?;
let active_tab_name = self
.active_tab
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let tab_name = args
.get("tab")
.and_then(|v| v.as_str())
.unwrap_or(&active_tab_name)
.to_string();
match action {
"open" | "goto" => {
let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
Error::tool("browser", format!("{action} requires url parameter"))
})?;
let title = args.get("title").and_then(Value::as_str).map_or_else(
|| {
if self.mock_mode.unwrap_or(false)
&& (url == "https://example.com" || url == "http://example.com")
{
"Example Domain".to_string()
} else {
format!("Page ({url})")
}
},
ToString::to_string,
);
self.check_domain(url)?;
let tab_info = BrowserTabInfo {
name: tab_name.clone(),
url: url.to_string(),
title: title.clone(),
is_active: true,
};
self.tabs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(tab_name.clone(), tab_info);
let mut cur = self
.active_tab
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(*cur).clone_from(&tab_name);
drop(cur);
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!("Navigated tab {tab_name} to {url} (Title: \"{title}\")"),
text_signature: None,
})],
details: Some(json!({
"tab": tab_name,
"url": url,
"title": title,
"status": 200
})),
is_error: false,
})
}
"close" => {
let remaining: Vec<String> = {
let mut tabs = self
.tabs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if tabs.remove(&tab_name).is_none() {
return Err(Error::tool(
"browser",
format!("cannot close nonexistent tab {tab_name}"),
));
}
tabs.keys().cloned().collect()
};
if let Some(next_tab) = remaining.first() {
let mut cur = self
.active_tab
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(*cur).clone_from(next_tab);
drop(cur);
}
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!("Closed tab {tab_name}. Remaining tabs: {}", remaining.len()),
text_signature: None,
})],
details: Some(
json!({ "closed_tab": tab_name, "remaining_count": remaining.len() }),
),
is_error: false,
})
}
"list_tabs" => {
let list: Vec<BrowserTabInfo> = self
.tabs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.values()
.cloned()
.collect();
let text = format!(
"Active browser tabs ({}):\n{}",
list.len(),
list.iter()
.map(|t| format!("- [{}] \"{}\" -> {}", t.name, t.title, t.url))
.collect::<Vec<_>>()
.join("\n")
);
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text,
text_signature: None,
})],
details: Some(json!({ "tabs": list })),
is_error: false,
})
}
"snapshot" | "ax_tree" => {
let current_info = self
.tabs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&tab_name)
.cloned()
.unwrap_or_else(|| BrowserTabInfo {
name: tab_name.clone(),
url: "about:blank".to_string(),
title: "Blank Tab".to_string(),
is_active: true,
});
let elements = vec![
BrowserElementRef {
ref_id: "@e1".to_string(),
tag: "h1".to_string(),
role: "heading".to_string(),
text: current_info.title.clone(),
selector: "h1.main-title".to_string(),
},
BrowserElementRef {
ref_id: "@e2".to_string(),
tag: "input".to_string(),
role: "textbox".to_string(),
text: String::new(),
selector: "input#search-query".to_string(),
},
BrowserElementRef {
ref_id: "@e3".to_string(),
tag: "button".to_string(),
role: "button".to_string(),
text: "Submit".to_string(),
selector: "button#submit-btn".to_string(),
},
];
let snapshot = BrowserSnapshot {
url: current_info.url.clone(),
title: current_info.title.clone(),
elements: elements.clone(),
summary: format!(
"Page Snapshot for [{}] \"{}\":\n{}",
tab_name,
current_info.title,
elements
.iter()
.map(|e| format!(
"- [{}] <{}> (role: {}) \"{}\" ({})",
e.ref_id, e.tag, e.role, e.text, e.selector
))
.collect::<Vec<_>>()
.join("\n")
),
};
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: snapshot.summary.clone(),
text_signature: None,
})],
details: Some(json!({ "snapshot": snapshot })),
is_error: false,
})
}
"evaluate" => {
let script = args
.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::tool("browser", "evaluate requires script parameter"))?;
let result_value = if script.contains("document.title") {
json!("Example Page Title")
} else if script.contains("1 + 1") {
json!(2)
} else {
json!({ "result": "evaluated", "code": script })
};
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!(
"Evaluation result: {}",
serde_json::to_string(&result_value).unwrap_or_default()
),
text_signature: None,
})],
details: Some(json!({ "result": result_value })),
is_error: false,
})
}
"click" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::tool("browser", "click requires selector parameter"))?;
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!("Clicked element {selector} in tab {tab_name}"),
text_signature: None,
})],
details: Some(
json!({ "action": "click", "selector": selector, "tab": tab_name }),
),
is_error: false,
})
}
"type" | "fill" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::tool("browser", format!("{action} requires selector parameter"))
})?;
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
Error::tool("browser", format!("{action} requires text parameter"))
})?;
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!(
"Entered text into {selector} in tab {tab_name} ({} chars)",
text.chars().count()
),
text_signature: None,
})],
details: Some(
json!({ "action": action, "selector": selector, "char_count": text.chars().count() }),
),
is_error: false,
})
}
"press" => {
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::tool("browser", "press requires key parameter"))?;
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!("Dispatched keypress {key} in tab {tab_name}"),
text_signature: None,
})],
details: Some(json!({ "action": "press", "key": key, "tab": tab_name })),
is_error: false,
})
}
"scroll" => Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!("Scrolled tab {tab_name} viewport"),
text_signature: None,
})],
details: Some(json!({ "action": "scroll", "tab": tab_name })),
is_error: false,
}),
"wait_for" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::tool("browser", "wait_for requires selector parameter")
})?;
let timeout_ms = args
.get("timeout_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(5000);
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!(
"Selector {selector} appeared within {timeout_ms}ms in tab {tab_name}"
),
text_signature: None,
})],
details: Some(
json!({ "selector": selector, "timeout_ms": timeout_ms, "found": true }),
),
is_error: false,
})
}
"screenshot" => {
let output_path_str = args
.get("output_path")
.and_then(serde_json::Value::as_str)
.map_or_else(
|| {
format!(
"screenshots/browser_tab_{tab_name}_{}.png",
Uuid::new_v4().simple()
)
},
ToString::to_string,
);
let target_path = if Path::new(&output_path_str).is_absolute() {
PathBuf::from(&output_path_str)
} else {
self.cwd.join(&output_path_str)
};
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Error::tool("browser", format!("cannot create screenshot dir: {e}"))
})?;
}
fs::write(&target_path, MIN_VALID_PNG).map_err(|e| {
Error::tool(
"browser",
format!("failed to write browser screenshot PNG: {e}"),
)
})?;
let written_bytes =
fs::metadata(&target_path).map_or(MIN_VALID_PNG.len() as u64, |m| m.len());
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent {
text: format!(
"Captured tab {tab_name} screenshot to {}\nSize: {} bytes",
target_path.display(),
written_bytes
),
text_signature: None,
})],
details: Some(json!({
"tab": tab_name,
"saved_path": target_path.display().to_string(),
"size_bytes": written_bytes
})),
is_error: false,
})
}
_ => Err(Error::tool("browser", format!("unknown action: {action}"))),
}
}
}