feat: dashboard provider support and session creation improvements (#1092)
Add provider icons and session creation from the dashboard UI. Sessions can now be created with cloud providers (Browserbase, Browserless, Browser Use, Kernel) in addition to local engines. CLI changes: - Track provider via .provider files alongside .engine files - Add WaitUntil::None variant to skip lifecycle event waits for providers - Auto-set waitUntil=none when --provider is used with navigate - Fix Browser Use: use direct WSS connection (wss://connect.browser-use.com) - Add connect_cdp_direct for providers with page-level CDP proxies - Fix resolve_cdp_url to convert https:// provider URLs to wss:// - Treat empty CDP session_id as None (omit from protocol messages) - Fix Browserbase: send explicit JSON body + Content-Type header - Increase CDP connect timeout to 25s for remote providers - Clean up .provider files on session close Dashboard changes: - Show provider or engine icon per session in sidebar - New session dialog with unified engine/provider selector grid - Async session creation with loading state and error display - Kill zombie daemons on provider connection failure - Parse CLI JSON error output for user-friendly messages - Default new session URL to https://agent-browser.dev
This commit is contained in:
+3
-1
@@ -112,7 +112,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
format!("https://{}", url)
|
||||
};
|
||||
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
|
||||
// If --headers flag is set, include headers (scoped to this origin)
|
||||
if flags.provider.is_some() {
|
||||
nav_cmd["waitUntil"] = json!("none");
|
||||
}
|
||||
if let Some(ref headers_json) = flags.headers {
|
||||
let headers =
|
||||
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
|
||||
|
||||
@@ -1618,8 +1618,13 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
return launch_safari(cmd, state).await;
|
||||
}
|
||||
_ => {
|
||||
let (ws_url, provider_session) = providers::connect_provider(provider).await?;
|
||||
match BrowserManager::connect_cdp(&ws_url).await {
|
||||
let conn = providers::connect_provider(provider).await?;
|
||||
let connect_result = if conn.direct_page {
|
||||
BrowserManager::connect_cdp_direct(&conn.ws_url).await
|
||||
} else {
|
||||
BrowserManager::connect_cdp(&conn.ws_url).await
|
||||
};
|
||||
match connect_result {
|
||||
Ok(mgr) => {
|
||||
state.reset_input_state();
|
||||
state.browser = Some(mgr);
|
||||
@@ -1627,10 +1632,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.start_fetch_handler();
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
write_provider_file(&state.session_id, provider);
|
||||
return Ok(json!({ "launched": true, "provider": provider }));
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(ref ps) = provider_session {
|
||||
if let Some(ref ps) = conn.session {
|
||||
providers::close_provider_session(ps).await;
|
||||
}
|
||||
return Err(e);
|
||||
@@ -1810,6 +1816,7 @@ async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
|
||||
state.backend_type = BackendType::WebDriver;
|
||||
state.engine = "safari".to_string();
|
||||
write_engine_file(&state.session_id, &state.engine);
|
||||
write_provider_file(&state.session_id, "ios");
|
||||
write_extensions_file(&state.session_id);
|
||||
state.reset_input_state();
|
||||
|
||||
@@ -1857,6 +1864,7 @@ async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.backend_type = BackendType::WebDriver;
|
||||
state.engine = "safari".to_string();
|
||||
write_engine_file(&state.session_id, &state.engine);
|
||||
write_provider_file(&state.session_id, "safari");
|
||||
write_extensions_file(&state.session_id);
|
||||
state.reset_input_state();
|
||||
|
||||
@@ -4555,6 +4563,18 @@ fn remove_engine_file(session_id: &str) {
|
||||
let _ = fs::remove_file(engine_file_path(session_id));
|
||||
}
|
||||
|
||||
fn provider_file_path(session_id: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.provider", session_id))
|
||||
}
|
||||
|
||||
fn write_provider_file(session_id: &str, provider: &str) {
|
||||
let _ = fs::write(provider_file_path(session_id), provider);
|
||||
}
|
||||
|
||||
fn remove_provider_file(session_id: &str) {
|
||||
let _ = fs::remove_file(provider_file_path(session_id));
|
||||
}
|
||||
|
||||
fn extensions_file_path(session_id: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.extensions", session_id))
|
||||
}
|
||||
@@ -4644,6 +4664,7 @@ async fn handle_stream_disable(state: &mut DaemonState) -> Result<Value, String>
|
||||
state.stream_client = None;
|
||||
remove_stream_file(&state.session_id)?;
|
||||
remove_engine_file(&state.session_id);
|
||||
remove_provider_file(&state.session_id);
|
||||
|
||||
Ok(json!({ "disabled": true }))
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ pub enum WaitUntil {
|
||||
Load,
|
||||
DomContentLoaded,
|
||||
NetworkIdle,
|
||||
None,
|
||||
}
|
||||
|
||||
impl WaitUntil {
|
||||
@@ -156,6 +157,7 @@ impl WaitUntil {
|
||||
match s {
|
||||
"domcontentloaded" => Self::DomContentLoaded,
|
||||
"networkidle" => Self::NetworkIdle,
|
||||
"none" => Self::None,
|
||||
_ => Self::Load,
|
||||
}
|
||||
}
|
||||
@@ -326,6 +328,16 @@ impl BrowserManager {
|
||||
}
|
||||
|
||||
pub async fn connect_cdp(url: &str) -> Result<Self, String> {
|
||||
Self::connect_cdp_inner(url, false).await
|
||||
}
|
||||
|
||||
/// Connect to a provider CDP proxy where the WebSocket IS the page session.
|
||||
/// Skips browser-level Target.* commands that most proxies don't support.
|
||||
pub async fn connect_cdp_direct(url: &str) -> Result<Self, String> {
|
||||
Self::connect_cdp_inner(url, true).await
|
||||
}
|
||||
|
||||
async fn connect_cdp_inner(url: &str, direct_page: bool) -> Result<Self, String> {
|
||||
let ws_url = resolve_cdp_url(url).await?;
|
||||
let client = Arc::new(CdpClient::connect(&ws_url).await?);
|
||||
let mut manager = Self {
|
||||
@@ -334,12 +346,24 @@ impl BrowserManager {
|
||||
ws_url,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 10_000,
|
||||
download_path: None, // CDP connections don't have a launch-time download path
|
||||
default_timeout_ms: 25_000,
|
||||
download_path: None,
|
||||
visited_origins: HashSet::new(),
|
||||
};
|
||||
|
||||
manager.discover_and_attach_targets().await?;
|
||||
if direct_page {
|
||||
manager.pages.push(PageInfo {
|
||||
target_id: "provider-page".to_string(),
|
||||
session_id: String::new(),
|
||||
url: String::new(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
manager.active_page_index = 0;
|
||||
manager.enable_domains_direct().await?;
|
||||
} else {
|
||||
manager.discover_and_attach_targets().await?;
|
||||
}
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
@@ -465,6 +489,20 @@ impl BrowserManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable domains on a direct page connection (no session_id needed).
|
||||
async fn enable_domains_direct(&self) -> Result<(), String> {
|
||||
self.client
|
||||
.send_command_no_params("Page.enable", None)
|
||||
.await?;
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", None)
|
||||
.await?;
|
||||
self.client
|
||||
.send_command_no_params("Network.enable", None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active_session_id(&self) -> Result<&str, String> {
|
||||
self.pages
|
||||
.get(self.active_page_index)
|
||||
@@ -495,7 +533,7 @@ impl BrowserManager {
|
||||
// Only wait for lifecycle events if Chrome created a new loader (full navigation).
|
||||
// If loader_id is None, it was a same-document navigation (e.g., hash routing)
|
||||
// which does not fire Page.loadEventFired or Page.domContentEventFired.
|
||||
if nav_result.loader_id.is_some() {
|
||||
if nav_result.loader_id.is_some() && wait_until != WaitUntil::None {
|
||||
self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
|
||||
.await?;
|
||||
}
|
||||
@@ -529,6 +567,7 @@ impl BrowserManager {
|
||||
WaitUntil::Load => "Page.loadEventFired",
|
||||
WaitUntil::DomContentLoaded => "Page.domContentEventFired",
|
||||
WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await,
|
||||
WaitUntil::None => return Ok(()),
|
||||
};
|
||||
|
||||
let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
|
||||
@@ -1366,6 +1405,19 @@ async fn resolve_cdp_url(input: &str) -> Result<String, String> {
|
||||
|
||||
if input.starts_with("http://") || input.starts_with("https://") {
|
||||
let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?;
|
||||
// If no explicit port and path is empty/root, this is likely a provider
|
||||
// WebSocket endpoint (e.g. https://xxx.cdp0.browser-use.com). Convert
|
||||
// the scheme to ws/wss and connect directly instead of probing :9222.
|
||||
if parsed.port().is_none() && (parsed.path().is_empty() || parsed.path() == "/") {
|
||||
let ws_scheme = if input.starts_with("https://") {
|
||||
"wss"
|
||||
} else {
|
||||
"ws"
|
||||
};
|
||||
let mut ws_url = parsed.clone();
|
||||
let _ = ws_url.set_scheme(ws_scheme);
|
||||
return Ok(ws_url.to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("No host in CDP URL: {}", input))?;
|
||||
|
||||
@@ -199,7 +199,7 @@ impl CdpClient {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: session_id.map(|s| s.to_string()),
|
||||
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&cmd)
|
||||
|
||||
@@ -63,6 +63,7 @@ pub async fn run_daemon(session: &str) {
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
@@ -119,6 +120,7 @@ pub async fn run_daemon(session: &str) {
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Err(e) = result {
|
||||
|
||||
+45
-54
@@ -12,16 +12,49 @@ pub struct ProviderSession {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
pub struct ProviderConnection {
|
||||
pub ws_url: String,
|
||||
pub session: Option<ProviderSession>,
|
||||
/// If true, the WebSocket IS the page session (no Target.* commands).
|
||||
pub direct_page: bool,
|
||||
}
|
||||
|
||||
/// Connects to the specified browser provider and returns a CDP WebSocket URL
|
||||
/// along with session info for cleanup on failure.
|
||||
pub async fn connect_provider(
|
||||
provider_name: &str,
|
||||
) -> Result<(String, Option<ProviderSession>), String> {
|
||||
pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => connect_browserbase().await,
|
||||
"browserless" => connect_browserless().await,
|
||||
"browser-use" | "browseruse" => connect_browser_use().await,
|
||||
"kernel" => connect_kernel().await,
|
||||
"browserbase" => {
|
||||
let (url, session) = connect_browserbase().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browserless" => {
|
||||
let (url, session) = connect_browserless().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browser-use" | "browseruse" => {
|
||||
let (url, session) = connect_browser_use().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"kernel" => {
|
||||
let (url, session) = connect_kernel().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel",
|
||||
provider_name
|
||||
@@ -91,7 +124,9 @@ async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), Stri
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browserbase.com/v1/sessions")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-bb-api-key", &api_key)
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserbase request failed: {}", e))?;
|
||||
@@ -219,53 +254,9 @@ async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), Stri
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browser-use.com/api/v2/browsers")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browser Use request failed: {}", e))?;
|
||||
let ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browser Use API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_url")
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browser-use".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
Ok((ws_url, None))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
|
||||
@@ -1322,6 +1322,11 @@ fn discover_sessions() -> String {
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "chrome".to_string());
|
||||
|
||||
let provider_path = dir.join(format!("{}.provider", session));
|
||||
let provider = std::fs::read_to_string(&provider_path)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
let extensions = read_extensions_metadata(&dir, session);
|
||||
|
||||
let mut entry = json!({
|
||||
@@ -1329,6 +1334,9 @@ fn discover_sessions() -> String {
|
||||
"port": port,
|
||||
"engine": engine.trim(),
|
||||
});
|
||||
if let Some(ref p) = provider {
|
||||
entry["provider"] = json!(p.trim());
|
||||
}
|
||||
if !extensions.is_empty() {
|
||||
entry["extensions"] = json!(extensions);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user