LazyBoy2/crates/grokboy-core/src/browser.rs

469 lines
19 KiB
Rust
Raw Normal View History

//! Optional Playwright DOM browser tools (P3) + human handoff (P4).
//!
//! Spawns `tools/playwright/browser_helper.mjs` (JSONL over stdin/stdout).
//! Fail closed with an install hint when Node / Playwright / Chromium is missing.
//! Prefer selectors / roles — not screenshot-first control.
//!
//! Env:
//! - `GROKBOY_BROWSER_HEADED=1` — launch Chromium headed (visible) by default
//! - `GROKBOY_HANDOFF_AUTO=1` — auto-resume handoff (tests); `abort` to auto-abort
2026-09-13 16:38:32 +00:00
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
2026-09-13 16:38:32 +00:00
use std::process::Command;
use std::sync::{Arc, Mutex};
pub const INSTALL_HINT: &str = "Playwright browser tools unavailable. Install with: \
cd tools/playwright && npm install && npx playwright install chromium";
const HELPER_REL: &str = "tools/playwright/browser_helper.mjs";
/// Shared last navigated URL for session metadata.
pub type LastUrlSlot = Arc<Mutex<Option<String>>>;
/// Locate the helper script relative to cwd, then walk parents, then exe-relative.
pub fn find_helper_script(cwd: &Path) -> Option<PathBuf> {
let mut dir = cwd.to_path_buf();
for _ in 0..8 {
let candidate = dir.join(HELPER_REL);
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
// Also try next to the running binary's ancestors (dev: target/debug).
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..6 {
let Some(d) = dir else { break };
let candidate = d.join(HELPER_REL);
if candidate.is_file() {
return Some(candidate);
}
// target/debug -> repo root
if let Some(parent) = d.parent() {
let up2 = parent.parent().map(|p| p.join(HELPER_REL));
if let Some(c) = up2 {
if c.is_file() {
return Some(c);
}
}
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
None
}
fn which_node() -> Option<PathBuf> {
// Prefer PATH lookup.
2026-09-13 16:38:32 +00:00
if let Ok(output) = Command::new("sh").arg("-c").arg("command -v node").output() {
if output.status.success() {
let p = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !p.is_empty() {
return Some(PathBuf::from(p));
}
}
}
None
}
/// One-shot `--cmd` invocation (no daemon). Useful for offline protocol tests.
pub fn browser_oneshot(cwd: &Path, req: &Value) -> Result<Value> {
2026-09-13 16:38:32 +00:00
let script =
find_helper_script(cwd).ok_or_else(|| anyhow!("{INSTALL_HINT} (helper not found)"))?;
let node = which_node().ok_or_else(|| anyhow!("{INSTALL_HINT} (node not found)"))?;
let cmd_json = serde_json::to_string(req)?;
let output = Command::new(node)
.arg(&script)
.arg("--cmd")
.arg(&cmd_json)
.current_dir(cwd)
.output()
.context("oneshot browser helper")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
if line.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"browser helper produced no JSON (stderr: {})",
stderr.trim()
));
}
2026-09-13 16:38:32 +00:00
let v: Value =
serde_json::from_str(line).with_context(|| format!("oneshot non-JSON: {line}"))?;
Ok(v)
}
/// Run helper `--self-test` (no Chromium). Returns parsed summary JSON.
pub fn browser_self_test(cwd: &Path) -> Result<Value> {
2026-09-13 16:38:32 +00:00
let script =
find_helper_script(cwd).ok_or_else(|| anyhow!("{INSTALL_HINT} (helper not found)"))?;
let node = which_node().ok_or_else(|| anyhow!("{INSTALL_HINT} (node not found)"))?;
let output = Command::new(node)
.arg(&script)
.arg("--self-test")
2026-09-13 16:38:32 +00:00
.current_dir(script.parent().unwrap_or(cwd))
.output()
.context("browser helper --self-test")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
if line.is_empty() {
return Err(anyhow!(
"self-test empty stdout; stderr={}",
String::from_utf8_lossy(&output.stderr)
));
}
let v: Value = serde_json::from_str(line)?;
if !output.status.success() || v.get("ok") != Some(&json!(true)) {
return Err(anyhow!("self-test failed: {v}"));
}
Ok(v)
}
pub fn update_last_url(slot: &LastUrlSlot, resp: &Value) {
if let Some(url) = resp.get("url").and_then(|u| u.as_str()) {
if let Ok(mut g) = slot.lock() {
*g = Some(url.to_string());
}
}
}
pub fn response_to_tool_json(resp: Value) -> Value {
if resp.get("ok") == Some(&json!(true)) {
let mut out = resp;
if let Some(obj) = out.as_object_mut() {
obj.remove("ok");
obj.remove("id");
}
out
} else {
let err = resp
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("browser tool failed");
json!({
"error": err,
"blocked": true,
"install_hint": INSTALL_HINT,
"detail": resp,
})
}
}
/// OpenAI tool definitions for browser ops (always registered; fail closed if missing).
pub fn browser_tool_definitions() -> Vec<Value> {
2026-09-13 16:38:32 +00:00
let mut defs = vec![
json!({
"type": "function",
"function": {
"name": "browser_navigate",
"description": "Navigate the optional Playwright browser to a URL (DOM path). Fails closed with install hint if Playwright is missing.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "URL to open" }
},
"required": ["url"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_snapshot",
"description": "Structured text snapshot of the page (roles/names/CSS selectors). Prefer this over screenshots for control.",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_click",
"description": "Click an element by CSS selector or role/name (Playwright DOM). Not pixel/XY click.",
"parameters": {
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector" },
"role": { "type": "string", "description": "ARIA role, e.g. button, link" },
"name": { "type": "string", "description": "Accessible name when using role" },
"text": { "type": "string", "description": "Visible text alternative" },
"label": { "type": "string" },
"placeholder": { "type": "string" }
}
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_type",
"description": "Type/fill text into an element by CSS selector or role/name.",
"parameters": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to type" },
"selector": { "type": "string" },
"role": { "type": "string" },
"name": { "type": "string" },
"label": { "type": "string" },
"placeholder": { "type": "string" },
"clear": { "type": "boolean", "description": "Fill (clear first) vs append; default true" }
},
"required": ["text"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_eval",
"description": "Evaluate a JavaScript expression in the page context (use sparingly; prefer snapshot/click/type).",
"parameters": {
"type": "object",
"properties": {
"expression": { "type": "string", "description": "JS expression to eval in page" }
},
"required": ["expression"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_handoff",
"description": "Pause for human help on auth walls (login, OTP, captcha). Shows a headed Chromium window, prints terminal instructions (ZH+EN), waits for Enter (or abort), then returns a DOM snapshot so the agent can continue. Prefer when automation cannot pass the wall.",
"parameters": {
"type": "object",
"properties": {
2026-09-13 16:38:32 +00:00
"options": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"description":"Alternative routes if manual login still fails, e.g. draft content without login. Runtime adds resume and stop choices."},
"reason": {
"type": "string",
"description": "Why human help is needed (e.g. login page, OTP, captcha)"
},
"timeout_secs": {
"type": "integer",
"description": "Seconds to wait for user (default 300)"
}
},
"required": ["reason"]
}
}
}),
2026-09-13 16:38:32 +00:00
];
let extra = [
("browser_release", "Close this task browser and release exclusive ownership while retaining its persistent login profile. Use before delegating browser work or waiting on browser children. Other tasks from the same owner can then reuse login.", json!({}), json!([])),
("browser_read_page", "Read visible page text in character segments and source links. offset defaults 0; limit defaults 12000. Use next_offset to continue.", json!({"offset":{"type":"integer"},"limit":{"type":"integer"}}), json!([])),
("browser_press", "Press a key such as Enter, Tab, Escape or Control+a on a located element or the active page.", json!({"key":{"type":"string"}}), json!(["key"])),
("browser_select", "Select options in a select element by value.", json!({"values":{"type":"array","items":{"type":"string"}}}), json!(["values"])),
("browser_scroll", "Scroll page or a located element by delta_y pixels, then inspect a new snapshot.", json!({"delta_y":{"type":"integer"}}), json!([])),
("browser_wait", "Wait for a located element to become visible/hidden/attached/detached, or page URL to match. No unconditional sleep. Default 10s, maximum 30s.", json!({"state":{"type":"string","enum":["visible","hidden","attached","detached"]},"url":{"type":"string"},"timeout_ms":{"type":"integer"}}), json!([])),
("browser_tabs", "List tabs/popups and frame selectors, or switch/close a tab by tab_id; new opens a blank tab. Action defaults list.", json!({"action":{"type":"string","enum":["list","switch","close","new"]}}), json!([])),
("browser_upload", "Upload a workspace file to a file input, by selector/role. Only upload content authorized by the user task.", json!({"path":{"type":"string"}}), json!(["path"])),
("browser_download", "Click a located download link and save the resulting download to a new workspace path. Returns actual saved path and byte size.", json!({"path":{"type":"string"}}), json!(["path"])),
];
for (name, description, properties, required) in extra {
defs.push(json!({"type":"function","function":{"name":name,"description":description,"parameters":{"type":"object","properties":properties,"required":required}}}));
}
2026-09-13 16:38:32 +00:00
for d in &mut defs {
let schema = &mut d["function"]["parameters"];
let props = schema["properties"].as_object_mut().unwrap();
props.insert(
"tab_id".into(),
json!({"type":"string","description":"Tab ID from browser_tabs; omit for active tab"}),
);
props.insert("frame".into(),json!({"type":"string","description":"CSS selector of iframe in active page; omit for main frame"}));
if matches!(
d["function"]["name"].as_str(),
Some(
"browser_press"
| "browser_select"
| "browser_scroll"
| "browser_wait"
| "browser_upload"
| "browser_download"
)
) {
let props = d["function"]["parameters"]["properties"]
.as_object_mut()
.unwrap();
for key in ["selector", "role", "name", "label", "placeholder"] {
props.insert(key.into(), json!({"type":"string"}));
}
}
2026-09-13 16:38:32 +00:00
}
defs
}
2026-09-13 16:38:32 +00:00
#[cfg(test)]
fn is_browser_tool(name: &str) -> bool {
browser_tool_definitions()
.iter()
.any(|d| d["function"]["name"] == name)
}
/// Outcome of waiting for the human during handoff.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandoffWait {
Resumed,
Aborted(String),
TimedOut,
}
/// Wait for stdin line (Enter to continue, `abort` to cancel) or timeout.
///
/// `GROKBOY_HANDOFF_AUTO=1|resume` skips the wait (tests).
/// `GROKBOY_HANDOFF_AUTO=abort` auto-aborts.
2026-09-13 16:38:32 +00:00
pub fn wait_for_handoff_resume(_timeout_secs: u64) -> HandoffWait {
if let Ok(v) = std::env::var("GROKBOY_HANDOFF_AUTO") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "1" | "true" | "yes" | "resume" | "continue") {
return HandoffWait::Resumed;
}
2026-09-13 16:38:32 +00:00
if matches!(v.as_str(), "abort" | "0" | "false" | "no") {
return HandoffWait::Aborted(format!("GROKBOY_HANDOFF_AUTO={v}"));
}
}
2026-09-13 16:38:32 +00:00
HandoffWait::Aborted("interactive handoff requires the agent input broker".into())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn repo_cwd() -> PathBuf {
// Crate manifest dir is crates/grokboy-core → repo root is ../..
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn finds_helper_from_repo_root() {
let root = repo_cwd();
let helper = find_helper_script(&root);
assert!(
helper.is_some(),
"expected helper under {}/{HELPER_REL}",
root.display()
);
}
#[test]
fn helper_self_test_protocol() {
let root = repo_cwd();
// Skip soft if node missing (should still usually be present on Mac).
if which_node().is_none() {
eprintln!("skip: node not on PATH");
return;
}
let summary = browser_self_test(&root).expect("self-test");
assert_eq!(summary["ok"], true);
}
#[test]
fn oneshot_ping_json_protocol() {
let root = repo_cwd();
if which_node().is_none() {
eprintln!("skip: node not on PATH");
return;
}
let resp = browser_oneshot(&root, &json!({"op": "ping", "id": "u1"})).unwrap();
assert_eq!(resp["ok"], true);
assert_eq!(resp["pong"], true);
assert_eq!(resp["protocol"], 1);
}
#[test]
fn response_maps_errors_to_blocked() {
let v = response_to_tool_json(json!({
"ok": false,
"blocked": true,
"error": "nope",
"id": "1"
}));
assert!(v.get("error").is_some());
assert_eq!(v["blocked"], true);
assert!(v["install_hint"].as_str().unwrap().contains("npm install"));
}
#[test]
fn browser_defs_count() {
2026-09-13 16:38:32 +00:00
assert_eq!(browser_tool_definitions().len(), 15);
assert!(is_browser_tool("browser_navigate"));
assert!(is_browser_tool("browser_snapshot"));
assert!(is_browser_tool("browser_handoff"));
assert!(!is_browser_tool("shell"));
}
#[test]
fn handoff_auto_resume_and_abort() {
let _env_lock = crate::test_env::lock();
// SAFETY: tests run serially for this env in practice; restore after.
let prev = std::env::var("GROKBOY_HANDOFF_AUTO").ok();
unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", "1") };
assert_eq!(wait_for_handoff_resume(1), HandoffWait::Resumed);
unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", "abort") };
match wait_for_handoff_resume(1) {
HandoffWait::Aborted(msg) => assert!(msg.contains("abort"), "{msg}"),
other => panic!("expected aborted, got {other:?}"),
}
match prev {
Some(v) => unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", v) },
None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") },
}
}
#[test]
fn oneshot_ping_lists_handoff_prepare() {
let root = repo_cwd();
if which_node().is_none() {
return;
}
let resp = browser_oneshot(&root, &json!({"op": "ping", "id": "h1"})).unwrap();
let ops = resp["ops"].as_array().expect("ops");
assert!(
ops.iter().any(|o| o.as_str() == Some("handoff_prepare")),
"{resp}"
);
}
#[test]
fn install_hint_constant() {
assert!(INSTALL_HINT.contains("npx playwright install chromium"));
}
#[tokio::test]
async fn execute_ping_via_navigate_missing_url_style() {
// Exercise execute path with type missing text → blocked JSON, no chromium needed
// if helper starts. Use browser_type without text through execute_browser_tool args check.
let root = repo_cwd();
if which_node().is_none() || find_helper_script(&root).is_none() {
return;
}
let slot: LastUrlSlot = Arc::new(Mutex::new(None));
// Direct request ping through oneshot already covered; here ensure spawn works with status via request.
let resp = browser_oneshot(&root, &json!({"op":"status"})).unwrap();
assert_eq!(resp["ok"], true);
let _ = slot;
let _ = SystemTime::now().duration_since(UNIX_EPOCH);
}
}