Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/browser/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ moltis-common = { workspace = true }
moltis-config = { workspace = true }
moltis-metrics = { optional = true, workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sysinfo = { workspace = true }
Expand Down
68 changes: 68 additions & 0 deletions crates/browser/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ impl BrowserManager {
BrowserAction::Back => self.go_back(session_id, sandbox).await,
BrowserAction::Forward => self.go_forward(session_id, sandbox).await,
BrowserAction::Refresh => self.refresh(session_id, sandbox).await,
BrowserAction::LiveUrl { interactive } => {
self.live_url(session_id, sandbox, browser, interactive)
.await
},
BrowserAction::Close => self.close(session_id, sandbox).await,
};

Expand Down Expand Up @@ -315,6 +319,36 @@ impl BrowserManager {
))
}

/// Return a human-usable live URL for this browser session.
async fn live_url(
&self,
session_id: Option<&str>,
sandbox: bool,
browser: Option<BrowserPreference>,
_interactive: bool,
) -> Result<(String, BrowserResponse), Error> {
if !sandbox {
return Err(Error::InvalidAction(
"live_url currently requires sandboxed browser sessions".to_string(),
));
}

let sid = self
.pool
.get_or_create(session_id, sandbox, browser)
.await?;
// Ensure a page target exists before asking browserless for /json/list
let _ = self.pool.get_page(&sid).await?;

let http_url = self.pool.sandbox_http_url(&sid).await.ok_or_else(|| {
Error::LaunchFailed("sandbox browser HTTP endpoint not available".to_string())
})?;

let live_url = fetch_devtools_live_url(&http_url).await?;
let response = BrowserResponse::success(sid.clone(), 0, true).with_live_url(live_url);
Ok((sid, response))
}

/// Take a screenshot of the page.
async fn screenshot(
&self,
Expand Down Expand Up @@ -875,6 +909,40 @@ fn truncate_url(url: &str) -> String {
}
}

async fn fetch_devtools_live_url(http_base: &str) -> Result<String, Error> {
let endpoint = format!("{}/json/list", http_base.trim_end_matches('/'));
let response = reqwest::get(&endpoint)
.await
.map_err(|e| Error::Cdp(format!("failed to query browser targets: {e}")))?;
Comment on lines +914 to +916
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No timeout on reqwest::get — can hang indefinitely

reqwest::get() uses a one-off client with no timeout configured (the workspace's reqwest declaration has no timeout feature). If the sandbox browser's HTTP endpoint stalls or the container is unhealthy, this await will block forever, leaving the agent tool in a hung state with no way to recover.

Add an explicit timeout using a short-lived reqwest::Client:

Suggested change
let response = reqwest::get(&endpoint)
.await
.map_err(|e| Error::Cdp(format!("failed to query browser targets: {e}")))?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| Error::Cdp(format!("failed to build HTTP client: {e}")))?;
let response = client
.get(&endpoint)
.send()
.await
.map_err(|e| Error::Cdp(format!("failed to query browser targets: {e}")))?;


if !response.status().is_success() {
return Err(Error::Cdp(format!(
"browser target list returned HTTP {}",
response.status()
)));
}

let targets = response
.json::<Vec<serde_json::Value>>()
.await
.map_err(|e| Error::Cdp(format!("failed to parse browser target list: {e}")))?;

let target = targets.first().ok_or_else(|| {
Error::Cdp("browser target list is empty; navigate first, then retry live_url".to_string())
})?;
let frontend = target
.get("devtoolsFrontendUrl")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::Cdp("browser target is missing devtoolsFrontendUrl".to_string()))?;

if frontend.starts_with("http://") || frontend.starts_with("https://") {
Ok(frontend.to_string())
} else {
Ok(format!("{}{}", http_base.trim_end_matches('/'), frontend))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
13 changes: 13 additions & 0 deletions crates/browser/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,19 @@ impl BrowserPool {
self.instances.read().await.len()
}

/// Return the sandbox browser HTTP base URL for a session, if available.
pub async fn sandbox_http_url(&self, session_id: &str) -> Option<String> {
let instance = {
let instances = self.instances.read().await;
instances.get(session_id).cloned()
}?;

let inst = instance.lock().await;
inst.container
.as_ref()
.map(|container| container.http_url())
}

/// Launch a new browser instance.
async fn launch_browser(
&self,
Expand Down
41 changes: 41 additions & 0 deletions crates/browser/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ pub enum BrowserAction {
/// Refresh the page.
Refresh,

/// Return a user-facing URL for live manual interaction with this browser
/// session (for login/takeover workflows).
LiveUrl {
/// Optional hint for future transport backends.
/// `true` = full interactive session, `false` = read-only view preferred.
#[serde(default = "default_live_interactive")]
interactive: bool,
},

/// Close the browser session.
Close,
}
Expand All @@ -78,6 +87,10 @@ fn default_wait_timeout_ms() -> u64 {
30000
}

fn default_live_interactive() -> bool {
true
}

/// Known Chromium-family browser engines we can launch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -181,6 +194,13 @@ impl fmt::Display for BrowserAction {
Self::Back => write!(f, "back"),
Self::Forward => write!(f, "forward"),
Self::Refresh => write!(f, "refresh"),
Self::LiveUrl { interactive } => {
if *interactive {
write!(f, "live_url(interactive)")
} else {
write!(f, "live_url(read_only)")
}
},
Self::Close => write!(f, "close"),
}
}
Expand Down Expand Up @@ -334,6 +354,10 @@ pub struct BrowserResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,

/// User-facing URL for manual browser viewing/control.
#[serde(skip_serializing_if = "Option::is_none")]
pub live_url: Option<String>,

/// Duration of the action in milliseconds.
pub duration_ms: u64,
}
Expand All @@ -351,6 +375,7 @@ impl BrowserResponse {
result: None,
url: None,
title: None,
live_url: None,
duration_ms,
}
}
Expand All @@ -367,6 +392,7 @@ impl BrowserResponse {
result: None,
url: None,
title: None,
live_url: None,
duration_ms,
}
}
Expand Down Expand Up @@ -396,6 +422,11 @@ impl BrowserResponse {
self.title = Some(title);
self
}

pub fn with_live_url(mut self, live_url: String) -> Self {
self.live_url = Some(live_url);
self
}
}

/// Browser configuration.
Expand Down Expand Up @@ -616,6 +647,16 @@ mod tests {
assert_eq!(value, BrowserPreference::Brave);
}

#[test]
fn test_browser_action_live_url_deserialize_defaults_interactive() {
let req: BrowserRequest = serde_json::from_str(r#"{"action":"live_url"}"#)
.unwrap_or_else(|e| panic!("failed to deserialize live_url action: {e}"));
match req.action {
BrowserAction::LiveUrl { interactive } => assert!(interactive),
_ => panic!("expected BrowserAction::LiveUrl"),
}
}

#[test]
fn resolved_profile_dir_returns_path_by_default() {
// Default config has persist_profile = true
Expand Down
Loading
Loading