use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{
BROWSER_STDERR_MAX_BYTES, BROWSER_STDOUT_MAX_BYTES, BROWSER_TIMEOUT_SECS, BrowserArgs,
BrowserModeArg,
},
contract::{metadata_key as meta, tool_name},
process::{BoundedChildProcessLimits, run_bounded_child_process},
};
use crate::agent::cancellation::AgentCancellation;
use regex::Regex;
use serde_json::json;
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
time::Duration,
};
use uuid::Uuid;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
const PAGE_NAME: &str = "main";
const DEFAULT_BROWSER_MODE: BrowserModeArg = BrowserModeArg::Headless;
const DEV_BROWSER_MISSING: &str = "dev-browser is not installed. Install manually with: npm install -g dev-browser && dev-browser install";
// dev-browser helper env is deny-by-default. Keep browser/display essentials only:
// PATH, HOME, USER, TMPDIR, LANG, LC_*, DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, XAUTHORITY.
fn apply_dev_browser_env_allowlist(command: &mut Command) {
command.env_clear();
for (key, value) in std::env::vars_os() {
if allowed_dev_browser_env(&key) {
command.env(key, value);
}
}
}
fn allowed_dev_browser_env(key: &std::ffi::OsStr) -> bool {
let Some(key) = key.to_str() else {
return false;
};
matches!(
key,
"PATH"
| "HOME"
| "USER"
| "TMPDIR"
| "LANG"
| "DISPLAY"
| "WAYLAND_DISPLAY"
| "XDG_RUNTIME_DIR"
| "XAUTHORITY"
) || key.starts_with("LC_")
}
#[derive(Debug)]
pub(crate) struct BrowserRuntimeState {
name: String,
page_open: bool,
used_browser: bool,
}
impl BrowserRuntimeState {
pub(crate) fn new() -> Self {
let id = Uuid::new_v4().to_string();
let short = id.split('-').next().unwrap_or("browser");
Self {
name: format!("magi-code-browser-{short}"),
page_open: false,
used_browser: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BrowserTransport {
mode: BrowserModeArg,
connect_url: Option<String>,
ignore_https_errors: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedBrowserCommand {
action: String,
args: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BuiltBrowserCommand {
action: String,
command: String,
script: String,
}
struct TempScriptDir {
dir: PathBuf,
script: PathBuf,
}
impl TempScriptDir {
fn create(script: &str) -> anyhow::Result<Self> {
let dir = std::env::temp_dir().join(format!("magi-code-browser-{}", Uuid::new_v4()));
fs::create_dir(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
}
let script_path = dir.join("output.js");
if let Err(error) = fs::write(&script_path, script) {
let _ = fs::remove_dir_all(&dir);
return Err(error.into());
}
Ok(Self {
dir,
script: script_path,
})
}
fn script_path(&self) -> &Path {
&self.script
}
fn cleanup(self) -> Option<String> {
fs::remove_dir_all(&self.dir)
.err()
.map(|error| format!("temp script cleanup failed: {error}"))
}
}
impl ToolRuntime {
pub(super) fn browser(
&self,
args: BrowserArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
self.browser_with_bin(args, Path::new("dev-browser"), cancellation)
}
fn browser_with_bin(
&self,
args: BrowserArgs,
dev_browser_bin: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let args = args.validate()?;
let transport = BrowserTransport::from_args(&args);
let parsed = parse_command(&args.command)?;
let browser_name = {
let browser_lock = self
.browser_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
browser_lock.name.clone()
};
let built = build_command(&parsed, &browser_name)?;
cancellation.check()?;
let temp = TempScriptDir::create(&built.script)?;
let _operation_lock = self
.browser_operation_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut result = self.run_dev_browser(
dev_browser_bin,
&browser_name,
&transport,
&built,
temp.script_path(),
cancellation,
);
let temp_cleanup_warning = temp.cleanup();
if let Ok(tool_result) = &mut result {
merge_cleanup_warning(tool_result, temp_cleanup_warning);
if tool_result.success {
let mut state = self
.browser_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.used_browser = true;
state.page_open = built.action != "close";
}
}
result
}
fn run_dev_browser(
&self,
dev_browser_bin: &Path,
browser_name: &str,
transport: &BrowserTransport,
built: &BuiltBrowserCommand,
script_path: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let mut command = Command::new(dev_browser_bin);
command
.args(build_dev_browser_args(transport, browser_name, script_path))
.current_dir(&self.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
apply_dev_browser_env_allowlist(&mut command);
#[cfg(unix)]
{
command.process_group(0);
}
let child = match command.spawn() {
Ok(child) => child,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(browser_failure_result(
&built.action,
&transport.mode,
&self.browser_metadata_base(built, transport, None, false, None),
DEV_BROWSER_MISSING.to_string(),
));
}
Err(error) => return Err(error.into()),
};
let output = run_bounded_child_process(
child,
BoundedChildProcessLimits {
stdout_max_bytes: BROWSER_STDOUT_MAX_BYTES,
stderr_max_bytes: BROWSER_STDERR_MAX_BYTES,
timeout: Duration::from_secs(BROWSER_TIMEOUT_SECS),
poll_interval: Duration::from_millis(20),
},
cancellation,
)?;
let stdout = output.stdout;
let stderr = output.stderr;
let cleanup_warning = output.cleanup_warning;
let stdout_truncated = output.stdout_truncated;
let stderr_truncated = output.stderr_truncated;
let exit_code = if output.timed_out {
None
} else {
output.status.as_ref().and_then(|status| status.code())
};
let mut metadata = self.browser_metadata_base(
built,
transport,
exit_code,
output.timed_out,
cleanup_warning.clone(),
);
metadata[meta::STDOUT_TRUNCATED] = json!(stdout_truncated);
metadata[meta::STDERR_TRUNCATED] = json!(stderr_truncated);
metadata[meta::STDOUT_LIMIT_BYTES] = json!(BROWSER_STDOUT_MAX_BYTES);
metadata[meta::STDERR_LIMIT_BYTES] = json!(BROWSER_STDERR_MAX_BYTES);
metadata[meta::TRUNCATED] = json!(stdout_truncated || stderr_truncated);
let raw_stdout = stdout.trim();
let raw_stderr = stderr.trim();
let status_success = output
.status
.as_ref()
.is_some_and(|status| status.success());
let success = status_success
&& !output.timed_out
&& !stdout_truncated
&& !stderr_truncated
&& cleanup_warning.is_none();
let mut content = if success {
browser_success_content(&built.action, raw_stdout, &mut metadata)
} else if output.timed_out {
"dev-browser command timed out".to_string()
} else if stdout_truncated || stderr_truncated {
format!(
"dev-browser output truncated: stdout limit {BROWSER_STDOUT_MAX_BYTES} bytes, stderr limit {BROWSER_STDERR_MAX_BYTES} bytes"
)
} else if !raw_stderr.is_empty() {
raw_stderr.to_string()
} else if !raw_stdout.is_empty() {
raw_stdout.to_string()
} else if let Some(code) = exit_code {
format!("Command failed with exit code {code}")
} else {
"dev-browser command failed".to_string()
};
if let Some(warning) = cleanup_warning
&& !success
{
content.push_str("\n[");
content.push_str(&warning);
content.push(']');
}
Ok(ToolResult {
tool_name: tool_name::BROWSER.to_string(),
success,
content,
metadata,
display: ToolResultDisplay::default(),
})
}
fn browser_metadata_base(
&self,
built: &BuiltBrowserCommand,
transport: &BrowserTransport,
exit_code: Option<i32>,
timed_out: bool,
cleanup_warning: Option<String>,
) -> serde_json::Value {
json!({
(meta::ACTION): built.action,
(meta::COMMAND): built.command,
(meta::MODE): transport.mode.as_str(),
(meta::CONNECT_URL): transport.connect_url.as_deref().map(sanitize_connect_url),
(meta::IGNORE_HTTPS_ERRORS): transport.ignore_https_errors,
(meta::EXIT_CODE): exit_code,
(meta::TIMED_OUT): timed_out,
(meta::CLEANUP_WARNING): cleanup_warning,
})
}
}
impl BrowserTransport {
fn from_args(args: &BrowserArgs) -> Self {
let mode = args.mode.unwrap_or(DEFAULT_BROWSER_MODE);
Self {
mode,
connect_url: args.connect_url.clone(),
ignore_https_errors: args.ignore_https_errors.unwrap_or(false),
}
}
}
impl BrowserModeArg {
fn as_str(self) -> &'static str {
match self {
Self::Headless => "headless",
Self::Headed => "headed",
Self::Connect => "connect",
}
}
}
fn browser_failure_result(
action: &str,
mode: &BrowserModeArg,
metadata: &serde_json::Value,
content: String,
) -> ToolResult {
let mut metadata = metadata.clone();
metadata[meta::ACTION] = json!(action);
metadata[meta::MODE] = json!(mode.as_str());
ToolResult {
tool_name: tool_name::BROWSER.to_string(),
success: false,
content,
metadata,
display: ToolResultDisplay::default(),
}
}
fn merge_cleanup_warning(result: &mut ToolResult, extra_warning: Option<String>) {
let Some(extra_warning) = extra_warning else {
return;
};
let existing = result
.metadata
.get(meta::CLEANUP_WARNING)
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty());
let merged = match existing {
Some(existing) => format!("{existing}; {extra_warning}"),
None => extra_warning,
};
result.metadata[meta::CLEANUP_WARNING] = json!(merged);
if !result.content.is_empty() {
result.content.push('\n');
}
result.content.push('[');
result.content.push_str(
result.metadata[meta::CLEANUP_WARNING]
.as_str()
.unwrap_or(""),
);
result.content.push(']');
}
fn browser_success_content(action: &str, stdout: &str, metadata: &mut serde_json::Value) -> String {
let output = if stdout.is_empty() {
"(no output)"
} else {
stdout
};
match action {
"snapshot" => transform_snapshot_output(output),
"screenshot" => {
if let Some(path) = extract_screenshot_path(output) {
metadata[meta::SCREENSHOT_PATH] = json!(path);
format!("Screenshot saved: {path}")
} else {
output.to_string()
}
}
_ => output.to_string(),
}
}
fn build_dev_browser_args(
transport: &BrowserTransport,
browser_name: &str,
script_path: &Path,
) -> Vec<String> {
let mut args = vec![
"--browser".to_string(),
browser_name.to_string(),
"--timeout".to_string(),
BROWSER_TIMEOUT_SECS.to_string(),
];
match transport.mode {
BrowserModeArg::Headless => args.push("--headless".to_string()),
BrowserModeArg::Headed => {}
BrowserModeArg::Connect => {
args.push("--connect".to_string());
if let Some(connect_url) = transport.connect_url.as_ref() {
args.push(connect_url.clone());
}
}
}
if transport.mode != BrowserModeArg::Connect && transport.ignore_https_errors {
args.push("--ignore-https-errors".to_string());
}
args.push("run".to_string());
args.push(script_path.to_string_lossy().to_string());
args
}
fn tokenize_command(input: &str) -> anyhow::Result<Vec<String>> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
let mut token_started = false;
for character in input.trim().chars() {
if escaped {
current.push(character);
escaped = false;
token_started = true;
continue;
}
if character == '\\' && quote != Some('\'') {
escaped = true;
token_started = true;
continue;
}
if let Some(active_quote) = quote {
if character == active_quote {
quote = None;
} else {
current.push(character);
}
token_started = true;
continue;
}
if character == '"' || character == '\'' {
quote = Some(character);
token_started = true;
continue;
}
if character.is_whitespace() {
if token_started {
tokens.push(std::mem::take(&mut current));
token_started = false;
}
continue;
}
current.push(character);
token_started = true;
}
if escaped {
current.push('\\');
}
if quote.is_some() {
anyhow::bail!("unmatched quote in browser command argument");
}
if token_started {
tokens.push(current);
}
Ok(tokens)
}
fn parse_command(command: &str) -> anyhow::Result<ParsedBrowserCommand> {
let tokens = tokenize_command(command)?;
let Some((action, args)) = tokens.split_first() else {
anyhow::bail!("Browser command cannot be empty");
};
Ok(ParsedBrowserCommand {
action: action.to_ascii_lowercase(),
args: args.to_vec(),
})
}
fn build_command(
parsed: &ParsedBrowserCommand,
browser_name: &str,
) -> anyhow::Result<BuiltBrowserCommand> {
let args = parsed.args.as_slice();
let action = parsed.action.as_str();
let script = match action {
"open" => {
expect_arg_count(args, &[1], "Usage: open <url>")?;
format!(
"const page = await browser.getPage({page});\nawait page.goto({url}, {{ waitUntil: \"domcontentloaded\" }});\nconsole.log(JSON.stringify({{\n url: page.url(),\n title: await page.title(),\n}}, null, 2));",
page = json_string(PAGE_NAME),
url = json_string(&args[0])
)
}
"snapshot" => {
expect_arg_count(args, &[0, 1], "Usage: snapshot [-i]")?;
if args.first().is_some_and(|arg| arg != "-i") {
anyhow::bail!("Usage: snapshot [-i]");
}
format!(
"const page = await browser.getPage({page});\nconst snapshot = await page.snapshotForAI({{ track: {page} }});\nconsole.log(snapshot.full);",
page = json_string(PAGE_NAME)
)
}
"click" => {
expect_arg_count(args, &[1], "Usage: click <@ref>")?;
let reference = to_ref(&args[0])?;
format!(
"const page = await browser.getPage({page});\nawait {locator}.click();\nawait page.waitForTimeout(250);\nconsole.log({message});",
page = json_string(PAGE_NAME),
locator = locator_script(&reference),
message = json_string(&format!("Clicked @{reference}"))
)
}
"fill" => {
if args.len() < 2 {
anyhow::bail!("Usage: fill <@ref> <text>");
}
let reference = to_ref(&args[0])?;
let text = args[1..].join(" ");
format!(
"const page = await browser.getPage({page});\nawait {locator}.fill({text});\nawait page.waitForTimeout(150);\nconsole.log({message});",
page = json_string(PAGE_NAME),
locator = locator_script(&reference),
text = json_string(&text),
message = json_string(&format!("Filled @{reference}"))
)
}
"type" => {
if args.len() < 2 {
anyhow::bail!("Usage: type <@ref> <text>");
}
let reference = to_ref(&args[0])?;
let text = args[1..].join(" ");
format!(
"const page = await browser.getPage({page});\nawait {locator}.type({text});\nawait page.waitForTimeout(150);\nconsole.log({message});",
page = json_string(PAGE_NAME),
locator = locator_script(&reference),
text = json_string(&text),
message = json_string(&format!("Typed into @{reference}"))
)
}
"select" => {
if args.len() < 2 {
anyhow::bail!("Usage: select <@ref> <value>");
}
let reference = to_ref(&args[0])?;
let value = args[1..].join(" ");
format!(
"const page = await browser.getPage({page});\nconst locator = {locator};\nlet selected = false;\ntry {{\n await locator.selectOption({{ label: {value} }});\n selected = true;\n}} catch {{\n}}\nif (!selected) {{\n await locator.selectOption({value});\n}}\nawait page.waitForTimeout(150);\nconsole.log({message});",
page = json_string(PAGE_NAME),
locator = locator_script(&reference),
value = json_string(&value),
message = json_string(&format!("Selected {value} on @{reference}"))
)
}
"press" => {
expect_arg_count(args, &[1], "Usage: press <key>")?;
format!(
"const page = await browser.getPage({page});\nawait page.keyboard.press({key});\nawait page.waitForTimeout(150);\nconsole.log({message});",
page = json_string(PAGE_NAME),
key = json_string(&args[0]),
message = json_string(&format!("Pressed {}", args[0]))
)
}
"scroll" => build_scroll_script(args)?,
"get" => build_get_script(args)?,
"wait" => build_wait_script(args)?,
"screenshot" => {
expect_arg_count(args, &[0, 1], "Usage: screenshot [--full]")?;
if args.first().is_some_and(|arg| arg != "--full") {
anyhow::bail!("Usage: screenshot [--full]");
}
let full_page = args.first().is_some_and(|arg| arg == "--full");
let file_name = format!("{browser_name}-${{Date.now()}}.png");
format!(
"const page = await browser.getPage({page});\nconst buffer = await page.screenshot({{ fullPage: {full_page} }});\nconst path = await saveScreenshot(buffer, {file_name});\nconsole.log(path);",
page = json_string(PAGE_NAME),
full_page = if full_page { "true" } else { "false" },
file_name = json_string(&file_name)
)
}
"close" => {
expect_arg_count(args, &[0], "Usage: close")?;
format!(
"const pages = await browser.listPages();\nconst main = pages.find((entry) => entry.name === {page});\nif (main) {{\n await browser.closePage({page});\n}}\nconsole.log({message});",
page = json_string(PAGE_NAME),
message = json_string("Closed browser page")
)
}
_ => anyhow::bail!("Unsupported browser command: {action}"),
};
Ok(BuiltBrowserCommand {
action: action.to_string(),
command: std::iter::once(action.to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>()
.join(" "),
script,
})
}
fn build_scroll_script(args: &[String]) -> anyhow::Result<String> {
expect_arg_count(args, &[1, 2], "Usage: scroll <up|down|left|right> [px]")?;
let direction = args[0].to_ascii_lowercase();
if !matches!(direction.as_str(), "up" | "down" | "left" | "right") {
anyhow::bail!("Scroll direction must be up, down, left, or right");
}
let pixels = args.get(1).map_or(Ok(500_u64), |value| {
parse_positive_integer(value, "Scroll pixels must be a positive integer")
.map(|pixels| pixels.min(i32::MAX as u64))
})?;
Ok(format!(
"const page = await browser.getPage({page});\nconst position = await page.evaluate(({{ direction, pixels }}) => {{\n let x = 0;\n let y = 0;\n\n if (direction === \"down\") y = pixels;\n if (direction === \"up\") y = -pixels;\n if (direction === \"right\") x = pixels;\n if (direction === \"left\") x = -pixels;\n\n window.scrollBy(x, y);\n return {{ x: window.scrollX, y: window.scrollY }};\n}}, {{ direction: {direction}, pixels: {pixels} }});\nconsole.log(JSON.stringify(position, null, 2));",
page = json_string(PAGE_NAME),
direction = json_string(&direction)
))
}
fn build_get_script(args: &[String]) -> anyhow::Result<String> {
if args.is_empty() || args.len() > 2 {
anyhow::bail!("Usage: get text|url|title [@ref]");
}
match args[0].to_ascii_lowercase().as_str() {
"url" => {
expect_arg_count(args, &[1], "Usage: get url")?;
Ok(format!(
"const page = await browser.getPage({});\nconsole.log(page.url());",
json_string(PAGE_NAME)
))
}
"title" => {
expect_arg_count(args, &[1], "Usage: get title")?;
Ok(format!(
"const page = await browser.getPage({});\nconsole.log(await page.title());",
json_string(PAGE_NAME)
))
}
"text" => {
if let Some(reference) = args.get(1) {
let reference = to_ref(reference)?;
Ok(format!(
"const page = await browser.getPage({page});\nconst text = await {locator}.innerText();\nconsole.log(text ?? \"\");",
page = json_string(PAGE_NAME),
locator = locator_script(&reference)
))
} else {
Ok(format!(
"const page = await browser.getPage({});\nconsole.log(await page.locator(\"body\").innerText());",
json_string(PAGE_NAME)
))
}
}
_ => anyhow::bail!("Usage: get text|url|title [@ref]"),
}
}
fn build_wait_script(args: &[String]) -> anyhow::Result<String> {
expect_arg_count(args, &[1], "Usage: wait <@ref|ms>")?;
if args[0].chars().all(|character| character.is_ascii_digit()) {
let ms = parse_positive_integer(&args[0], "Wait milliseconds must be a positive integer")?
.min(BROWSER_TIMEOUT_SECS * 1000);
return Ok(format!(
"const page = await browser.getPage({page});\nawait page.waitForTimeout({ms});\nconsole.log({message});",
page = json_string(PAGE_NAME),
message = json_string(&format!("Waited {ms}ms"))
));
}
let reference = to_ref(&args[0])?;
Ok(format!(
"const page = await browser.getPage({page});\nawait {locator}.waitFor({{ state: \"visible\" }});\nconsole.log({message});",
page = json_string(PAGE_NAME),
locator = locator_script(&reference),
message = json_string(&format!("Waited for @{reference}"))
))
}
fn expect_arg_count(args: &[String], counts: &[usize], usage: &str) -> anyhow::Result<()> {
if counts.contains(&args.len()) {
Ok(())
} else {
anyhow::bail!(usage.to_string())
}
}
fn to_ref(token: &str) -> anyhow::Result<String> {
let normalized = token.strip_prefix('@').unwrap_or(token);
let valid = normalized.len() >= 2
&& normalized.starts_with(['e', 'E'])
&& normalized[1..]
.chars()
.all(|character| character.is_ascii_digit());
if !valid {
anyhow::bail!("Invalid ref: {token}");
}
Ok(normalized.to_ascii_lowercase())
}
fn parse_positive_integer(value: &str, message: &str) -> anyhow::Result<u64> {
if value.is_empty() || !value.chars().all(|character| character.is_ascii_digit()) {
anyhow::bail!(message.to_string());
}
let parsed = value.parse::<u64>()?;
if parsed == 0 {
anyhow::bail!(message.to_string());
}
Ok(parsed)
}
fn locator_script(reference: &str) -> String {
format!(
"page.locator({})",
json_string(&format!("aria-ref={reference}"))
)
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("string serialization cannot fail")
}
fn transform_snapshot_output(output: &str) -> String {
static REF_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
REF_REGEX
.get_or_init(|| Regex::new(r"(?i)\[ref=(e\d+)\]").unwrap())
.replace_all(output, |captures: ®ex::Captures<'_>| {
format!("@{}", captures[1].to_ascii_lowercase())
})
.into_owned()
}
fn extract_screenshot_path(output: &str) -> Option<String> {
output
.lines()
.rev()
.map(str::trim)
.find(|line| {
let lower = line.to_ascii_lowercase();
[".png", ".jpg", ".jpeg", ".webp"]
.iter()
.any(|suffix| lower.ends_with(suffix))
})
.map(ToString::to_string)
}
fn sanitize_connect_url(url: &str) -> String {
let (prefix, rest) = url
.split_once("://")
.map_or(("", url), |(scheme, rest)| (scheme, rest));
let (authority_and_path, query) = rest
.split_once('?')
.map_or((rest, None), |(base, _)| (base, Some("?<redacted>")));
let sanitized_base = if let Some((userinfo, host)) = authority_and_path.rsplit_once('@') {
if userinfo.contains('/') {
authority_and_path.to_string()
} else {
format!("<redacted>@{host}")
}
} else {
authority_and_path.to_string()
};
let mut out = if prefix.is_empty() {
sanitized_base
} else {
format!("{prefix}://{sanitized_base}")
};
if let Some(query) = query {
out.push_str(query);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolRuntime;
use serde_json::json;
use std::{
fs,
sync::Arc,
sync::atomic::{AtomicBool, Ordering},
thread,
time::Duration,
};
#[test]
fn browser_args_validate_transport_defaults_and_rejections() {
let defaulted: BrowserArgs =
serde_json::from_value(json!({"command":"open https://example.com"})).unwrap();
let defaulted = defaulted.validate().unwrap();
assert_eq!(defaulted.mode, None);
let connect_with_url: BrowserArgs = serde_json::from_value(
json!({"command":"snapshot", "mode":"connect", "connectUrl":"http://localhost:9222"}),
)
.unwrap();
assert!(connect_with_url.validate().is_ok());
let connect_without_mode: BrowserArgs = serde_json::from_value(
json!({"command":"snapshot", "connectUrl":"http://localhost:9222"}),
)
.unwrap();
assert!(connect_without_mode.validate().is_err());
let connect_without_url: BrowserArgs =
serde_json::from_value(json!({"command":"snapshot", "mode":"connect"})).unwrap();
assert_eq!(
connect_without_url.validate().unwrap_err().to_string(),
"connectUrl is required when mode is 'connect'"
);
for connect_url in ["--flag", "http:///path", "ftp://localhost:21"] {
let invalid: BrowserArgs = serde_json::from_value(
json!({"command":"snapshot", "mode":"connect", "connectUrl":connect_url}),
)
.unwrap();
assert_eq!(
invalid.validate().unwrap_err().to_string(),
"connectUrl must be an http(s) or ws(s) URL with a host"
);
}
let ignore_with_connect: BrowserArgs = serde_json::from_value(
json!({"command":"snapshot", "mode":"connect", "connectUrl":"ws://localhost:9222", "ignoreHttpsErrors":true}),
)
.unwrap();
assert!(ignore_with_connect.validate().is_err());
let unknown =
serde_json::from_value::<BrowserArgs>(json!({"command":"snapshot", "extra":true}));
assert!(unknown.is_err());
let empty: BrowserArgs = serde_json::from_value(json!({"command":" "})).unwrap();
assert!(empty.validate().is_err());
}
#[test]
fn parser_accepts_documented_commands() {
for command in [
"open https://example.com",
"snapshot",
"snapshot -i",
"click @e12",
"fill @e12 hello",
"type @e12 hello",
"select @e12 value",
"press Enter",
"scroll down",
"scroll left 250",
"get title",
"get url",
"get text",
"get text @e12",
"wait @e12",
"wait 250",
"screenshot",
"screenshot --full",
"close",
] {
let parsed = parse_command(command).unwrap();
build_command(&parsed, "magi-code-browser-test")
.unwrap_or_else(|error| panic!("{command} failed: {error}"));
}
}
#[test]
fn parser_accepts_quoted_text_and_normalizes_refs() {
let parsed = parse_command("fill @E12 \"hello world\"").unwrap();
let built = build_command(&parsed, "magi-code-browser-test").unwrap();
assert_eq!(built.action, "fill");
assert!(built.script.contains("aria-ref=e12"));
assert!(built.script.contains("hello world"));
let typed = parse_command("type e12 'single quoted'").unwrap();
let built = build_command(&typed, "magi-code-browser-test").unwrap();
assert!(built.script.contains("single quoted"));
let empty = parse_command("fill @e12 \"\"").unwrap();
assert_eq!(empty.args, vec!["@e12".to_string(), String::new()]);
let built = build_command(&empty, "magi-code-browser-test").unwrap();
assert!(built.script.contains("fill(\"\")"));
}
#[test]
fn parser_rejects_malformed_commands() {
assert_eq!(
parse_command("fill @e12 \"unterminated")
.unwrap_err()
.to_string(),
"unmatched quote in browser command argument"
);
for command in [
"snapshot foo",
"close now",
"screenshot --ful",
"get title now",
"scroll down 100 nope",
"scroll down 100abc",
"click bad",
] {
let parsed = parse_command(command).unwrap();
assert!(
build_command(&parsed, "magi-code-browser-test").is_err(),
"{command}"
);
}
}
#[test]
fn transforms_snapshot_refs_and_extracts_screenshot_path() {
assert_eq!(transform_snapshot_output("button [ref=E12]"), "button @e12");
assert_eq!(
extract_screenshot_path("ignored\n/tmp/one.txt\n/tmp/final.PNG").as_deref(),
Some("/tmp/final.PNG")
);
}
#[test]
fn generated_scripts_json_quote_user_text() {
let parsed = parse_command("fill @e1 \"a\\\"; throw new Error()\"").unwrap();
let built = build_command(&parsed, "magi-code-browser-test").unwrap();
assert!(built.script.contains(r#""a\"; throw new Error()""#));
}
#[test]
fn generated_scripts_clamp_numeric_args() {
let wait = parse_command("wait 999999999999").unwrap();
let built = build_command(&wait, "magi-code-browser-test").unwrap();
let max_wait_ms = BROWSER_TIMEOUT_SECS * 1000;
assert!(
built
.script
.contains(&format!("waitForTimeout({max_wait_ms})"))
);
assert!(built.script.contains(&format!("Waited {max_wait_ms}ms")));
let scroll = parse_command("scroll down 999999999999").unwrap();
let built = build_command(&scroll, "magi-code-browser-test").unwrap();
assert!(built.script.contains(&format!("pixels: {}", i32::MAX)));
}
#[cfg(unix)]
#[test]
fn temp_script_dir_uses_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp = TempScriptDir::create("console.log('ok');").unwrap();
let mode = fs::metadata(&temp.dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
assert!(temp.script_path().exists());
assert!(temp.cleanup().is_none());
}
#[cfg(unix)]
#[test]
fn runtime_fake_dev_browser_receives_expected_args_and_cleans_temp() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
let log = temp.path().join("args.log");
fs::write(
&fake,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\nscript=\nfor arg do script=\"$arg\"; done\nprintf '%s\\n' \"$script\" > {}\nprintf '/tmp/screen.png\\n'\n",
log.display(),
temp.path().join("script-path.log").display()
),
)
.unwrap();
make_executable(&fake);
let runtime = ToolRuntime::new(temp.path()).unwrap();
let result = runtime
.browser_with_bin(
BrowserArgs {
command: "screenshot --full".to_string(),
mode: Some(BrowserModeArg::Headed),
connect_url: None,
ignore_https_errors: Some(true),
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "Screenshot saved: /tmp/screen.png");
let args = fs::read_to_string(&log).unwrap();
assert!(args.contains("--browser\n"));
assert!(args.contains("--timeout\n60\n"));
assert!(args.contains("--ignore-https-errors\n"));
assert!(args.contains("run\n"));
let script_path = fs::read_to_string(temp.path().join("script-path.log")).unwrap();
assert!(!Path::new(script_path.trim()).exists());
}
#[cfg(unix)]
#[test]
fn runtime_fake_dev_browser_does_not_inherit_credential_env() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(
&fake,
"#!/bin/sh\nprintf '%s' \"$MAGI_CODE_TEST_ENV_PROBE_BROWSER\"\n",
)
.unwrap();
make_executable(&fake);
let runtime = ToolRuntime::new(temp.path()).unwrap();
unsafe { std::env::set_var("MAGI_CODE_TEST_ENV_PROBE_BROWSER", "leaked") };
let result = runtime
.browser_with_bin(
BrowserArgs {
command: "snapshot".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
unsafe { std::env::remove_var("MAGI_CODE_TEST_ENV_PROBE_BROWSER") };
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "(no output)");
}
#[cfg(unix)]
#[test]
fn runtime_non_zero_exit_is_recoverable_failure() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(&fake, "#!/bin/sh\necho failed >&2\nexit 7\n").unwrap();
make_executable(&fake);
let runtime = ToolRuntime::new(temp.path()).unwrap();
let result = runtime
.browser_with_bin(
BrowserArgs {
command: "open https://example.com".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(!result.success);
assert_eq!(result.content, "failed");
assert_eq!(result.metadata[meta::EXIT_CODE], 7);
}
#[cfg(unix)]
#[test]
fn runtime_stdout_over_cap_marks_truncated() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(&fake, "#!/bin/sh\nyes x | head -c 70000\n").unwrap();
make_executable(&fake);
let runtime = ToolRuntime::new(temp.path()).unwrap();
let result = runtime
.browser_with_bin(
BrowserArgs {
command: "snapshot".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(!result.success);
assert!(result.content.contains("dev-browser output truncated"));
assert_eq!(result.metadata[meta::STDOUT_TRUNCATED], true);
assert_eq!(result.metadata[meta::TRUNCATED], true);
assert_eq!(
result.metadata[meta::STDOUT_LIMIT_BYTES],
BROWSER_STDOUT_MAX_BYTES
);
}
#[cfg(unix)]
#[test]
fn runtime_tracks_page_open_and_close_state() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(&fake, "#!/bin/sh\necho ok\n").unwrap();
make_executable(&fake);
let runtime = ToolRuntime::new(temp.path()).unwrap();
let opened = runtime
.browser_with_bin(
BrowserArgs {
command: "open https://example.com".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(opened.success, "{}", opened.content);
{
let state = runtime.browser_state.lock().unwrap();
assert!(state.used_browser);
assert!(state.page_open);
}
let closed = runtime
.browser_with_bin(
BrowserArgs {
command: "close".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(closed.success, "{}", closed.content);
let state = runtime.browser_state.lock().unwrap();
assert!(state.used_browser);
assert!(!state.page_open);
}
#[cfg(unix)]
#[test]
fn runtime_recovers_from_poisoned_browser_state_mutex() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(&fake, "#!/bin/sh\necho ok\n").unwrap();
make_executable(&fake);
let runtime = Arc::new(ToolRuntime::new(temp.path()).unwrap());
let state = Arc::clone(&runtime.browser_state);
thread::spawn(move || {
let _guard = state.lock().unwrap();
panic!("poison browser state mutex");
})
.join()
.unwrap_err();
let result = runtime
.browser_with_bin(
BrowserArgs {
command: "open https://example.com".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "{}", result.content);
let state = runtime
.browser_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(state.used_browser);
assert!(state.page_open);
}
#[cfg(unix)]
#[test]
fn runtime_recovers_from_poisoned_browser_operation_mutex() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(&fake, "#!/bin/sh\necho ok\n").unwrap();
make_executable(&fake);
let runtime = Arc::new(ToolRuntime::new(temp.path()).unwrap());
let operation_lock = Arc::clone(&runtime.browser_operation_lock);
thread::spawn(move || {
let _guard = operation_lock.lock().unwrap();
panic!("poison browser operation mutex");
})
.join()
.unwrap_err();
let result = runtime
.browser_with_bin(
BrowserArgs {
command: "snapshot".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "{}", result.content);
}
#[cfg(unix)]
#[test]
fn runtime_concurrent_calls_do_not_hold_browser_state_mutex_across_process() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
let log = temp.path().join("order.log");
fs::write(
&fake,
format!(
"#!/bin/sh\nprintf 'start\\n' >> {}\nsleep 0.2\nprintf 'end\\n' >> {}\necho ok\n",
log.display(),
log.display()
),
)
.unwrap();
make_executable(&fake);
let runtime = Arc::new(ToolRuntime::new(temp.path()).unwrap());
let handles = (0..2)
.map(|_| {
let runtime = Arc::clone(&runtime);
let fake = fake.clone();
thread::spawn(move || {
runtime
.browser_with_bin(
BrowserArgs {
command: "snapshot".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap()
})
})
.collect::<Vec<_>>();
for handle in handles {
let result = handle.join().unwrap();
assert!(result.success, "{}", result.content);
}
let order = fs::read_to_string(log).unwrap();
assert_eq!(
order.lines().collect::<Vec<_>>(),
vec!["start", "end", "start", "end"]
);
}
#[cfg(unix)]
#[test]
fn runtime_cancellation_returns_prompt_canceled() {
let temp = tempfile::TempDir::new().unwrap();
let fake = temp.path().join("dev-browser");
fs::write(&fake, "#!/bin/sh\nsleep 2\n").unwrap();
make_executable(&fake);
let runtime = ToolRuntime::new(temp.path()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let cancellation = AgentCancellation::new(Arc::clone(&cancel));
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
cancel.store(true, Ordering::SeqCst);
});
let result = runtime.browser_with_bin(
BrowserArgs {
command: "snapshot".to_string(),
mode: None,
connect_url: None,
ignore_https_errors: None,
},
&fake,
&cancellation,
);
assert!(result.unwrap_err().to_string().contains("prompt canceled"));
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
}