use base64::Engine;
use crate::client::AgentKernel;
use crate::error::{Error, Result};
use crate::types::{AriaSnapshot, PageResult};
const GOTO_SCRIPT: &str = r#"
import asyncio, json, sys
from playwright.async_api import async_playwright
async def main():
url = sys.argv[1]
async with async_playwright() as p:
b = await p.chromium.launch()
page = await b.new_page()
await page.goto(url, timeout=30000)
title = await page.title()
url_final = page.url
text = await page.evaluate("() => document.body.innerText.slice(0, 8000)")
links = await page.evaluate('''() =>
Array.from(document.querySelectorAll('a[href]'))
.slice(0, 50)
.map(a => ({text: a.textContent.trim(), href: a.href}))
.filter(l => l.href.startsWith("http"))
''')
print(json.dumps({"title": title, "url": url_final, "text": text, "links": links}))
await b.close()
asyncio.run(main())
"#;
const SCREENSHOT_SCRIPT: &str = r#"
import asyncio, base64, json, sys
from playwright.async_api import async_playwright
async def main():
url = sys.argv[1]
async with async_playwright() as p:
b = await p.chromium.launch()
page = await b.new_page()
await page.goto(url, timeout=30000)
data = await page.screenshot()
print(base64.b64encode(data).decode())
await b.close()
asyncio.run(main())
"#;
const EVALUATE_SCRIPT: &str = r#"
import asyncio, json, sys
from playwright.async_api import async_playwright
async def main():
url = sys.argv[1]
expr = sys.argv[2]
async with async_playwright() as p:
b = await p.chromium.launch()
page = await b.new_page()
await page.goto(url, timeout=30000)
result = await page.evaluate(expr)
print(json.dumps(result))
await b.close()
asyncio.run(main())
"#;
pub const BROWSER_SETUP_CMD: &[&str] = &[
"sh",
"-c",
"pip install -q playwright && playwright install --with-deps chromium",
];
const BROWSER_HEALTH_SCRIPT: &str = r#"
import json, urllib.request, sys
port = sys.argv[1] if len(sys.argv) > 1 else "9222"
try:
req = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5)
print(req.read().decode())
except Exception as e:
print(json.dumps({"status": "down", "error": str(e)}))
sys.exit(1)
"#;
const BROWSER_REQUEST_SCRIPT: &str = r#"
import json, urllib.request, sys
port = sys.argv[1] if len(sys.argv) > 1 else "9222"
method = sys.argv[2] if len(sys.argv) > 2 else "GET"
path = sys.argv[3] if len(sys.argv) > 3 else "/health"
body_str = sys.argv[4] if len(sys.argv) > 4 else None
url = f"http://127.0.0.1:{port}{path}"
data = body_str.encode() if body_str else None
req = urllib.request.Request(url, data=data, method=method)
if data:
req.add_header("Content-Type", "application/json")
try:
resp = urllib.request.urlopen(req, timeout=60)
print(resp.read().decode())
except urllib.error.HTTPError as e:
print(e.read().decode())
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
"#;
pub struct BrowserSession {
name: String,
client: AgentKernel,
removed: bool,
last_url: Option<String>,
server_started: bool,
}
impl BrowserSession {
pub(crate) fn new(name: String, client: AgentKernel) -> Self {
Self {
name,
client,
removed: false,
last_url: None,
server_started: false,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub async fn goto(&mut self, url: &str) -> Result<PageResult> {
let output = self
.client
.exec_in_sandbox(&self.name, &["python3", "-c", GOTO_SCRIPT, url], None)
.await?;
self.last_url = Some(url.to_string());
let result: PageResult = serde_json::from_str(&output.output)?;
Ok(result)
}
pub async fn screenshot(&self, url: Option<&str>) -> Result<Vec<u8>> {
let target = url
.map(String::from)
.or_else(|| self.last_url.clone())
.ok_or_else(|| {
Error::Validation("No URL specified and no previous goto() call".to_string())
})?;
let output = self
.client
.exec_in_sandbox(
&self.name,
&["python3", "-c", SCREENSHOT_SCRIPT, &target],
None,
)
.await?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(output.output.trim())
.map_err(|e| Error::Server(format!("base64 decode failed: {e}")))?;
Ok(bytes)
}
pub async fn evaluate(&self, expression: &str, url: Option<&str>) -> Result<serde_json::Value> {
let target = url
.map(String::from)
.or_else(|| self.last_url.clone())
.ok_or_else(|| {
Error::Validation("No URL specified and no previous goto() call".to_string())
})?;
let output = self
.client
.exec_in_sandbox(
&self.name,
&["python3", "-c", EVALUATE_SCRIPT, &target, expression],
None,
)
.await?;
let value: serde_json::Value = serde_json::from_str(&output.output)?;
Ok(value)
}
pub async fn remove(&mut self) -> Result<()> {
if self.removed {
return Ok(());
}
self.removed = true;
self.client.remove_sandbox(&self.name).await
}
async fn ensure_server(&mut self) -> Result<()> {
if self.server_started {
return Ok(());
}
let output = self
.client
.exec_in_sandbox(
&self.name,
&["python3", "-c", BROWSER_HEALTH_SCRIPT, "9222"],
None,
)
.await;
if let Ok(out) = output {
if out.output.contains("\"status\":\"ok\"") || out.output.contains("\"status\": \"ok\"")
{
self.server_started = true;
return Ok(());
}
}
Err(Error::Server(
"Browser server not running — use browser_create or MCP browser_open to start it"
.to_string(),
))
}
async fn browser_request(
&self,
method: &str,
path: &str,
body: Option<&str>,
) -> Result<String> {
let mut cmd = vec![
"python3",
"-c",
BROWSER_REQUEST_SCRIPT,
"9222",
method,
path,
];
if let Some(b) = body {
cmd.push(b);
}
let output = self.client.exec_in_sandbox(&self.name, &cmd, None).await?;
Ok(output.output)
}
pub async fn open(&mut self, url: &str, page: Option<&str>) -> Result<AriaSnapshot> {
let page = page.unwrap_or("default");
self.ensure_server().await?;
let body = serde_json::json!({ "url": url }).to_string();
let path = format!("/pages/{page}/goto");
let output = self.browser_request("POST", &path, Some(&body)).await?;
let result: AriaSnapshot = serde_json::from_str(&output)?;
Ok(result)
}
pub async fn snapshot(&mut self, page: Option<&str>) -> Result<AriaSnapshot> {
let page = page.unwrap_or("default");
self.ensure_server().await?;
let path = format!("/pages/{page}/snapshot");
let output = self.browser_request("GET", &path, None).await?;
let result: AriaSnapshot = serde_json::from_str(&output)?;
Ok(result)
}
pub async fn click(
&mut self,
page: Option<&str>,
ref_id: Option<&str>,
selector: Option<&str>,
) -> Result<AriaSnapshot> {
let page = page.unwrap_or("default");
self.ensure_server().await?;
let mut body = serde_json::Map::new();
if let Some(r) = ref_id {
body.insert("ref".to_string(), serde_json::Value::String(r.to_string()));
}
if let Some(s) = selector {
body.insert(
"selector".to_string(),
serde_json::Value::String(s.to_string()),
);
}
let body_str = serde_json::Value::Object(body).to_string();
let path = format!("/pages/{page}/click");
let output = self.browser_request("POST", &path, Some(&body_str)).await?;
let result: AriaSnapshot = serde_json::from_str(&output)?;
Ok(result)
}
pub async fn fill(
&mut self,
value: &str,
page: Option<&str>,
ref_id: Option<&str>,
selector: Option<&str>,
) -> Result<AriaSnapshot> {
let page = page.unwrap_or("default");
self.ensure_server().await?;
let mut body = serde_json::Map::new();
body.insert(
"value".to_string(),
serde_json::Value::String(value.to_string()),
);
if let Some(r) = ref_id {
body.insert("ref".to_string(), serde_json::Value::String(r.to_string()));
}
if let Some(s) = selector {
body.insert(
"selector".to_string(),
serde_json::Value::String(s.to_string()),
);
}
let body_str = serde_json::Value::Object(body).to_string();
let path = format!("/pages/{page}/fill");
let output = self.browser_request("POST", &path, Some(&body_str)).await?;
let result: AriaSnapshot = serde_json::from_str(&output)?;
Ok(result)
}
pub async fn close_page(&mut self, page: Option<&str>) -> Result<()> {
let page = page.unwrap_or("default");
self.ensure_server().await?;
let path = format!("/pages/{page}");
self.browser_request("DELETE", &path, None).await?;
Ok(())
}
pub async fn list_pages(&mut self) -> Result<Vec<String>> {
self.ensure_server().await?;
let output = self.browser_request("GET", "/pages", None).await?;
let value: serde_json::Value = serde_json::from_str(&output)?;
let pages = value["pages"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Ok(pages)
}
}