Skip to main content

agentkernel_sdk/
browser.rs

1//! Browser session for orchestrating headless browsers in sandboxes.
2//!
3//! Each method generates a self-contained Python/Playwright script,
4//! runs it inside the sandbox, and parses the JSON result.
5
6use base64::Engine;
7
8use crate::client::AgentKernel;
9use crate::error::{Error, Result};
10use crate::types::PageResult;
11
12// ---------------------------------------------------------------------------
13// Inline Playwright script templates
14// ---------------------------------------------------------------------------
15
16const GOTO_SCRIPT: &str = r#"
17import asyncio, json, sys
18from playwright.async_api import async_playwright
19async def main():
20    url = sys.argv[1]
21    async with async_playwright() as p:
22        b = await p.chromium.launch()
23        page = await b.new_page()
24        await page.goto(url, timeout=30000)
25        title = await page.title()
26        url_final = page.url
27        text = await page.evaluate("() => document.body.innerText.slice(0, 8000)")
28        links = await page.evaluate('''() =>
29            Array.from(document.querySelectorAll('a[href]'))
30                .slice(0, 50)
31                .map(a => ({text: a.textContent.trim(), href: a.href}))
32                .filter(l => l.href.startsWith("http"))
33        ''')
34        print(json.dumps({"title": title, "url": url_final, "text": text, "links": links}))
35        await b.close()
36asyncio.run(main())
37"#;
38
39const SCREENSHOT_SCRIPT: &str = r#"
40import asyncio, base64, json, sys
41from playwright.async_api import async_playwright
42async def main():
43    url = sys.argv[1]
44    async with async_playwright() as p:
45        b = await p.chromium.launch()
46        page = await b.new_page()
47        await page.goto(url, timeout=30000)
48        data = await page.screenshot()
49        print(base64.b64encode(data).decode())
50        await b.close()
51asyncio.run(main())
52"#;
53
54const EVALUATE_SCRIPT: &str = r#"
55import asyncio, json, sys
56from playwright.async_api import async_playwright
57async def main():
58    url = sys.argv[1]
59    expr = sys.argv[2]
60    async with async_playwright() as p:
61        b = await p.chromium.launch()
62        page = await b.new_page()
63        await page.goto(url, timeout=30000)
64        result = await page.evaluate(expr)
65        print(json.dumps(result))
66        await b.close()
67asyncio.run(main())
68"#;
69
70/// Command to install Playwright + Chromium inside a sandbox.
71pub const BROWSER_SETUP_CMD: &[&str] = &[
72    "sh",
73    "-c",
74    "pip install -q playwright && playwright install --with-deps chromium",
75];
76
77/// A sandboxed headless browser controlled from outside.
78///
79/// The browser (Chromium via Playwright) runs inside an agentkernel sandbox.
80/// You call high-level methods; the SDK generates and executes scripts internally.
81///
82/// # Example
83///
84/// ```no_run
85/// # async fn example() -> agentkernel_sdk::Result<()> {
86/// let client = agentkernel_sdk::AgentKernel::builder().build()?;
87/// let mut browser = client.browser("my-browser", None).await?;
88/// let page = browser.goto("https://example.com").await?;
89/// println!("{} — {} links", page.title, page.links.len());
90/// let png = browser.screenshot(None).await?;
91/// browser.remove().await?;
92/// # Ok(())
93/// # }
94/// ```
95pub struct BrowserSession {
96    /// The sandbox name.
97    name: String,
98    /// The underlying client.
99    client: AgentKernel,
100    /// Whether `remove()` has already been called.
101    removed: bool,
102    /// Last URL visited via `goto()`.
103    last_url: Option<String>,
104}
105
106impl BrowserSession {
107    /// Create a new `BrowserSession`.
108    ///
109    /// Prefer [`AgentKernel::browser`] which creates the sandbox and installs
110    /// Playwright for you.
111    pub(crate) fn new(name: String, client: AgentKernel) -> Self {
112        Self {
113            name,
114            client,
115            removed: false,
116            last_url: None,
117        }
118    }
119
120    /// The sandbox name backing this browser session.
121    pub fn name(&self) -> &str {
122        &self.name
123    }
124
125    /// Navigate to a URL and return page data (title, text, links).
126    pub async fn goto(&mut self, url: &str) -> Result<PageResult> {
127        let output = self
128            .client
129            .exec_in_sandbox(&self.name, &["python3", "-c", GOTO_SCRIPT, url], None)
130            .await?;
131        self.last_url = Some(url.to_string());
132        let result: PageResult = serde_json::from_str(&output.output)?;
133        Ok(result)
134    }
135
136    /// Take a PNG screenshot. Returns the raw PNG bytes.
137    ///
138    /// If `url` is `None`, re-uses the last URL from [`goto`](Self::goto).
139    pub async fn screenshot(&self, url: Option<&str>) -> Result<Vec<u8>> {
140        let target = url
141            .map(String::from)
142            .or_else(|| self.last_url.clone())
143            .ok_or_else(|| {
144                Error::Validation("No URL specified and no previous goto() call".to_string())
145            })?;
146        let output = self
147            .client
148            .exec_in_sandbox(
149                &self.name,
150                &["python3", "-c", SCREENSHOT_SCRIPT, &target],
151                None,
152            )
153            .await?;
154        let bytes = base64::engine::general_purpose::STANDARD
155            .decode(output.output.trim())
156            .map_err(|e| Error::Server(format!("base64 decode failed: {e}")))?;
157        Ok(bytes)
158    }
159
160    /// Run a JavaScript expression on a page and return the result as JSON.
161    ///
162    /// If `url` is `None`, re-uses the last URL from [`goto`](Self::goto).
163    pub async fn evaluate(&self, expression: &str, url: Option<&str>) -> Result<serde_json::Value> {
164        let target = url
165            .map(String::from)
166            .or_else(|| self.last_url.clone())
167            .ok_or_else(|| {
168                Error::Validation("No URL specified and no previous goto() call".to_string())
169            })?;
170        let output = self
171            .client
172            .exec_in_sandbox(
173                &self.name,
174                &["python3", "-c", EVALUATE_SCRIPT, &target, expression],
175                None,
176            )
177            .await?;
178        let value: serde_json::Value = serde_json::from_str(&output.output)?;
179        Ok(value)
180    }
181
182    /// Remove the underlying sandbox. Idempotent.
183    pub async fn remove(&mut self) -> Result<()> {
184        if self.removed {
185            return Ok(());
186        }
187        self.removed = true;
188        self.client.remove_sandbox(&self.name).await
189    }
190}