// The tools/list JSON literal expanded by serde_json::json! is large // enough now (32 tool definitions) that the default macro recursion // limit (128) overflows. Bumping for this crate only. #![recursion_limit = "512"] pub mod http; use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use obscura_browser::{BrowserContext, Page}; use obscura_dom::NodeId; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; /// Cap on text returned to the agent unless the caller passes a larger /// `max_chars`. Agents waste context on multi-KB raw page dumps; this /// keeps a single tool call from burning a window's worth of tokens. /// Override via tool args. const DEFAULT_TEXT_LIMIT: usize = 4000; #[derive(Deserialize)] struct RpcMessage { #[allow(dead_code)] jsonrpc: String, #[serde(default)] id: Option, method: String, #[serde(default)] params: Value, } #[derive(Serialize)] struct RpcResponse { jsonrpc: &'static str, id: Value, #[serde(skip_serializing_if = "Option::is_none")] result: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Serialize)] struct RpcError { code: i32, message: String, } impl RpcResponse { fn ok(id: Value, result: Value) -> Self { RpcResponse { jsonrpc: "2.0", id, result: Some(result), error: None } } fn err(id: Value, code: i32, message: impl Into) -> Self { RpcResponse { jsonrpc: "2.0", id, result: None, error: Some(RpcError { code, message: message.into() }) } } } pub struct BrowserState { /// Open tabs keyed by tab_id (e.g. "tab-1"). BTreeMap so list ordering /// is stable across calls (agents reason about "tab #2" deterministically). tabs: std::collections::BTreeMap, /// The tab id every tool call operates on. None means there are no /// open tabs; the next page_mut() call creates one. active_tab: Option, tab_counter: u32, context: Arc, user_agent: Option, console_messages: Vec, /// Element-ref table from the last `browser_snapshot` on the ACTIVE /// tab. Agents click / fill / type by `ref` (e.g. `"e3"`) instead of /// guessing a CSS selector. Refs are stable within a snapshot; the /// table is wiped on every navigation / tab switch and refilled on /// the next snapshot call. interactive_refs: HashMap, } impl BrowserState { pub fn new(proxy: Option, user_agent: Option, stealth: bool) -> Self { BrowserState { tabs: std::collections::BTreeMap::new(), active_tab: None, tab_counter: 0, context: Arc::new(BrowserContext::with_options("mcp".to_string(), proxy, stealth)), user_agent, console_messages: Vec::new(), interactive_refs: HashMap::new(), } } /// Make sure there is at least one tab and return a &mut to the /// active tab's Page. Auto-creates a default tab if none exist so /// every legacy single-page tool continues to work without /// requiring an explicit browser_tab_new. fn page_mut(&mut self) -> &mut Page { if self.active_tab.is_none() { self.tab_counter += 1; let id = format!("tab-{}", self.tab_counter); self.tabs.insert(id.clone(), Page::new("mcp-page".to_string(), self.context.clone())); self.active_tab = Some(id); } let id = self.active_tab.as_ref().unwrap().clone(); self.tabs.get_mut(&id).expect("active tab must exist") } fn new_tab(&mut self) -> String { self.tab_counter += 1; let id = format!("tab-{}", self.tab_counter); self.tabs.insert(id.clone(), Page::new(format!("mcp-{id}"), self.context.clone())); self.active_tab = Some(id.clone()); self.interactive_refs.clear(); id } /// Resolve `ref=eN` to a CSS selector that uniquely targets the /// element. Snapshot writes `data-obscura-ref="eN"` onto every /// interactable, so the attribute survives across calls as long as /// the page isn't re-rendered without it. Returns `Err` if the ref /// hasn't been registered (caller must call browser_snapshot first). fn ref_to_selector(&self, r: &str) -> Result { if !self.interactive_refs.contains_key(r) { return Err(format!( "unknown ref '{r}'; call browser_snapshot first to refresh the ref table" )); } Ok(format!("[data-obscura-ref=\"{r}\"]")) } } pub(crate) async fn dispatch(method: &str, id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse { match method { "initialize" => handle_initialize(id, params), "ping" => RpcResponse::ok(id, json!({})), "tools/list" => handle_tools_list(id), "tools/call" => handle_tool_call(id, params, state).await, "resources/list" => RpcResponse::ok(id, json!({"resources": []})), "prompts/list" => RpcResponse::ok(id, json!({"prompts": []})), _ => RpcResponse::err(id, -32601, format!("Unknown method: {method}")), } } pub async fn run(proxy: Option, user_agent: Option, stealth: bool) -> Result<()> { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); let mut reader = BufReader::new(stdin); let mut writer = stdout; let mut state = BrowserState::new(proxy, user_agent, stealth); loop { // MCP stdio transport: newline-delimited JSON (one message per line) let mut line = String::new(); let n = reader.read_line(&mut line).await?; if n == 0 { return Ok(()); } let trimmed = line.trim(); if trimmed.is_empty() { continue; } let msg: RpcMessage = match serde_json::from_str(trimmed) { Ok(m) => m, Err(_) => continue, }; // Notifications (no id) need no response if msg.id.is_none() { continue; } let id = msg.id.clone().unwrap_or(Value::Null); let response = dispatch(&msg.method, id, &msg.params, &mut state).await; let mut body = serde_json::to_string(&response)?; body.push('\n'); writer.write_all(body.as_bytes()).await?; writer.flush().await?; } } fn handle_initialize(id: Value, params: &Value) -> RpcResponse { let _client_version = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(""); RpcResponse::ok(id, json!({ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "serverInfo": { "name": "obscura-mcp", "version": env!("CARGO_PKG_VERSION") } })) } fn handle_tools_list(id: Value) -> RpcResponse { RpcResponse::ok(id, json!({ "tools": [ { "name": "browser_navigate", "description": "Navigate to a URL and wait for the page to load", "inputSchema": { "type": "object", "properties": { "url": { "type": "string", "description": "URL to navigate to" }, "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle0"], "description": "Navigation wait condition (default: load)" } }, "required": ["url"] } }, { "name": "browser_snapshot", "description": "Get the current page content as text (title, URL, and readable body text)", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_click", "description": "Click an element. Pass `ref` (preferred, from browser_snapshot / browser_interactive_elements) OR a `selector`.", "inputSchema": { "type": "object", "properties": { "ref": { "type": "string", "description": "Element ref like 'e3' from a recent snapshot" }, "selector": { "type": "string", "description": "CSS selector (fallback if ref unavailable)" } } } }, { "name": "browser_fill", "description": "Set the value of an input element. Pass `ref` (preferred) OR `selector`.", "inputSchema": { "type": "object", "properties": { "ref": { "type": "string" }, "selector": { "type": "string" }, "value": { "type": "string", "description": "Value to set" } }, "required": ["value"] } }, { "name": "browser_type", "description": "Type text into an input element (appends to existing value). Pass `ref` (preferred) OR `selector`.", "inputSchema": { "type": "object", "properties": { "ref": { "type": "string" }, "selector": { "type": "string" }, "text": { "type": "string", "description": "Text to type" } }, "required": ["text"] } }, { "name": "browser_press_key", "description": "Dispatch a keyboard event on an element or the document", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "description": "Key name (e.g. Enter, Tab, Escape)" }, "selector": { "type": "string", "description": "CSS selector (optional, defaults to document)" } }, "required": ["key"] } }, { "name": "browser_select_option", "description": "Select an option from a element" }, "value": { "type": "string", "description": "Value or text of the option to select" } }, "required": ["selector", "value"] } }, { "name": "browser_evaluate", "description": "Evaluate a JavaScript expression in the page context and return the result", "inputSchema": { "type": "object", "properties": { "expression": { "type": "string", "description": "JavaScript expression to evaluate" } }, "required": ["expression"] } }, { "name": "browser_wait_for", "description": "Wait for a CSS selector to appear in the DOM", "inputSchema": { "type": "object", "properties": { "selector": { "type": "string", "description": "CSS selector to wait for" }, "timeout": { "type": "number", "description": "Timeout in seconds (default: 30)" } }, "required": ["selector"] } }, { "name": "browser_network_requests", "description": "Return the list of network requests made by the current page", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_console_messages", "description": "Return the console messages logged by the current page", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_close", "description": "Close the current browser page and reset state", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_markdown", "description": "Extract the current page as Markdown (headings, paragraphs, lists, links, code blocks). Use this instead of browser_snapshot when you want token-dense structured content rather than plain text.", "inputSchema": { "type": "object", "properties": { "max_chars": { "type": "number", "description": "Truncate to this many characters (default 4000)" } } } }, { "name": "browser_links", "description": "List every anchor link on the current page as one JSON object per line: {text, href}. Use when you need to enumerate where to navigate next.", "inputSchema": { "type": "object", "properties": { "limit": { "type": "number", "description": "Max number of links to return (default 100)" }, "internal_only": { "type": "boolean", "description": "If true, only return links on the same origin as the current page" } } } }, { "name": "browser_interactive_elements", "description": "List every clickable / typeable element on the current page with a stable ref ID and a brief description. Use this BEFORE clicking or filling so you can refer to elements by ref instead of guessing a CSS selector. Refs look like 'e3' and stay valid until the next navigation.", "inputSchema": { "type": "object", "properties": { "limit": { "type": "number", "description": "Max number of elements (default 100)" } } } }, { "name": "browser_back", "description": "Navigate back in the page history (equivalent to the browser back button).", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_forward", "description": "Navigate forward in the page history.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_reload", "description": "Reload the current page.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_get_cookies", "description": "Return all cookies in the browser's cookie jar as one JSON object per line.", "inputSchema": { "type": "object", "properties": { "domain": { "type": "string", "description": "Filter to cookies on this domain (default: all)" } } } }, { "name": "browser_set_cookie", "description": "Add or replace a cookie in the jar. Use this to skip a login flow when you already have a session token.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "value": { "type": "string" }, "domain": { "type": "string", "description": "e.g. example.com or .example.com" }, "path": { "type": "string", "description": "default '/'" }, "secure": { "type": "boolean" }, "http_only": { "type": "boolean" } }, "required": ["name", "value", "domain"] } }, { "name": "browser_clear_cookies", "description": "Wipe every cookie from the jar.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_wait_for_text", "description": "Wait until a substring appears anywhere in the rendered page text. Use when you want to wait for a result message or notification rather than a specific selector.", "inputSchema": { "type": "object", "properties": { "text": { "type": "string" }, "timeout": { "type": "number", "description": "Seconds (default 30)" } }, "required": ["text"] } }, { "name": "browser_detect_forms", "description": "List every
on the page with its action URL, method, and a description of each input/textarea/select. Use to understand a form's structure before filling it in.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_fill_form", "description": "Fill multiple inputs in one call. `fields` is an array of {ref?, selector?, value, type?}. type='text' (default) sets value, type='check'/'uncheck' toggles checkboxes, type='select' picks an option by value or visible text. Saves N round-trips vs N browser_fill calls.", "inputSchema": { "type": "object", "properties": { "fields": { "type": "array", "items": { "type": "object", "properties": { "ref": { "type": "string" }, "selector": { "type": "string" }, "value": { "type": "string" }, "type": { "type": "string", "enum": ["text", "check", "uncheck", "select"] } } } }, "submit_ref": { "type": "string", "description": "Optional: click this element after filling (e.g. submit button ref)" }, "submit_selector": { "type": "string" } }, "required": ["fields"] } }, { "name": "browser_scroll", "description": "Scroll the page or an element. `direction` is 'top'|'bottom'|'up'|'down'|'left'|'right' (default 'down'). `amount` in pixels (default viewport height). Use 'bottom' to trigger infinite-scroll loaders.", "inputSchema": { "type": "object", "properties": { "direction": { "type": "string", "enum": ["top", "bottom", "up", "down", "left", "right"] }, "amount": { "type": "number", "description": "Pixels (default: one viewport)" }, "ref": { "type": "string", "description": "Optional element to scroll into view" }, "selector": { "type": "string" } } } }, { "name": "browser_get_attribute", "description": "Read an attribute of an element (href, src, value, class, data-*, etc.). Returns the raw attribute value as a string, or empty string if missing.", "inputSchema": { "type": "object", "properties": { "ref": { "type": "string" }, "selector": { "type": "string" }, "attribute": { "type": "string", "description": "Attribute name (e.g. href, value, src)" } }, "required": ["attribute"] } }, { "name": "browser_count", "description": "Count how many elements on the page match a CSS selector. Cheap existence / pagination probe.", "inputSchema": { "type": "object", "properties": { "selector": { "type": "string" } }, "required": ["selector"] } }, { "name": "browser_extract", "description": "Extract a structured object from the page given a map of {field_name: css_selector}. Returns one JSON object with each field set to the matching element's text content (or attribute via 'selector@attr' syntax, e.g. 'a@href'). For list extraction, append '[]' to the field name (e.g. 'rows[]') and the value will be an array.", "inputSchema": { "type": "object", "properties": { "schema": { "type": "object", "description": "Map of field_name to CSS selector. Suffix selector with '@attr' for attribute, suffix field name with '[]' for array." } }, "required": ["schema"] } }, { "name": "browser_tab_new", "description": "Open a new tab (isolated browser page). Returns the tab ID; subsequent tool calls operate on the most recently opened or browser_tab_switch'd tab.", "inputSchema": { "type": "object", "properties": { "url": { "type": "string", "description": "Optional URL to navigate the new tab to" } } } }, { "name": "browser_tab_list", "description": "List all open tabs with their ID, URL, title, and which one is active.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_tab_switch", "description": "Switch the active tab. All subsequent tool calls (snapshot, click, etc.) target this tab.", "inputSchema": { "type": "object", "properties": { "tab_id": { "type": "string" } }, "required": ["tab_id"] } }, { "name": "browser_tab_close", "description": "Close a tab by ID (default: the active tab). If you close the active tab, the next remaining tab becomes active.", "inputSchema": { "type": "object", "properties": { "tab_id": { "type": "string" } } } }, { "name": "browser_search", "description": "Find substring matches in the visible page text. Returns each match with its surrounding context. Use this to confirm content exists before scraping or to locate a section.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "case_sensitive": { "type": "boolean" }, "limit": { "type": "number", "description": "Max matches to return (default 10)" }, "context_chars": { "type": "number", "description": "Chars on each side of the match (default 80)" } }, "required": ["query"] } }, { "name": "browser_storage_state", "description": "Export the full authentication / session state (cookies + localStorage + sessionStorage) as a JSON object. Save this to skip a login on a subsequent run via browser_set_storage_state.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "browser_set_storage_state", "description": "Restore session state previously returned by browser_storage_state. Pass the JSON object. Use to bring an authenticated session back without re-logging in.", "inputSchema": { "type": "object", "properties": { "state": { "type": "object", "description": "{cookies: [...], origins: [{origin, localStorage: [...], sessionStorage: [...]}]}" } }, "required": ["state"] } } ] })) } async fn handle_tool_call(id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse { let name = match params.get("name").and_then(Value::as_str) { Some(n) => n, None => return RpcResponse::err(id, -32602, "Missing tool name"), }; let args = params.get("arguments").unwrap_or(&Value::Null); let result = match name { "browser_navigate" => tool_navigate(args, state).await, "browser_snapshot" => tool_snapshot(args, state), "browser_click" => tool_click(args, state), "browser_fill" => tool_fill(args, state), "browser_type" => tool_type(args, state), "browser_press_key" => tool_press_key(args, state), "browser_select_option" => tool_select_option(args, state), "browser_evaluate" => tool_evaluate(args, state), "browser_wait_for" => tool_wait_for(args, state).await, "browser_network_requests" => tool_network_requests(state), "browser_console_messages" => tool_console_messages(state), "browser_close" => tool_close(state), // Tier 1 agent-UX additions "browser_markdown" => tool_markdown(args, state), "browser_links" => tool_links(args, state), "browser_interactive_elements" => tool_interactive_elements(args, state), "browser_back" => tool_back(state).await, "browser_forward" => tool_forward(state).await, "browser_reload" => tool_reload(state).await, "browser_get_cookies" => tool_get_cookies(args, state), "browser_set_cookie" => tool_set_cookie(args, state), "browser_clear_cookies" => tool_clear_cookies(state), "browser_wait_for_text" => tool_wait_for_text(args, state).await, // Tier 2 agent-UX additions "browser_detect_forms" => tool_detect_forms(state), "browser_fill_form" => tool_fill_form(args, state), "browser_scroll" => tool_scroll(args, state), "browser_get_attribute" => tool_get_attribute(args, state), "browser_count" => tool_count(args, state), "browser_extract" => tool_extract(args, state), "browser_tab_new" => tool_tab_new(args, state).await, "browser_tab_list" => tool_tab_list(state), "browser_tab_switch" => tool_tab_switch(args, state), "browser_tab_close" => tool_tab_close(args, state), "browser_search" => tool_search(args, state), "browser_storage_state" => tool_storage_state(state), "browser_set_storage_state" => tool_set_storage_state(args, state), _ => Err(format!("Unknown tool: {name}")), }; match result { Ok(content) => RpcResponse::ok(id, json!({ "content": [{ "type": "text", "text": content }] })), Err(e) => RpcResponse::ok(id, json!({ "content": [{ "type": "text", "text": format!("Error: {e}") }], "isError": true })), } } /// Resolve a tool call's element target from either `ref` (preferred) or /// `selector` (fallback). Agents that called `browser_snapshot` / /// `browser_interactive_elements` get a ref table they can refer to; /// scripted clients can still pass raw CSS selectors. fn resolve_target(args: &Value, state: &BrowserState) -> Result { if let Some(r) = args.get("ref").and_then(Value::as_str) { return state.ref_to_selector(r); } if let Some(sel) = args.get("selector").and_then(Value::as_str) { return Ok(sel.to_string()); } Err("Missing 'ref' or 'selector' parameter".to_string()) } /// Clamp text to `max_chars` and tack on a `...(truncated, N more chars)` /// marker so the agent can ask for more if needed. Default ceiling is /// 4 KiB to prevent a single tool call from consuming a window of context. fn truncate(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_string(); } let head: String = text.chars().take(max_chars).collect(); let remaining = text.chars().count() - max_chars; format!("{head}\n...(truncated, {remaining} more chars)") } async fn tool_navigate(args: &Value, state: &mut BrowserState) -> Result { let url = args.get("url").and_then(Value::as_str) .ok_or("Missing url parameter")?; let wait_until = args.get("waitUntil").and_then(Value::as_str).unwrap_or("load"); let condition = obscura_browser::lifecycle::WaitUntil::from_str(wait_until); let ua = state.user_agent.clone(); let page = state.page_mut(); if let Some(ref ua) = ua { page.http_client.set_user_agent(ua).await; } page.navigate_with_wait(url, condition).await .map_err(|e| e.to_string())?; let summary = format!("Navigated to {} — \"{}\"", page.url_string(), page.title); // DOM changed — invalidate the ref table. Next snapshot will rebuild. state.interactive_refs.clear(); Ok(summary) } fn tool_snapshot(args: &Value, state: &mut BrowserState) -> Result { let max_chars = args.get("max_chars").and_then(Value::as_u64).map(|n| n as usize) .unwrap_or(DEFAULT_TEXT_LIMIT); rebuild_interactive_refs(state)?; let page = state.page_mut(); let url = page.url_string(); let title = page.title.clone(); let body_text = page.with_dom(|dom| { if let Ok(Some(body)) = dom.query_selector("body") { extract_text(dom, body) } else { String::new() } }).unwrap_or_default(); let refs_summary = if state.interactive_refs.is_empty() { String::new() } else { format!( "\n\n{} interactive element(s) registered. Call browser_interactive_elements to list, or pass `ref` to browser_click/browser_fill/browser_type.", state.interactive_refs.len(), ) }; let body = truncate(body_text.trim(), max_chars); Ok(format!("URL: {url}\nTitle: {title}\n\n{body}{refs_summary}")) } fn tool_click(args: &Value, state: &mut BrowserState) -> Result { let selector = resolve_target(args, state)?; let js = format!( r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:element not found"; el.click(); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap() ); let result = state.page_mut().evaluate(&js); if result.as_str() == Some("error:element not found") { Err(format!("Element not found: {selector}")) } else { // A click can navigate or rewrite the DOM; the old ref table may // no longer match. Conservative: invalidate. Next snapshot rebuilds. state.interactive_refs.clear(); Ok(format!("Clicked '{selector}'")) } } fn tool_fill(args: &Value, state: &mut BrowserState) -> Result { let selector = resolve_target(args, state)?; let value = args.get("value").and_then(Value::as_str) .ok_or("Missing value parameter")?; let js = format!( r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:element not found"; el.value = {val}; el.dispatchEvent(new Event("input", {{bubbles:true}})); el.dispatchEvent(new Event("change", {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), val = serde_json::to_string(value).unwrap() ); let result = state.page_mut().evaluate(&js); if result.as_str() == Some("error:element not found") { Err(format!("Element not found: {selector}")) } else { Ok(format!("Filled '{selector}' with value")) } } fn tool_type(args: &Value, state: &mut BrowserState) -> Result { let selector = resolve_target(args, state)?; let text = args.get("text").and_then(Value::as_str) .ok_or("Missing text parameter")?; let js = format!( r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:element not found"; el.value = (el.value || "") + {txt}; el.dispatchEvent(new Event("input", {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), txt = serde_json::to_string(text).unwrap() ); let result = state.page_mut().evaluate(&js); if result.as_str() == Some("error:element not found") { Err(format!("Element not found: {selector}")) } else { Ok(format!("Typed into '{selector}'")) } } fn tool_press_key(args: &Value, state: &mut BrowserState) -> Result { let key = args.get("key").and_then(Value::as_str) .ok_or("Missing key parameter")?; let selector = args.get("selector").and_then(Value::as_str); let target = match selector { Some(sel) => format!("document.querySelector({})", serde_json::to_string(sel).unwrap()), None => "document".to_string(), }; let js = format!( r#"(function(){{ var t = {target}; if (!t) return "error:element not found"; t.dispatchEvent(new KeyboardEvent("keydown", {{key:{key},bubbles:true}})); t.dispatchEvent(new KeyboardEvent("keyup", {{key:{key},bubbles:true}})); return "ok"; }})()"#, target = target, key = serde_json::to_string(key).unwrap() ); state.page_mut().evaluate(&js); Ok(format!("Pressed key '{key}'")) } fn tool_select_option(args: &Value, state: &mut BrowserState) -> Result { let selector = args.get("selector").and_then(Value::as_str) .ok_or("Missing selector parameter")?; let value = args.get("value").and_then(Value::as_str) .ok_or("Missing value parameter")?; let js = format!( r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:element not found"; var opts = Array.from(el.options); var opt = opts.find(function(o){{ return o.value === {val} || o.text === {val}; }}); if (!opt) return "error:option not found"; el.value = opt.value; el.dispatchEvent(new Event("change", {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(selector).unwrap(), val = serde_json::to_string(value).unwrap() ); let result = state.page_mut().evaluate(&js); match result.as_str() { Some("error:element not found") => Err(format!("Element not found: {selector}")), Some("error:option not found") => Err(format!("Option not found: {value}")), _ => Ok(format!("Selected '{value}' in '{selector}'")), } } fn tool_evaluate(args: &Value, state: &mut BrowserState) -> Result { let expression = args.get("expression").and_then(Value::as_str) .ok_or("Missing expression parameter")?; let result = state.page_mut().evaluate(expression); Ok(match &result { Value::String(s) => s.clone(), Value::Null => "null".to_string(), other => serde_json::to_string_pretty(other).unwrap_or_default(), }) } async fn tool_wait_for(args: &Value, state: &mut BrowserState) -> Result { let selector = args.get("selector").and_then(Value::as_str) .ok_or("Missing selector parameter")?; let timeout_secs = args.get("timeout").and_then(Value::as_f64).unwrap_or(30.0) as u64; let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); // Exponential backoff: 5 -> 10 -> 20 -> ... -> 200 ms. The old fixed // 200ms tick added up to a full poll cycle of latency every time; // a selector that appears in 30ms now returns in ~35ms instead of // the next 200ms tick. let mut tick_ms: u64 = 5; loop { let found = state.page_mut().with_dom(|dom| { dom.query_selector(selector).ok().flatten().is_some() }).unwrap_or(false); if found { return Ok(format!("Found '{selector}'")); } if tokio::time::Instant::now() >= deadline { return Err(format!("Timeout waiting for '{selector}'")); } tokio::time::sleep(tokio::time::Duration::from_millis(tick_ms)).await; if tick_ms < 200 { tick_ms = (tick_ms * 2).min(200); } } } fn tool_network_requests(state: &mut BrowserState) -> Result { let page = state.page_mut(); let events = &page.network_events; if events.is_empty() { return Ok("No network requests recorded.".to_string()); } let lines: Vec = events.iter().map(|e| { format!("[{}] {} {} ({}B)", e.status, e.method, e.url, e.body_size) }).collect(); Ok(lines.join("\n")) } fn tool_console_messages(state: &BrowserState) -> Result { if state.console_messages.is_empty() { Ok("No console messages.".to_string()) } else { Ok(state.console_messages.join("\n")) } } fn tool_close(state: &mut BrowserState) -> Result { state.tabs.clear(); state.active_tab = None; state.console_messages.clear(); state.interactive_refs.clear(); Ok("All browser tabs closed.".to_string()) } // ===== Tier 1 agent-UX additions ===== /// Convert the rendered page to Markdown by running the JS-side converter /// already used by `obscura fetch --dump markdown`. More token-dense than /// browser_snapshot for content-heavy pages (article bodies, docs sites). fn tool_markdown(args: &Value, state: &mut BrowserState) -> Result { let max_chars = args.get("max_chars").and_then(Value::as_u64).map(|n| n as usize) .unwrap_or(DEFAULT_TEXT_LIMIT); let page = state.page_mut(); let result = page.evaluate(obscura_browser::HTML_TO_MARKDOWN_JS); let md = result.as_str().unwrap_or_default(); Ok(truncate(md, max_chars)) } /// Enumerate every `` on the page. One JSON object per line so /// the agent can grep / split without round-tripping to a JSON parser. fn tool_links(args: &Value, state: &mut BrowserState) -> Result { let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize; let internal_only = args.get("internal_only").and_then(Value::as_bool).unwrap_or(false); let page = state.page_mut(); let base_origin = url::Url::parse(&page.url_string()) .ok() .map(|u| u.origin()) .unwrap_or_else(|| url::Url::parse("about:blank").unwrap().origin()); let js = r#"(function(){ var out = []; var seen = new Set(); var as = document.querySelectorAll('a[href]'); for (var i = 0; i < as.length; i++) { var a = as[i]; var href = a.href || ''; if (!href || href === '#' || href.startsWith('javascript:')) continue; if (seen.has(href)) continue; seen.add(href); var t = (a.innerText || a.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 200); out.push({text: t, href: href}); } return out; })()"#; let val = page.evaluate(js); let arr = val.as_array().cloned().unwrap_or_default(); let lines: Vec = arr.into_iter() .filter(|item| { if !internal_only { return true; } item.get("href").and_then(|v| v.as_str()) .and_then(|h| url::Url::parse(h).ok()) .map(|u| u.origin() == base_origin) .unwrap_or(false) }) .take(limit) .map(|item| item.to_string()) .collect(); if lines.is_empty() { Ok("No links found.".to_string()) } else { Ok(lines.join("\n")) } } /// List every interactable element with a stable ref ID, the kind of /// element, and a one-line description. Agents pass `ref` to click/fill/ /// type instead of crafting selectors. Also assigns `data-obscura-ref` /// to each element so the ref survives until the next navigation. fn tool_interactive_elements(args: &Value, state: &mut BrowserState) -> Result { let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize; rebuild_interactive_refs(state)?; if state.interactive_refs.is_empty() { return Ok("No interactive elements on this page.".to_string()); } let page = state.page_mut(); let js = format!(r#"(function(){{ var els = document.querySelectorAll('[data-obscura-ref]'); var out = []; for (var i = 0; i < els.length && out.length < {limit}; i++) {{ var e = els[i]; var label = (e.innerText || e.textContent || e.getAttribute('aria-label') || e.getAttribute('placeholder') || e.getAttribute('value') || e.getAttribute('name') || '').trim().replace(/\s+/g, ' ').slice(0, 80); var role = e.getAttribute('role') || ''; var typeAttr = e.getAttribute('type') || ''; out.push({{ ref: e.getAttribute('data-obscura-ref'), tag: e.tagName.toLowerCase(), type: typeAttr, role: role, name: e.getAttribute('name') || '', label: label, }}); }} return out; }})()"#); let val = page.evaluate(&js); let arr = val.as_array().cloned().unwrap_or_default(); let lines: Vec = arr.into_iter().map(|item| { let r = item.get("ref").and_then(|v| v.as_str()).unwrap_or("?"); let tag = item.get("tag").and_then(|v| v.as_str()).unwrap_or("?"); let ty = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(""); let name = item.get("name").and_then(|v| v.as_str()).unwrap_or(""); let role = item.get("role").and_then(|v| v.as_str()).unwrap_or(""); let kind = if !ty.is_empty() { format!("{tag}[{ty}]") } else if !role.is_empty() { format!("{tag}[role={role}]") } else { tag.to_string() }; let detail = if !name.is_empty() { format!(" name={name:?}") } else { String::new() }; format!("ref={r:<5} {kind:<22} {label:?}{detail}") }).collect(); Ok(lines.join("\n")) } /// Rebuild the ref table: walk the DOM, find every interactable, assign /// a stable `data-obscura-ref="eN"` attribute, remember the nid for later /// validation. Called on every snapshot / interactive-elements call so the /// agent always sees fresh refs. fn rebuild_interactive_refs(state: &mut BrowserState) -> Result<(), String> { state.interactive_refs.clear(); let page = state.page_mut(); // Tag every interactable with data-obscura-ref="eN" in DOM order. let tag_js = r#"(function(){ var sel = 'a[href], button, input:not([type=hidden]), select, textarea, [role=button], [role=link], [role=checkbox], [role=tab], [role=menuitem], [role=option], [onclick], [tabindex]:not([tabindex="-1"])'; var els = document.querySelectorAll(sel); var refs = []; for (var i = 0; i < els.length; i++) { var ref = 'e' + (i + 1); els[i].setAttribute('data-obscura-ref', ref); refs.push(ref); } return refs; })()"#; let val = page.evaluate(tag_js); let refs: Vec = val.as_array() .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); // Map ref -> nid via a second pass so ref_to_selector can sanity-check. for r in refs { let selector = format!("[data-obscura-ref=\"{r}\"]"); let page = state.page_mut(); let nid = page.with_dom(|dom| dom.query_selector(&selector).ok().flatten()); if let Some(Some(node_id)) = nid { state.interactive_refs.insert(r, node_id); } } Ok(()) } async fn tool_back(state: &mut BrowserState) -> Result { let history_url = state.page_mut().with_dom(|_| ()).map(|_| ()); let _ = history_url; // We track simple page history on the Page itself; navigate to the // entry before the cursor. let page = state.page_mut(); if page.history.len() < 2 || page.history_index == 0 { return Err("No previous page in history.".to_string()); } let prev_idx = page.history_index - 1; let url = page.history[prev_idx].clone(); page.set_history_index(prev_idx); let condition = obscura_browser::lifecycle::WaitUntil::DomContentLoaded; let stash = (page.history.clone(), page.history_index); page.navigate_with_wait(&url, condition).await.map_err(|e| e.to_string())?; let page = state.page_mut(); page.history = stash.0; page.history_index = stash.1; state.interactive_refs.clear(); Ok(format!("Back to {url}")) } async fn tool_forward(state: &mut BrowserState) -> Result { let page = state.page_mut(); if page.history_index + 1 >= page.history.len() { return Err("No forward page in history.".to_string()); } let next_idx = page.history_index + 1; let url = page.history[next_idx].clone(); page.set_history_index(next_idx); let condition = obscura_browser::lifecycle::WaitUntil::DomContentLoaded; let stash = (page.history.clone(), page.history_index); page.navigate_with_wait(&url, condition).await.map_err(|e| e.to_string())?; let page = state.page_mut(); page.history = stash.0; page.history_index = stash.1; state.interactive_refs.clear(); Ok(format!("Forward to {url}")) } async fn tool_reload(state: &mut BrowserState) -> Result { let url = state.page_mut().url_string(); if url == "about:blank" { return Err("Nothing to reload.".to_string()); } let condition = obscura_browser::lifecycle::WaitUntil::DomContentLoaded; state.page_mut().navigate_with_wait(&url, condition).await.map_err(|e| e.to_string())?; state.interactive_refs.clear(); Ok(format!("Reloaded {url}")) } fn tool_get_cookies(args: &Value, state: &BrowserState) -> Result { let domain_filter = args.get("domain").and_then(Value::as_str); let cookies = state.context.cookie_jar.get_all_cookies(); let lines: Vec = cookies.iter() .filter(|c| domain_filter.is_none_or(|d| c.domain == d || c.domain.trim_start_matches('.') == d)) .map(|c| serde_json::to_string(&json!({ "name": c.name, "value": c.value, "domain": c.domain, "path": c.path, "secure": c.secure, "http_only": c.http_only, })).unwrap_or_default()) .collect(); if lines.is_empty() { Ok("No cookies.".to_string()) } else { Ok(lines.join("\n")) } } fn tool_set_cookie(args: &Value, state: &BrowserState) -> Result { let name = args.get("name").and_then(Value::as_str) .ok_or("Missing name parameter")?; let value = args.get("value").and_then(Value::as_str) .ok_or("Missing value parameter")?; let domain = args.get("domain").and_then(Value::as_str) .ok_or("Missing domain parameter")?; let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); let secure = args.get("secure").and_then(Value::as_bool).unwrap_or(false); let http_only = args.get("http_only").and_then(Value::as_bool).unwrap_or(false); let cookie = obscura_net::CookieInfo { name: name.to_string(), value: value.to_string(), domain: domain.to_string(), path: path.to_string(), secure, http_only, same_site: String::new(), expires: None, }; state.context.cookie_jar.set_cookies_from_cdp(vec![cookie]); Ok(format!("Set cookie {name} on {domain}{path}")) } fn tool_clear_cookies(state: &BrowserState) -> Result { state.context.cookie_jar.clear(); Ok("Cleared all cookies.".to_string()) } async fn tool_wait_for_text(args: &Value, state: &mut BrowserState) -> Result { let needle = args.get("text").and_then(Value::as_str) .ok_or("Missing text parameter")?; let timeout_secs = args.get("timeout").and_then(Value::as_f64).unwrap_or(30.0) as u64; let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); let escaped = serde_json::to_string(needle).unwrap_or_else(|_| "\"\"".to_string()); let js = format!(r#"(function(){{ var t = (document.body && (document.body.innerText || document.body.textContent)) || ''; return t.indexOf({needle}) >= 0; }})()"#, needle = escaped); // Exponential backoff like browser_wait_for (see comment there). let mut tick_ms: u64 = 5; loop { let found = state.page_mut().evaluate(&js).as_bool().unwrap_or(false); if found { return Ok(format!("Found text {needle:?}")); } if tokio::time::Instant::now() >= deadline { return Err(format!("Timeout waiting for text {needle:?}")); } tokio::time::sleep(tokio::time::Duration::from_millis(tick_ms)).await; if tick_ms < 200 { tick_ms = (tick_ms * 2).min(200); } } } // ===== Tier 2 agent-UX additions ===== /// Describe every on the page. For each form, list its action, /// method, and every field (input/select/textarea) with its name, type, /// current value, and any visible label text. Agents call this to /// understand a form's shape before filling it. fn tool_detect_forms(state: &mut BrowserState) -> Result { let page = state.page_mut(); let js = r#"(function(){ var forms = document.querySelectorAll('form'); var out = []; for (var i = 0; i < forms.length; i++) { var f = forms[i]; var fields = []; var inputs = f.querySelectorAll('input, select, textarea, button'); for (var j = 0; j < inputs.length; j++) { var el = inputs[j]; var tag = el.tagName.toLowerCase(); var type = (el.getAttribute('type') || (tag === 'input' ? 'text' : tag)).toLowerCase(); if (tag === 'input' && type === 'hidden') continue; var name = el.getAttribute('name') || ''; var label = ''; if (el.id) { var lab = document.querySelector('label[for="' + el.id + '"]'); if (lab) label = (lab.innerText || lab.textContent || '').trim(); } if (!label) label = el.getAttribute('aria-label') || el.getAttribute('placeholder') || ''; var opts = null; if (tag === 'select') { opts = []; var os = el.querySelectorAll('option'); for (var k = 0; k < os.length; k++) { opts.push({ value: os[k].value, text: (os[k].textContent || '').trim() }); } } fields.push({ tag: tag, type: type, name: name, value: el.value || '', checked: el.checked || false, required: el.required || false, label: label.trim().slice(0, 100), ref: el.getAttribute('data-obscura-ref') || null, options: opts, }); } out.push({ index: i, id: f.id || '', name: f.getAttribute('name') || '', action: f.action || '', method: (f.method || 'get').toLowerCase(), fields: fields, }); } return out; })()"#; let val = page.evaluate(js); if val.is_null() { return Ok("No forms found.".to_string()); } serde_json::to_string_pretty(&val).map_err(|e| e.to_string()) } /// Fill multiple fields in one call. Each entry: {ref|selector, value, type?}. /// type='text' (default) sets value, 'check'/'uncheck' toggles checkbox, /// 'select' picks an option by value or visible text. Optional /// `submit_ref`/`submit_selector` clicks after filling. fn tool_fill_form(args: &Value, state: &mut BrowserState) -> Result { let fields = args.get("fields").and_then(Value::as_array) .ok_or("Missing fields array")? .clone(); let mut filled = 0u32; let mut errors = Vec::new(); for field in fields { let value = field.get("value").and_then(Value::as_str).unwrap_or(""); let kind = field.get("type").and_then(Value::as_str).unwrap_or("text"); let selector = match resolve_target(&field, state) { Ok(s) => s, Err(e) => { errors.push(e); continue; } }; let js = match kind { "check" => format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; el.checked = true; el.dispatchEvent(new Event('change', {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap()), "uncheck" => format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; el.checked = false; el.dispatchEvent(new Event('change', {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap()), "select" => format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; var want = {val}; var matched = false; for (var i = 0; i < el.options.length; i++) {{ var o = el.options[i]; if (o.value === want || (o.textContent || '').trim() === want) {{ el.selectedIndex = i; matched = true; break; }} }} if (!matched) return "error:no matching option"; el.dispatchEvent(new Event('change', {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), val = serde_json::to_string(value).unwrap()), _ => format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; el.value = {val}; el.dispatchEvent(new Event('input', {{bubbles:true}})); el.dispatchEvent(new Event('change', {{bubbles:true}})); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), val = serde_json::to_string(value).unwrap()), }; let res = state.page_mut().evaluate(&js); match res.as_str() { Some("ok") => filled += 1, Some(e) => errors.push(format!("{selector}: {e}")), None => errors.push(format!("{selector}: unknown error")), } } // Optional submit click let submit_target = if args.get("submit_ref").is_some() || args.get("submit_selector").is_some() { let pseudo = json!({ "ref": args.get("submit_ref"), "selector": args.get("submit_selector"), }); resolve_target(&pseudo, state).ok() } else { None }; if let Some(sel) = submit_target { let js = format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; el.click(); return "ok"; }})()"#, sel = serde_json::to_string(&sel).unwrap()); let _ = state.page_mut().evaluate(&js); state.interactive_refs.clear(); } if errors.is_empty() { Ok(format!("Filled {filled} fields.")) } else { Ok(format!("Filled {filled} fields. Errors: {}", errors.join("; "))) } } /// Scroll the page (or an element) by direction + amount, or scroll an /// element into view. Used to trigger infinite-scroll loaders or to /// reach off-viewport content. Returns the new scroll position. fn tool_scroll(args: &Value, state: &mut BrowserState) -> Result { let direction = args.get("direction").and_then(Value::as_str).unwrap_or("down"); let amount = args.get("amount").and_then(Value::as_f64); // Element scroll-into-view path if args.get("ref").is_some() || args.get("selector").is_some() { let selector = resolve_target(args, state)?; let js = format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; el.scrollIntoView({{behavior:'instant', block:'center'}}); return JSON.stringify({{x: window.scrollX, y: window.scrollY}}); }})()"#, sel = serde_json::to_string(&selector).unwrap()); let res = state.page_mut().evaluate(&js); if res.as_str() == Some("error:not found") { return Err(format!("Element not found: {selector}")); } return Ok(format!("Scrolled element into view. {}", res.as_str().unwrap_or(""))); } // Page-level scroll. Also dispatch a 'scroll' event so infinite- // scroll handlers fire (we don't have a real layout engine, so the // window.scrollY value won't change but the event is what matters). let amt = amount.unwrap_or(720.0); let js = format!(r#"(function(){{ var dir = {dir}; var amt = {amt}; switch (dir) {{ case 'top': window.scrollTo(0, 0); break; case 'bottom': window.scrollTo(0, document.body.scrollHeight); break; case 'up': window.scrollBy(0, -amt); break; case 'down': window.scrollBy(0, amt); break; case 'left': window.scrollBy(-amt, 0); break; case 'right': window.scrollBy(amt, 0); break; }} try {{ window.dispatchEvent(new Event('scroll', {{bubbles:true}})); }} catch(e) {{}} try {{ document.dispatchEvent(new Event('scroll', {{bubbles:true}})); }} catch(e) {{}} return JSON.stringify({{x: window.scrollX, y: window.scrollY, max_y: document.body.scrollHeight, viewport_h: window.innerHeight}}); }})()"#, dir = serde_json::to_string(direction).unwrap(), amt = amt, ); let res = state.page_mut().evaluate(&js); // A scroll can reveal new DOM (infinite scroll); invalidate refs. state.interactive_refs.clear(); Ok(format!("Scrolled {direction}. {}", res.as_str().unwrap_or(""))) } fn tool_get_attribute(args: &Value, state: &mut BrowserState) -> Result { let selector = resolve_target(args, state)?; let attr = args.get("attribute").and_then(Value::as_str) .ok_or("Missing attribute parameter")?; let js = format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return null; var v = el.getAttribute({a}); if (v === null && {a} === 'value') v = el.value || ''; return v == null ? '' : v; }})()"#, sel = serde_json::to_string(&selector).unwrap(), a = serde_json::to_string(attr).unwrap(), ); let res = state.page_mut().evaluate(&js); if res.is_null() { return Err(format!("Element not found: {selector}")); } Ok(res.as_str().unwrap_or("").to_string()) } fn tool_count(args: &Value, state: &mut BrowserState) -> Result { let selector = args.get("selector").and_then(Value::as_str) .ok_or("Missing selector parameter")?; let js = format!( "document.querySelectorAll({sel}).length", sel = serde_json::to_string(selector).unwrap() ); let res = state.page_mut().evaluate(&js); // V8 numbers come back as f64 even when they are integer-valued; as_u64 // returns None for f64 in serde_json, so coerce via f64. let n = res.as_u64() .or_else(|| res.as_f64().map(|f| f as u64)) .unwrap_or(0); Ok(n.to_string()) } /// Extract structured data: `schema` is a map of field name to CSS /// selector. Suffix selector with `@attr` to read an attribute instead /// of text. Suffix field name with `[]` to return an array (queries all /// matching elements rather than the first). fn tool_extract(args: &Value, state: &mut BrowserState) -> Result { let schema = args.get("schema").and_then(Value::as_object) .ok_or("Missing schema object")? .clone(); let schema_json = serde_json::to_string(&schema).unwrap(); let js = format!(r#"(function(){{ var schema = {schema}; var out = {{}}; for (var key in schema) {{ if (!Object.prototype.hasOwnProperty.call(schema, key)) continue; var spec = schema[key]; var is_array = key.endsWith('[]'); var name = is_array ? key.slice(0, -2) : key; // Selector may end with `@attr` to read an attribute. var attr = null; var sel = spec; var at = spec.lastIndexOf('@'); if (at > 0 && spec.indexOf(' ', at) < 0) {{ attr = spec.slice(at + 1); sel = spec.slice(0, at); }} var get = function(el) {{ if (!el) return null; if (attr) return el.getAttribute(attr) || ''; return ((el.innerText || el.textContent) || '').trim(); }}; if (is_array) {{ var els = document.querySelectorAll(sel); var arr = []; for (var i = 0; i < els.length; i++) arr.push(get(els[i])); out[name] = arr; }} else {{ out[name] = get(document.querySelector(sel)); }} }} return out; }})()"#, schema = schema_json); let res = state.page_mut().evaluate(&js); serde_json::to_string_pretty(&res).map_err(|e| e.to_string()) } async fn tool_tab_new(args: &Value, state: &mut BrowserState) -> Result { let url = args.get("url").and_then(Value::as_str); let id = state.new_tab(); if let Some(u) = url { let ua = state.user_agent.clone(); let page = state.page_mut(); if let Some(ref ua) = ua { page.http_client.set_user_agent(ua).await; } page.navigate_with_wait(u, obscura_browser::lifecycle::WaitUntil::DomContentLoaded) .await.map_err(|e| e.to_string())?; Ok(format!("Opened {id} and navigated to {}", page.url_string())) } else { Ok(format!("Opened {id} (about:blank).")) } } fn tool_tab_list(state: &BrowserState) -> Result { if state.tabs.is_empty() { return Ok("No tabs open.".to_string()); } let lines: Vec = state.tabs.iter().map(|(id, page)| { let active = if Some(id) == state.active_tab.as_ref() { "*" } else { " " }; let url = page.url_string(); let title = page.title.replace('\n', " "); format!("{active} {id} {url} \"{title}\"") }).collect(); Ok(lines.join("\n")) } fn tool_tab_switch(args: &Value, state: &mut BrowserState) -> Result { let tab_id = args.get("tab_id").and_then(Value::as_str) .ok_or("Missing tab_id parameter")?; if !state.tabs.contains_key(tab_id) { return Err(format!("No such tab: {tab_id}")); } state.active_tab = Some(tab_id.to_string()); state.interactive_refs.clear(); Ok(format!("Active tab: {tab_id}")) } fn tool_tab_close(args: &Value, state: &mut BrowserState) -> Result { let tab_id = args.get("tab_id").and_then(Value::as_str) .map(String::from) .or_else(|| state.active_tab.clone()) .ok_or("No tab to close")?; if state.tabs.remove(&tab_id).is_none() { return Err(format!("No such tab: {tab_id}")); } if state.active_tab.as_deref() == Some(&tab_id) { // Promote some remaining tab to active, if any. state.active_tab = state.tabs.keys().next().cloned(); state.interactive_refs.clear(); } let summary = if let Some(ref a) = state.active_tab { format!("Closed {tab_id}. Active tab now {a}.") } else { format!("Closed {tab_id}. No tabs remain.") }; Ok(summary) } fn extract_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeId) -> String { use obscura_dom::NodeData; let mut result = String::new(); let node = match dom.get_node(node_id) { Some(n) => n, None => return result, }; match &node.data { NodeData::Text { contents } => { let trimmed = contents.trim(); if !trimmed.is_empty() { result.push_str(trimmed); result.push(' '); } } NodeData::Element { name, .. } => { let tag = name.local.as_ref(); if matches!(tag, "script" | "style" | "noscript") { return result; } let is_block = matches!( tag, "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "li" | "tr" | "br" | "hr" | "section" | "article" | "header" | "footer" | "nav" | "main" | "aside" | "blockquote" | "pre" | "ul" | "ol" | "table" ); if is_block { result.push('\n'); } for child in dom.children(node_id) { result.push_str(&extract_text(dom, child)); } if is_block { result.push('\n'); } } _ => { for child in dom.children(node_id) { result.push_str(&extract_text(dom, child)); } } } result } // ===== Tier 3 agent-UX additions ===== /// Substring search in visible page text. Returns each match with N chars /// of surrounding context so the agent can locate the section without /// pulling the whole page into its window. fn tool_search(args: &Value, state: &mut BrowserState) -> Result { let query = args.get("query").and_then(Value::as_str) .ok_or("Missing query parameter")?; let case_sensitive = args.get("case_sensitive").and_then(Value::as_bool).unwrap_or(false); let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize; let context = args.get("context_chars").and_then(Value::as_u64).unwrap_or(80) as usize; let page = state.page_mut(); let body = page.with_dom(|dom| { dom.query_selector("body").ok().flatten() .map(|b| extract_text(dom, b)) .unwrap_or_default() }).unwrap_or_default(); let haystack = if case_sensitive { body.clone() } else { body.to_lowercase() }; let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() }; let mut out = Vec::new(); let mut idx = 0; while let Some(pos) = haystack[idx..].find(&needle) { let abs = idx + pos; let start = abs.saturating_sub(context); let end = (abs + needle.len() + context).min(body.len()); // body and haystack share byte offsets even when lowercased (only ASCII fold). // For safety on non-ASCII, fall back to char-boundary trimming. let start = body[..start].rfind(|c: char| c.is_whitespace()) .map(|i| i + 1).unwrap_or(start); let end = body[end..].find(|c: char| c.is_whitespace()) .map(|i| end + i).unwrap_or(end); let snippet = body.get(start..end).unwrap_or("").trim().replace('\n', " "); out.push(json!({ "offset": abs, "snippet": snippet, })); idx = abs + needle.len(); if out.len() >= limit { break; } } if out.is_empty() { Ok(format!("No matches for {query:?}.")) } else { Ok(format!("{} match(es). {}", out.len(), out.iter().map(|v| v.to_string()).collect::>().join("\n"))) } } /// Export full session state: cookies + localStorage + sessionStorage /// for every origin the page knows about. Agents stash this between /// runs to skip a login flow. fn tool_storage_state(state: &mut BrowserState) -> Result { let cookies: Vec = state.context.cookie_jar.get_all_cookies().iter().map(|c| json!({ "name": c.name, "value": c.value, "domain": c.domain, "path": c.path, "secure": c.secure, "http_only": c.http_only, "same_site": c.same_site, "expires": c.expires, })).collect(); // Pull localStorage + sessionStorage for the current page's origin. let storage_js = r#"(function(){ var ls = [], ss = []; try { for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); ls.push([k, localStorage.getItem(k)]); } } catch(e) {} try { for (var j = 0; j < sessionStorage.length; j++) { var k2 = sessionStorage.key(j); ss.push([k2, sessionStorage.getItem(k2)]); } } catch(e) {} return { origin: location.origin || '', localStorage: ls, sessionStorage: ss }; })()"#; let storage = if state.active_tab.is_some() { state.page_mut().evaluate(storage_js) } else { Value::Null }; let origins = if storage.is_object() { vec![storage] } else { vec![] }; let out = json!({ "cookies": cookies, "origins": origins }); serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } fn tool_set_storage_state(args: &Value, state: &mut BrowserState) -> Result { let s = args.get("state").ok_or("Missing state object")?; let mut applied = 0u32; // Cookies if let Some(cookies) = s.get("cookies").and_then(Value::as_array) { let parsed: Vec = cookies.iter().filter_map(|c| { Some(obscura_net::CookieInfo { name: c.get("name")?.as_str()?.to_string(), value: c.get("value")?.as_str()?.to_string(), domain: c.get("domain")?.as_str()?.to_string(), path: c.get("path").and_then(Value::as_str).unwrap_or("/").to_string(), secure: c.get("secure").and_then(Value::as_bool).unwrap_or(false), http_only: c.get("http_only").and_then(Value::as_bool).unwrap_or(false), same_site: c.get("same_site").and_then(Value::as_str).unwrap_or("").to_string(), expires: c.get("expires").and_then(Value::as_i64), }) }).collect(); applied += parsed.len() as u32; state.context.cookie_jar.set_cookies_from_cdp(parsed); } // Storage (per origin). Only applies if there's an active page; we // restore on whatever origin is currently loaded, which usually // matches because agents navigate before restoring state. if state.active_tab.is_some() { if let Some(origins) = s.get("origins").and_then(Value::as_array) { for origin_entry in origins { let mut snippets = Vec::new(); if let Some(arr) = origin_entry.get("localStorage").and_then(Value::as_array) { for pair in arr { if let (Some(k), Some(v)) = ( pair.get(0).and_then(Value::as_str), pair.get(1).and_then(Value::as_str), ) { snippets.push(format!( "try {{ localStorage.setItem({k},{v}); }} catch(e) {{}};", k = serde_json::to_string(k).unwrap(), v = serde_json::to_string(v).unwrap(), )); applied += 1; } } } if let Some(arr) = origin_entry.get("sessionStorage").and_then(Value::as_array) { for pair in arr { if let (Some(k), Some(v)) = ( pair.get(0).and_then(Value::as_str), pair.get(1).and_then(Value::as_str), ) { snippets.push(format!( "try {{ sessionStorage.setItem({k},{v}); }} catch(e) {{}};", k = serde_json::to_string(k).unwrap(), v = serde_json::to_string(v).unwrap(), )); applied += 1; } } } if !snippets.is_empty() { let _ = state.page_mut().evaluate(&snippets.join("\n")); } } } } Ok(format!("Restored {applied} state entries.")) }