From 171dba23e76bd6fd50971cb2824c0a6758c241d8 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 31 May 2026 21:21:07 +0000 Subject: [PATCH] Feat/mcp agent skill tier1 (#215) * feat(mcp): 10 new agent tools + element refs (12 -> 22 tools) Brings obscura-mcp from a Playwright-style core (navigate/click/fill/ type/snapshot/wait_for/evaluate/select/press_key/network_requests/ console_messages/close) up to a full agent surface. New tools: - browser_markdown: render the page as Markdown (reuses the HTML_TO_MARKDOWN_JS converter behind `fetch --dump markdown`). Token-dense alternative to browser_snapshot. - browser_links: every with anchor text, JSON-per-line. Optional internal_only and limit args. - browser_interactive_elements: list every clickable / typeable element with a stable ref (e1, e2, ...) and a one-line label. - browser_back / browser_forward / browser_reload: history nav, uses the Page history stack from the engine-tier1 commits. - browser_get_cookies / browser_set_cookie / browser_clear_cookies: full cookie jar ops so an agent can skip a login flow when it already has a session token. - browser_wait_for_text: wait for a substring to appear anywhere in the visible page text. Complements browser_wait_for which only checks for selectors. Element refs (the playwright-mcp pattern, agent UX win): - BrowserState now keeps an interactive_refs HashMap built on each browser_snapshot / browser_interactive_elements call. - Each interactive element gets a data-obscura-ref="eN" attribute written into the DOM so refs survive across calls. - browser_click / browser_fill / browser_type accept `ref` (preferred) or `selector` (fallback). Removes the agent's 'guess a CSS selector then retry' loop. - Refs are cleared on every navigation / reload / back / forward / close. Output size limits: - browser_snapshot, browser_markdown accept max_chars (default 4000). - browser_links, browser_interactive_elements accept limit (default 100). - Truncated responses say '...(truncated, N more chars)' so the agent can ask for more if needed. Verified end-to-end via /tmp/mcp-test.sh: tools/list returns 22 tools, ref-based click on a real element works, markdown converts H1+P+UL correctly, cookies round-trip. 28/28 integration tests pass. * feat(mcp): 10 more agent tools, multi-tab, structured extract (22 -> 32 tools) Tier 2: closes the gaps in agentic flows the Tier 1 surface still needed multi-call workarounds for (login forms, paginated lists, multi-step research across multiple destinations). Multi-tab: - BrowserState now holds a BTreeMap of tabs (Page per tab) with an active_tab cursor. page_mut() auto-creates a default tab so every legacy single-tab tool keeps working. - browser_tab_new: open and optionally navigate. - browser_tab_list: list all open tabs with active marker. - browser_tab_switch: change which tab subsequent tools operate on. - browser_tab_close: close (default: active tab), promotes another. Form ergonomics: - browser_detect_forms: returns each
with action, method, and every field's tag, type, name, current value, label (from ` 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); + 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(200)).await; + } +} + +// ===== 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 { @@ -588,3 +1572,150 @@ fn extract_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeId) -> Str 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.")) +}