Skip to content

Add live_url action, tracked browser sessions listing, and web UI integration#532

Open
penso wants to merge 1 commit intomainfrom
codex/explore-container-browser-integration-for-moltis
Open

Add live_url action, tracked browser sessions listing, and web UI integration#532
penso wants to merge 1 commit intomainfrom
codex/explore-container-browser-integration-for-moltis

Conversation

@penso
Copy link
Copy Markdown
Collaborator

@penso penso commented Mar 31, 2026

Motivation

  • Provide a way to obtain a human-usable DevTools URL for manual login/takeover of sandboxed browser sessions.
  • Surface currently tracked browser session mappings so operators can find session IDs for live_url/debugging.
  • Keep session tracking bounded and expose it to the web UI and API for observability.

Description

  • Add a new BrowserAction::LiveUrl action and default interactive flag via types.rs, and extend BrowserResponse with live_url and a with_live_url helper.
  • Implement BrowserManager::live_url which queries the sandboxed browser HTTP endpoint and returns the DevTools frontend URL; add fetch_devtools_live_url using reqwest and add reqwest to the crates/browser dependencies.
  • Add BrowserPool::sandbox_http_url to expose the sandbox HTTP base URL for a session and wire usage from live_url to ensure a page target exists before querying /json/list.
  • Track per-chat saved browser sessions in crates/tools::browser with a global TRACKED_BROWSER_SESSIONS map and list_tracked_browser_sessions() helper, update save_session/clear_session to maintain this map, and add BrowserSessionOverview type.
  • Expose tracked sessions via a new API handler api_browser_sessions_handler at GET /api/browser/sessions and add the route in the web server router.
  • Update the web UI (page-settings.js) to fetch and display the tracked browser sessions table in Settings → Tools, and update docs (browser-automation.md) and tool help text/schema to include live_url.

Testing

  • Added unit test test_browser_action_live_url_deserialize_defaults_interactive in crates/browser to verify live_url deserializes with default interactive = true, and it passes.
  • Added/updated crates/tools async tests (saved_browser_sessions_are_scoped_by_chat_session, empty_session_id_is_not_saved, session_cache_evicts_when_full, clearing_one_chat_session_keeps_other_browser_sessions, tracked_browser_sessions_are_listed) to cover session tracking behavior, and they pass under cargo test.
  • Ran the full test suite with cargo test across the workspace and all automated tests succeeded.

Codex Task

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq bot commented Mar 31, 2026

Merging this PR will not alter performance

✅ 39 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing codex/explore-container-browser-integration-for-moltis (723970a) with main (1c04279)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 31, 2026

Codecov Report

❌ Patch coverage is 12.24490% with 86 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/browser/src/manager.rs 0.00% 54 Missing ⚠️
crates/browser/src/types.rs 45.00% 11 Missing ⚠️
crates/web/src/api.rs 0.00% 11 Missing ⚠️
crates/browser/src/pool.rs 0.00% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 31, 2026

Greptile Summary

This PR adds a live_url browser action that surfaces a DevTools frontend URL for manual login/takeover of sandboxed browser sessions, exposes tracked browser sessions via a new GET /api/browser/sessions endpoint, and renders them in the Settings → Tools web UI. The feature fits naturally into the existing BrowserManager action dispatch and reuses the session-tracking infrastructure already in place.

Key changes:

  • BrowserAction::LiveUrl variant added in types.rs with #[serde(default)] for the interactive field; clean and well-tested.
  • fetch_devtools_live_url in manager.rs queries the sandbox's /json/list endpoint via reqwest::getmissing a request timeout — the call can hang indefinitely if the sandbox container is unresponsive (P1).
  • TRACKED_BROWSER_SESSIONS global static in crates/tools/src/browser.rs tracks all sessions process-wide; the eviction logic is per-BrowserTool-instance only, so the global can grow beyond MAX_TRACKED_SESSIONS if multiple instances are alive simultaneously, and tests sharing this mutable static can interfere with one another (P2).
  • The new /api/browser/sessions route is correctly placed inside the protected router and inherits the existing auth-gate middleware.
  • The JS async chain in page-settings.js correctly awaits .json() resolution via Promise.resolve(responsePromise) chaining, though a minor state-sequencing improvement is possible.

Confidence Score: 4/5

Safe to merge after adding a timeout to reqwest::get in fetch_devtools_live_url; all other findings are non-blocking P2.

One P1 issue: reqwest::get in fetch_devtools_live_url has no timeout, meaning a stalled sandbox container will permanently block the live_url action with no recovery path. Remaining findings (global map bounding, test isolation, minor JS state sequencing) are P2. Score is 4 due to the single present P1.

crates/browser/src/manager.rs (missing timeout on HTTP call) and crates/tools/src/browser.rs (global static eviction bounds).

Important Files Changed

Filename Overview
crates/browser/src/manager.rs Adds live_url action dispatch and fetch_devtools_live_url helper; the helper calls reqwest::get with no timeout, which can hang indefinitely on an unresponsive sandbox.
crates/tools/src/browser.rs Introduces global TRACKED_BROWSER_SESSIONS static and per-session overview tracking; eviction only bounds each BrowserTool instance's local map, so the global can grow beyond MAX_TRACKED_SESSIONS with multiple instances; test isolation is also fragile due to shared mutable global state.
crates/browser/src/types.rs Adds BrowserAction::LiveUrl, default_live_interactive, BrowserResponse::live_url field and with_live_url builder; clean and well-tested with the new deserialization unit test.
crates/browser/src/pool.rs Adds sandbox_http_url helper that reads the container's HTTP base URL behind an async RwLock; straightforward and correct.
crates/web/src/api.rs New api_browser_sessions_handler added to the protected router; reads from the global static directly. The early-return for empty sessions and serde_json::to_value path are redundant but not a bug.
crates/web/src/assets/js/page-settings.js Adds browser sessions table to Settings → Tools; async fetch chain correctly uses Promise.resolve() to forward the in-flight .json() Promise, but tool data / node inventory state is set a render cycle before loadingTools is cleared.
crates/web/src/lib.rs Registers /api/browser/sessions inside the existing protected router — correctly inherits auth-gate middleware like all other API routes.
docs/src/browser-automation.md Documentation updated with live_url action entry in the actions table and a note about the Settings → Tools session listing; accurate and consistent with the implementation.

Sequence Diagram

sequenceDiagram
    participant Agent as LLM Agent
    participant BrowserTool as BrowserTool (crates/tools)
    participant BrowserManager as BrowserManager (crates/browser)
    participant BrowserPool as BrowserPool
    participant Sandbox as Sandbox Container (HTTP :9222)
    participant TRACKED as TRACKED_BROWSER_SESSIONS (global static)

    Agent->>BrowserTool: execute({action: "live_url"})
    BrowserTool->>BrowserTool: inject sandbox mode + saved session_id
    BrowserTool->>BrowserManager: handle_request(LiveUrl{interactive:true})
    BrowserManager->>BrowserPool: get_or_create(session_id, sandbox=true)
    BrowserPool-->>BrowserManager: sid
    BrowserManager->>BrowserPool: get_page(&sid)  [ensure page target exists]
    BrowserPool-->>BrowserManager: Page
    BrowserManager->>BrowserPool: sandbox_http_url(&sid)
    BrowserPool-->>BrowserManager: "http://sandbox-host:9222"
    BrowserManager->>Sandbox: GET /json/list  [no timeout]
    Sandbox-->>BrowserManager: [{devtoolsFrontendUrl: "/devtools/..."}]
    BrowserManager-->>BrowserTool: BrowserResponse{live_url: "http://..."}
    BrowserTool->>TRACKED: save_session(key, sid, sandboxed=true)
    BrowserTool-->>Agent: {live_url: "http://sandbox:9222/devtools/..."}

    Note over Agent,TRACKED: GET /api/browser/sessions
    participant WebUI as Web UI (Settings - Tools)
    WebUI->>WebUI: fetch("/api/browser/sessions")
    WebUI->>TRACKED: list_tracked_browser_sessions()
    TRACKED-->>WebUI: Vec<BrowserSessionOverview>
    WebUI-->>WebUI: render sessions table
Loading

Reviews (1): Last reviewed commit: "feat(web): show tracked browser sessions..." | Re-trigger Greptile

Comment on lines +914 to +916
let response = reqwest::get(&endpoint)
.await
.map_err(|e| Error::Cdp(format!("failed to query browser targets: {e}")))?;
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}")))?;

Comment on lines +58 to +67
static TRACKED_BROWSER_SESSIONS: LazyLock<StdRwLock<HashMap<String, BrowserSessionOverview>>> =
LazyLock::new(|| StdRwLock::new(HashMap::new()));

pub fn list_tracked_browser_sessions() -> Vec<BrowserSessionOverview> {
let guard = TRACKED_BROWSER_SESSIONS
.read()
.unwrap_or_else(|e| e.into_inner());
let mut items: Vec<BrowserSessionOverview> = guard.values().cloned().collect();
items.sort_by(|a, b| b.last_seen_unix_ms.cmp(&a.last_seen_unix_ms));
items
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.

P2 Global map not bounded when multiple BrowserTool instances exist

The eviction logic in save_session only checks session_ids.len() (the per-instance local map). If multiple BrowserTool instances exist in the same process, each instance adds entries to TRACKED_BROWSER_SESSIONS but the global cap is never enforced — the global can grow to N × MAX_TRACKED_SESSIONS entries (128 per instance). The doc comment on TRACKED_BROWSER_SESSIONS implies it is bounded, but this invariant only holds when there is exactly one live instance.

Additionally, after the eviction block releases the TRACKED_BROWSER_SESSIONS lock and before the second acquisition for the insert (lines 139–147), another concurrent save_session call from a different instance can interleave. Consider merging the two TRACKED_BROWSER_SESSIONS write-lock acquisitions inside save_session into one to remove that TOCTOU window and to apply a global cap atomically.

Comment on lines +494 to +511
#[tokio::test]
async fn tracked_browser_sessions_are_listed() {
let config = moltis_config::schema::BrowserConfig {
enabled: true,
..Default::default()
};
let tool = BrowserTool::from_config(&config).unwrap();

tool.save_session("web:session:list", "browser-session-list", true)
.await;

let list = list_tracked_browser_sessions();
assert!(list.iter().any(|entry| {
entry.chat_session_key == "web:session:list"
&& entry.browser_session_id == "browser-session-list"
&& entry.sandboxed
}));
}
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.

P2 Test shares mutable global static — parallel tests can interfere

TRACKED_BROWSER_SESSIONS is a process-level LazyLock<StdRwLock<...>> and all tests mutate it. Rust runs unit tests in parallel by default; entries inserted by saved_browser_sessions_are_scoped_by_chat_session, session_cache_evicts_when_full, or clearing_one_chat_session_keeps_other_browser_sessions will be visible in tracked_browser_sessions_are_listed. In the eviction test, a parallel test inserting into the global could cause the eviction branch to remove that test's entry from the global (because the evict key is taken from the local session_ids, and the same string key could have been inserted there by a different BrowserTool instance in parallel).

The any() predicate makes tracked_browser_sessions_are_listed resilient today, but the session_cache_evicts_when_full test's global-side assertions could flake. Consider using a per-test key prefix derived from the test name or using #[serial] (via the serial_test crate) for the tests that touch this static.

Comment on lines +1752 to +1762
var browserSessionsResult = results[2];
var nextBrowserSessions = [];
if (browserSessionsResult.status === "fulfilled") {
nextBrowserSessions = browserSessionsResult.value
.json()
.then((payload) => (Array.isArray(payload?.sessions) ? payload.sessions : []))
.catch(() => []);
}
setToolData(nextToolData);
setNodeInventory(nextNodeInventory);
return Promise.resolve(nextBrowserSessions);
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.

P2 State set across two render cycles — brief redundant loading indicator

setToolData and setNodeInventory are called in the first .then(), but setLoadingTools(false) lives in the second .then() (after response.json() has been awaited). This means React will render an intermediate state where tool data is populated but loadingTools is still true, causing a brief redundant spinner frame. Consider moving all three set* calls into the second handler so all state is set atomically:

.then((nextBrowserSessions) => {
    setToolData(nextToolData);
    setNodeInventory(nextNodeInventory);
    setBrowserSessions(nextBrowserSessions);
    setLoadingTools(false);
})

(Move setToolData and setNodeInventory calls out of the first .then() and into the second one, after nextBrowserSessions has resolved.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant