use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::process::Command;
use crate::output::println_json;
struct Provider {
name: String,
base: String,
auth_header: String,
key_env: String,
key: String,
create_path: String,
list_path: String,
stop_method: String,
stop_path: String,
cdp_fields: Vec<String>,
project_id: Option<String>,
}
fn env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|s| !s.is_empty())
}
fn s(v: &str) -> String {
v.to_string()
}
fn preset(name: &str) -> Result<Provider> {
let p = match name {
"browser-use" | "browseruse" | "bu" => Provider {
name: s("browser-use"),
base: s("https://api.browser-use.com/api/v3"),
auth_header: s("X-Browser-Use-API-Key"),
key_env: s("BROWSER_USE_API_KEY"),
key: String::new(),
create_path: s("/browsers"),
list_path: s("/profiles?pageSize=100&pageNumber=1"),
stop_method: s("PATCH"),
stop_path: s("/browsers/{id}"),
cdp_fields: vec![s("cdpUrl")],
project_id: None,
},
"steel" => Provider {
name: s("steel"),
base: s("https://api.steel.dev/v1"),
auth_header: s("Steel-Api-Key"),
key_env: s("STEEL_API_KEY"),
key: String::new(),
create_path: s("/sessions"),
list_path: s("/sessions"),
stop_method: s("POST"),
stop_path: s("/sessions/{id}/release"),
cdp_fields: vec![s("websocketUrl"), s("connectUrl"), s("cdpUrl")],
project_id: None,
},
"hyperbrowser" => Provider {
name: s("hyperbrowser"),
base: s("https://api.hyperbrowser.ai"),
auth_header: s("x-api-key"),
key_env: s("HYPERBROWSER_API_KEY"),
key: String::new(),
create_path: s("/api/session"),
list_path: s("/api/sessions?status=active"),
stop_method: s("PUT"),
stop_path: s("/api/session/{id}/stop"),
cdp_fields: vec![s("wsEndpoint")],
project_id: None,
},
"browserbase" => Provider {
name: s("browserbase"),
base: s("https://api.browserbase.com/v1"),
auth_header: s("X-BB-API-Key"),
key_env: s("BROWSERBASE_API_KEY"),
key: String::new(),
create_path: s("/sessions"),
list_path: s("/sessions"),
stop_method: s("POST"),
stop_path: s("/sessions/{id}"),
cdp_fields: vec![s("connectUrl")],
project_id: None,
},
other => Provider {
name: s(other),
base: env("BROWSER_CONTROL_CLOUD_API")
.context("custom cloud provider needs BROWSER_CONTROL_CLOUD_API")?,
auth_header: env("BROWSER_CONTROL_CLOUD_AUTH_HEADER").unwrap_or_else(|| s("Authorization")),
key_env: s("BROWSER_CONTROL_CLOUD_API_KEY"),
key: String::new(),
create_path: env("BROWSER_CONTROL_CLOUD_CREATE_PATH").unwrap_or_else(|| s("/sessions")),
list_path: env("BROWSER_CONTROL_CLOUD_LIST_PATH").unwrap_or_else(|| s("/sessions")),
stop_method: env("BROWSER_CONTROL_CLOUD_STOP_METHOD").unwrap_or_else(|| s("DELETE")),
stop_path: env("BROWSER_CONTROL_CLOUD_STOP_PATH").unwrap_or_else(|| s("/sessions/{id}")),
cdp_fields: env("BROWSER_CONTROL_CLOUD_CDP_FIELD")
.map(|f| vec![f])
.unwrap_or_else(|| {
vec![s("cdpUrl"), s("wsEndpoint"), s("websocketUrl"), s("connectUrl")]
}),
project_id: None,
},
};
Ok(p)
}
fn resolve() -> Result<Provider> {
let name = env("BROWSER_CONTROL_CLOUD_PROVIDER").unwrap_or_else(|| s("browser-use"));
let mut p = preset(&name)?;
if let Some(v) = env("BROWSER_CONTROL_CLOUD_API") {
p.base = v;
}
if let Some(v) = env("BROWSER_CONTROL_CLOUD_AUTH_HEADER") {
p.auth_header = v;
}
p.key = env("BROWSER_CONTROL_CLOUD_API_KEY")
.or_else(|| env(&p.key_env))
.with_context(|| format!("{} (or BROWSER_CONTROL_CLOUD_API_KEY) missing", p.key_env))?;
p.project_id = env("BROWSER_CONTROL_CLOUD_PROJECT_ID").or_else(|| env("BROWSERBASE_PROJECT_ID"));
Ok(p)
}
async fn request(p: &Provider, path: &str, method: &str, body: Option<Value>) -> Result<Value> {
let url = format!("{}{}", p.base, path);
let cli = reqwest::Client::new();
let req = match method {
"GET" => cli.get(url),
"PATCH" => cli.patch(url),
"POST" => cli.post(url),
"PUT" => cli.put(url),
"DELETE" => cli.delete(url),
_ => bail!("bad method {method}"),
}
.header(p.auth_header.as_str(), p.key.as_str())
.header("Content-Type", "application/json");
let req = if let Some(b) = body { req.json(&b) } else { req };
let text = req.send().await?.error_for_status()?.text().await?;
Ok(if text.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&text)?
})
}
fn default_create_body(p: &Provider) -> Value {
match p.name.as_str() {
"browser-use" => json!({ "timeout": 240 }),
"hyperbrowser" => json!({ "timeoutMinutes": 5 }),
_ => json!({}),
}
}
fn stop_body(p: &Provider) -> Option<Value> {
match p.name.as_str() {
"browser-use" => Some(json!({ "action": "stop" })),
"browserbase" => {
let mut o = json!({ "status": "REQUEST_RELEASE" });
if let Some(pid) = &p.project_id {
o["projectId"] = json!(pid);
}
Some(o)
}
_ => None,
}
}
fn first_field<'a>(v: &'a Value, keys: &[String]) -> Option<&'a str> {
keys.iter().find_map(|k| v.get(k).and_then(|x| x.as_str()))
}
pub async fn cloud_list() -> Result<()> {
let p = resolve()?;
let list_path = p.list_path.clone();
println_json(request(&p, &list_path, "GET", None).await?)
}
pub async fn cloud_start(body: Option<&str>) -> Result<()> {
let p = resolve()?;
let mut b = body
.map(serde_json::from_str)
.transpose()?
.unwrap_or_else(|| default_create_body(&p));
if let (Some(pid), Some(obj)) = (&p.project_id, b.as_object_mut()) {
obj.entry("projectId").or_insert_with(|| json!(pid));
}
let create_path = p.create_path.clone();
let resp = request(&p, &create_path, "POST", Some(b)).await?;
println_json(resp.clone())?;
if let Some(cdp) = first_field(&resp, &p.cdp_fields) {
let ws = if cdp.starts_with("ws") {
cdp.to_string()
} else {
crate::cdp::resolve_http(cdp).await.unwrap_or_else(|_| cdp.to_string())
};
eprintln!("export BROWSER_CONTROL_CDP_WS={ws}");
}
Ok(())
}
pub async fn cloud_stop(id: &str) -> Result<()> {
let p = resolve()?;
let path = p.stop_path.replace("{id}", id);
let method = p.stop_method.clone();
let body = stop_body(&p);
println_json(request(&p, &path, &method, body).await?)
}
pub fn passthrough(bin: &str, args: &[&str]) -> Result<()> {
let s = Command::new(bin).args(args).status()?;
if !s.success() {
bail!("{bin} exited with {s}")
}
Ok(())
}
pub fn sync_profile(
profile: &str,
browser: Option<&str>,
cloud_profile_id: Option<&str>,
) -> Result<()> {
let mut c = Command::new("profile-use");
c.args(["sync", "--profile", profile]);
if let Some(b) = browser {
c.args(["--browser", b]);
}
if let Some(id) = cloud_profile_id {
c.args(["--cloud-profile-id", id]);
}
let s = c.status()?;
if !s.success() {
bail!("profile-use exited with {s}")
}
Ok(())
}