use std::cmp::Reverse;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
use crate::error::{Error, Result};
use crate::tools::{Tool, ToolContext, ToolRegistry};
pub const BROWSER_PROVIDER_PROTOCOL: &str = "supercode/browser-provider-v1";
pub const BROWSER_OPERATION_PROTOCOL: &str = "supercode/browser-operation-v1";
pub const BROWSER_PROVIDER_MAX_REQUEST_BYTES: usize = 256 * 1024;
pub const BROWSER_PROVIDER_MAX_RESPONSE_BYTES: usize = 1024 * 1024;
pub const BROWSER_PROVIDER_TIMEOUT: Duration = Duration::from_secs(12);
#[derive(Debug, Clone)]
pub struct BrowserOperationDefinition {
pub name: &'static str,
pub cli_name: &'static str,
pub description: &'static str,
pub mutates_page: bool,
pub permission: &'static str,
pub input_schema: Value,
}
fn locator_schema() -> Value {
json!({
"oneOf": [
{"type":"object","properties":{"by":{"const":"css"},"value":{"type":"string"}},"required":["by","value"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"ref"},"value":{"type":"string"}},"required":["by","value"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"role"},"role":{"type":"string"},"name":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","role"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"text"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"testId"},"value":{"type":"string"}},"required":["by","value"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"label"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"placeholder"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"altText"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
{"type":"object","properties":{"by":{"const":"title"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false}
]
})
}
fn endpoint_schema() -> Value {
json!({
"oneOf": [
{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}},"required":["x","y"],"additionalProperties":false},
{"type":"object","properties":{"locator": locator_schema()},"required":["locator"],"additionalProperties":false}
]
})
}
fn target_properties() -> serde_json::Map<String, Value> {
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
("locator".into(), locator_schema()),
("index".into(), json!({"type":"integer","minimum":0})),
(
"expectedRevision".into(),
json!({"type":"integer","minimum":0}),
),
])
}
pub fn browser_operation_registry() -> Vec<BrowserOperationDefinition> {
let empty = || json!({"type":"object","properties":{},"additionalProperties":false});
let object = |properties: serde_json::Map<String, Value>, required: &[&str]| {
json!({
"type":"object",
"properties": properties,
"required": required,
"additionalProperties": false
})
};
vec![
BrowserOperationDefinition {
name: "browser.status",
cli_name: "status",
description: "Report the available browser provider and its active page fidelity.",
mutates_page: false,
permission: "browser.read",
input_schema: empty(),
},
BrowserOperationDefinition {
name: "browser.snapshot",
cli_name: "snapshot",
description: "Return a bounded accessibility snapshot with stable page-local refs.",
mutates_page: false,
permission: "browser.read",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
("locator".into(), locator_schema()),
]),
&[],
),
},
BrowserOperationDefinition {
name: "browser.query",
cli_name: "query",
description: "Resolve a CSS, accessibility-ref, role, text or test-id locator.",
mutates_page: false,
permission: "browser.read",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.wait",
cli_name: "wait",
description: "Wait for a locator to become attached or visible.",
mutates_page: false,
permission: "browser.read",
input_schema: {
let mut properties = target_properties();
properties.insert("state".into(), json!({"enum":["attached","visible"]}));
properties.insert(
"timeout".into(),
json!({"type":"number","minimum":0,"maximum":30000}),
);
object(properties, &["locator"])
},
},
BrowserOperationDefinition {
name: "browser.click",
cli_name: "click",
description: "Click a page locator through the selected browser provider.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.fill",
cli_name: "fill",
description: "Fill an input, textarea or contenteditable locator.",
mutates_page: true,
permission: "browser.interact",
input_schema: {
let mut properties = target_properties();
properties.insert("value".into(), json!({"type":"string"}));
object(properties, &["locator", "value"])
},
},
BrowserOperationDefinition {
name: "browser.press",
cli_name: "press",
description: "Dispatch one keyboard press to a locator or the active element.",
mutates_page: true,
permission: "browser.interact",
input_schema: {
let mut properties = target_properties();
properties.insert("key".into(), json!({"type":"string"}));
object(properties, &["key"])
},
},
BrowserOperationDefinition {
name: "browser.hover",
cli_name: "hover",
description: "Hover a page locator using synthetic DOM pointer semantics.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.focus",
cli_name: "focus",
description: "Focus a page locator.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.check",
cli_name: "check",
description: "Check a checkbox or radio locator.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.uncheck",
cli_name: "uncheck",
description: "Uncheck a checkbox locator.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.select",
cli_name: "select",
description: "Select one or more options by value or label.",
mutates_page: true,
permission: "browser.interact",
input_schema: {
let mut properties = target_properties();
properties.insert(
"values".into(),
json!({"type":"array","items":{"type":"string"},"maxItems":100}),
);
object(properties, &["locator", "values"])
},
},
BrowserOperationDefinition {
name: "browser.scroll",
cli_name: "scroll",
description: "Scroll the selected page in one direction by a bounded amount.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
(
"direction".into(),
json!({"enum":["up","down","left","right"]}),
),
(
"amount".into(),
json!({"type":"number","minimum":1,"maximum":10000}),
),
(
"expectedRevision".into(),
json!({"type":"integer","minimum":0}),
),
]),
&["direction"],
),
},
BrowserOperationDefinition {
name: "browser.script",
cli_name: "script",
description: "Run an author-written Playwright script against the provider's page. `page` is the shared in-page Playwright shim; `args` is passed alongside it; the returned value must be JSON-serializable.",
mutates_page: true,
permission: "browser.script",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
(
"source".into(),
json!({"type":"string","minLength":1,"maxLength":100000}),
),
("args".into(), json!({"type":"object"})),
(
"timeout".into(),
json!({"type":"number","minimum":0,"maximum":120000}),
),
]),
&["source"],
),
},
BrowserOperationDefinition {
name: "browser.box",
cli_name: "box",
description: "Return a locator's bounding box in CSS pixels, for pointer work on canvases and free-form surfaces.",
mutates_page: false,
permission: "browser.read",
input_schema: object(target_properties(), &["locator"]),
},
BrowserOperationDefinition {
name: "browser.mouse",
cli_name: "mouse",
description: "Move, press, release, or click the pointer at viewport coordinates.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
("action".into(), json!({"enum":["move","down","up","click"]})),
("x".into(), json!({"type":"number"})),
("y".into(), json!({"type":"number"})),
]),
&["action"],
),
},
BrowserOperationDefinition {
name: "browser.drag",
cli_name: "drag",
description: "Press, move, and release the pointer from one point or locator to another.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
("from".into(), endpoint_schema()),
("to".into(), endpoint_schema()),
(
"steps".into(),
json!({"type":"integer","minimum":1,"maximum":100}),
),
]),
&["from", "to"],
),
},
BrowserOperationDefinition {
name: "browser.wheel",
cli_name: "wheel",
description: "Dispatch a wheel event at the pointer position.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
("deltaX".into(), json!({"type":"number"})),
("deltaY".into(), json!({"type":"number"})),
]),
&[],
),
},
BrowserOperationDefinition {
name: "browser.back",
cli_name: "back",
description: "Navigate the selected page one entry backward in session history.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
(
"expectedRevision".into(),
json!({"type":"integer","minimum":0}),
),
]),
&[],
),
},
BrowserOperationDefinition {
name: "browser.forward",
cli_name: "forward",
description: "Navigate the selected page one entry forward in session history.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
(
"expectedRevision".into(),
json!({"type":"integer","minimum":0}),
),
]),
&[],
),
},
BrowserOperationDefinition {
name: "browser.reload",
cli_name: "reload",
description: "Reload the selected page.",
mutates_page: true,
permission: "browser.interact",
input_schema: object(
serde_json::Map::from_iter([
("page".into(), json!({"type":"string"})),
(
"expectedRevision".into(),
json!({"type":"integer","minimum":0}),
),
]),
&[],
),
},
]
}
pub fn browser_operation(name: &str) -> Option<BrowserOperationDefinition> {
browser_operation_registry()
.into_iter()
.find(|operation| operation.name == name || operation.cli_name == name)
}
pub fn browser_provider_directory() -> PathBuf {
let root = std::env::var_os("SUPERCODE_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("XDG_CONFIG_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(|path| path.join("supercode"))
})
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|path| path.join(".config").join("supercode"))
})
.unwrap_or_else(|| std::env::temp_dir().join("supercode"));
root.join("providers").join("browser")
}
#[derive(Debug, Clone, Deserialize)]
struct ProviderIdentity {
id: String,
name: String,
#[serde(default)]
fidelity: Value,
}
#[derive(Debug, Clone, Deserialize)]
struct ProviderDiscovery {
protocol: String,
workspace: String,
host: String,
port: u16,
token: String,
provider: ProviderIdentity,
}
#[derive(Debug)]
struct DiscoveryCandidate {
discovery: ProviderDiscovery,
modified: SystemTime,
}
fn canonical_workspace(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(unix)]
fn owner_only(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o077 == 0
}
#[cfg(not(unix))]
fn owner_only(_metadata: &std::fs::Metadata) -> bool {
true
}
fn discovery_candidates(workspace: &Path) -> Vec<DiscoveryCandidate> {
let canonical = canonical_workspace(workspace);
let Ok(entries) = std::fs::read_dir(browser_provider_directory()) else {
return Vec::new();
};
let mut candidates = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if !metadata.file_type().is_file() || !owner_only(&metadata) {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if bytes.len() > 64 * 1024 {
continue;
}
let Ok(discovery) = serde_json::from_slice::<ProviderDiscovery>(&bytes) else {
continue;
};
if discovery.protocol != BROWSER_PROVIDER_PROTOCOL
|| discovery.host != "127.0.0.1"
|| discovery.token.len() < 32
|| canonical_workspace(Path::new(&discovery.workspace)) != canonical
|| discovery.provider.id.trim().is_empty()
|| discovery.provider.name.trim().is_empty()
{
continue;
}
candidates.push(DiscoveryCandidate {
discovery,
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
});
}
candidates.sort_by_key(|candidate| Reverse(candidate.modified));
candidates
}
fn failure(operation: &str, code: &str, message: impl Into<String>) -> Value {
json!({
"ok": false,
"operation": operation,
"error": {"code": code, "message": message.into()}
})
}
fn validate_input(
operation: &BrowserOperationDefinition,
input: &Value,
) -> std::result::Result<(), String> {
let Some(object) = input.as_object() else {
return Err("browser operation input must be an object".into());
};
let properties = operation
.input_schema
.get("properties")
.and_then(Value::as_object)
.expect("browser registry schemas are object schemas");
if let Some(unknown) = object.keys().find(|key| !properties.contains_key(*key)) {
return Err(format!("unknown input field `{unknown}`"));
}
let required = operation
.input_schema
.get("required")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str);
for field in required {
if !object.contains_key(field) {
return Err(format!("missing required input field `{field}`"));
}
}
if let Some(page) = object.get("page") {
if !page
.as_str()
.is_some_and(|value| !value.is_empty() && value.len() <= 512)
{
return Err("`page` must be a non-empty opaque handle of at most 512 bytes".into());
}
}
if let Some(locator) = object.get("locator") {
validate_locator(locator)?;
}
if let Some(index) = object.get("index") {
if index.as_u64().is_none() {
return Err("`index` must be a non-negative integer".into());
}
}
if let Some(revision) = object.get("expectedRevision") {
if revision.as_u64().is_none() {
return Err("`expectedRevision` must be a non-negative integer".into());
}
}
if operation.name == "browser.fill" && !object.get("value").is_some_and(Value::is_string) {
return Err("`value` must be a string".into());
}
if operation.name == "browser.press"
&& !object
.get("key")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty() && value.len() <= 100)
{
return Err("`key` must be a non-empty string of at most 100 bytes".into());
}
if operation.name == "browser.wait" {
if !matches!(
object.get("state").and_then(Value::as_str),
None | Some("attached" | "visible")
) {
return Err("`state` must be attached or visible".into());
}
if let Some(wait) = object.get("timeout") {
if !wait
.as_f64()
.is_some_and(|value| (0.0..=30_000.0).contains(&value))
{
return Err("`timeout` must be between 0 and 30000".into());
}
}
}
if operation.name == "browser.select"
&& !object
.get("values")
.and_then(Value::as_array)
.is_some_and(|values| values.len() <= 100 && values.iter().all(Value::is_string))
{
return Err("`values` must be an array of at most 100 strings".into());
}
if operation.name == "browser.scroll" {
if !matches!(
object.get("direction").and_then(Value::as_str),
Some("up" | "down" | "left" | "right")
) {
return Err("`direction` must be up, down, left, or right".into());
}
if let Some(amount) = object.get("amount") {
if !amount
.as_f64()
.is_some_and(|value| (1.0..=10_000.0).contains(&value))
{
return Err("`amount` must be between 1 and 10000".into());
}
}
}
Ok(())
}
fn validate_locator(value: &Value) -> std::result::Result<(), String> {
let Some(locator) = value.as_object() else {
return Err("`locator` must be an object".into());
};
let Some(kind) = locator.get("by").and_then(Value::as_str) else {
return Err("`locator.by` is required".into());
};
let allowed: &[&str] = match kind {
"css" | "ref" | "testId" => &["by", "value"],
"role" => &["by", "role", "name", "exact"],
"text" => &["by", "text", "exact"],
_ => return Err(format!("unsupported locator kind `{kind}`")),
};
if let Some(unknown) = locator.keys().find(|key| !allowed.contains(&key.as_str())) {
return Err(format!("unknown locator field `{unknown}`"));
}
let primary = match kind {
"css" | "ref" | "testId" => "value",
"role" => "role",
"text" => "text",
_ => unreachable!(),
};
if !locator
.get(primary)
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty() && value.len() <= 2_000)
{
return Err(format!("`locator.{primary}` must be a non-empty string"));
}
if locator.get("name").is_some_and(|value| !value.is_string())
|| locator
.get("exact")
.is_some_and(|value| !value.is_boolean())
{
return Err("locator `name` must be a string and `exact` must be boolean".into());
}
Ok(())
}
pub async fn call_browser_operation(workspace: &Path, name: &str, input: Value) -> Value {
let Some(operation) = browser_operation(name) else {
return failure(name, "OPERATION_NOT_FOUND", "Unknown browser operation");
};
if let Err(message) = validate_input(&operation, &input) {
return failure(operation.name, "INVALID_INPUT", message);
}
let candidates = discovery_candidates(workspace);
if candidates.is_empty() {
return failure(
operation.name,
"PROVIDER_UNAVAILABLE",
"No browser provider is running for this workspace",
);
}
let mut last_error = "No browser provider answered".to_string();
for candidate in candidates {
match call_provider(&candidate.discovery, &operation, &input).await {
Ok(mut result) => {
if let Some(object) = result.as_object_mut() {
object.insert(
"provider".into(),
json!({
"id": candidate.discovery.provider.id,
"name": candidate.discovery.provider.name,
"fidelity": candidate.discovery.provider.fidelity,
}),
);
}
return result;
}
Err(error) => last_error = error.to_string(),
}
}
failure(operation.name, "PROVIDER_UNAVAILABLE", last_error)
}
async fn call_provider(
discovery: &ProviderDiscovery,
operation: &BrowserOperationDefinition,
input: &Value,
) -> Result<Value> {
let address = format!("{}:{}", discovery.host, discovery.port);
let mut stream = timeout(BROWSER_PROVIDER_TIMEOUT, TcpStream::connect(&address))
.await
.map_err(|_| Error::tool(operation.name, "browser provider connection timed out"))??;
let id = format!("sc-{}", random_hex_16()?);
let request = json!({
"protocol": BROWSER_PROVIDER_PROTOCOL,
"id": id,
"token": discovery.token,
"call": {
"protocol": BROWSER_OPERATION_PROTOCOL,
"operation": operation.name,
"input": input,
}
});
let mut bytes = serde_json::to_vec(&request)?;
bytes.push(b'\n');
if bytes.len() > BROWSER_PROVIDER_MAX_REQUEST_BYTES {
return Err(Error::tool(
operation.name,
"browser provider request exceeds 256 KiB",
));
}
timeout(BROWSER_PROVIDER_TIMEOUT, stream.write_all(&bytes))
.await
.map_err(|_| Error::tool(operation.name, "browser provider write timed out"))??;
let mut response = Vec::new();
let mut bounded = stream.take((BROWSER_PROVIDER_MAX_RESPONSE_BYTES + 1) as u64);
timeout(BROWSER_PROVIDER_TIMEOUT, bounded.read_to_end(&mut response))
.await
.map_err(|_| Error::tool(operation.name, "browser provider response timed out"))??;
if response.len() > BROWSER_PROVIDER_MAX_RESPONSE_BYTES {
return Err(Error::tool(
operation.name,
"browser provider response exceeds 1 MiB",
));
}
let envelope: Value = serde_json::from_slice(&response)?;
if envelope.get("protocol").and_then(Value::as_str) != Some(BROWSER_PROVIDER_PROTOCOL)
|| envelope.get("id").and_then(Value::as_str) != Some(&id)
{
return Err(Error::tool(
operation.name,
"invalid browser provider response envelope",
));
}
let result = envelope
.get("result")
.cloned()
.ok_or_else(|| Error::tool(operation.name, "browser provider response omitted result"))?;
if result.get("ok").and_then(Value::as_bool).is_none()
|| result.get("operation").and_then(Value::as_str) != Some(operation.name)
{
return Err(Error::tool(
operation.name,
"invalid browser provider operation result",
));
}
Ok(result)
}
fn random_hex_16() -> Result<String> {
let mut bytes = [0_u8; 16];
getrandom::getrandom(&mut bytes)
.map_err(|error| Error::Other(format!("browser request id generation failed: {error}")))?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[derive(Clone)]
struct BrowserTool {
operation: BrowserOperationDefinition,
}
#[async_trait]
impl Tool for BrowserTool {
fn name(&self) -> &str {
self.operation.name
}
fn description(&self) -> &str {
self.operation.description
}
fn parameters(&self) -> Value {
self.operation.input_schema.clone()
}
fn structured_output(&self) -> bool {
true
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
Ok(call_browser_operation(&ctx.cwd, self.operation.name, args)
.await
.to_string())
}
}
pub fn register_browser_tools(registry: &mut ToolRegistry) {
for operation in browser_operation_registry() {
registry.register(BrowserTool { operation });
}
}