feat(terminal-browser): import hardened carbonyl runtime

This commit is contained in:
Felipe Coury
2026-07-06 10:24:39 -03:00
parent 6289c176b7
commit 8e878f6871
37 changed files with 5714 additions and 0 deletions
+27
View File
@@ -4001,6 +4001,32 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-terminal-browser"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"codex-utils-pty",
"futures",
"libc",
"pretty_assertions",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"url",
"vt100",
"which 8.0.0",
]
[[package]]
name = "codex-terminal-detection"
version = "0.0.0"
@@ -4114,6 +4140,7 @@ dependencies = [
"codex-sandboxing",
"codex-shell-command",
"codex-state",
"codex-terminal-browser",
"codex-terminal-detection",
"codex-utils-absolute-path",
"codex-utils-approval-presets",
+2
View File
@@ -115,6 +115,7 @@ members = [
"codex-client",
"codex-api",
"state",
"terminal-browser",
"terminal-detection",
"test-binary-support",
"thread-manager-sample",
@@ -226,6 +227,7 @@ codex-skills-extension = { path = "ext/skills" }
codex-skills = { path = "skills" }
codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-terminal-browser = { path = "terminal-browser" }
codex-terminal-detection = { path = "terminal-detection" }
codex-test-binary-support = { path = "test-binary-support" }
codex-thread-store = { path = "thread-store" }
+16
View File
@@ -186,6 +186,8 @@ pub enum Feature {
///
/// Requirements-only gate: this should be set from requirements, not user config.
BrowserUseExternal,
/// Allow the agent to control a Carbonyl browser rendered in the terminal.
TerminalBrowser,
/// Allow Codex Computer Use.
///
/// Requirements-only gate: this should be set from requirements, not user config.
@@ -1136,6 +1138,20 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::TerminalBrowser,
key: "terminal_browser",
stage: if cfg!(any(target_os = "macos", target_os = "linux")) {
Stage::Experimental {
name: "Terminal browser",
menu_description: "Let Codex browse with Carbonyl in the right terminal panel.",
announcement: "",
}
} else {
Stage::UnderDevelopment
},
default_enabled: false,
},
FeatureSpec {
id: Feature::ComputerUse,
key: "computer_use",
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "terminal-browser",
crate_name = "codex_terminal_browser",
)
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "codex-terminal-browser"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_terminal_browser"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-pty = { workspace = true }
futures = { workspace = true }
libc = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "net", "process", "rt", "sync", "time"] }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
vt100 = { workspace = true }
which = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
@@ -0,0 +1,267 @@
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use serde_json::json;
use crate::actions::BrowserToolOutput;
use crate::actions::bounded_snapshot_json;
use crate::actions::page_metadata;
use crate::cdp::CdpClient;
use crate::handles::BrowserHandles;
const MAX_AX_NODES: usize = 300;
const MAX_SNAPSHOT_TEXT_CHARS: usize = 6_000;
pub(crate) async fn snapshot(
client: &CdpClient,
handles: &mut BrowserHandles,
) -> Result<BrowserToolOutput> {
let tree = client
.call("Accessibility.getFullAXTree", json!({}))
.await?;
let metadata = page_metadata(client).await?;
let ax_nodes = tree
.get("nodes")
.and_then(Value::as_array)
.context("accessibility tree omitted nodes")?;
handles.begin_snapshot();
let mut actionable_nodes = Vec::new();
let mut contextual_nodes = Vec::new();
let mut page_text = String::new();
for (index, node) in ax_nodes.iter().enumerate().filter(|(_, node)| {
!node
.get("ignored")
.and_then(Value::as_bool)
.unwrap_or(/*default*/ false)
}) {
let role = ax_value(node, "role");
let name = ax_value(node, "name");
if role == "StaticText" && !name.is_empty() && page_text.len() < MAX_SNAPSHOT_TEXT_CHARS {
if !page_text.is_empty() {
page_text.push('\n');
}
let remaining = MAX_SNAPSHOT_TEXT_CHARS.saturating_sub(page_text.len());
page_text.extend(name.chars().take(remaining));
}
let actionable = is_actionable_role(&role);
if (role.is_empty() && name.is_empty())
|| (actionable && actionable_nodes.len() >= MAX_AX_NODES)
{
continue;
}
let mut output = serde_json::Map::new();
output.insert("role".to_string(), Value::String(role.clone()));
output.insert(
"name".to_string(),
Value::String(clipped(&name, /*max_chars*/ 200)),
);
if actionable
&& let Some(backend_node_id) = node.get("backendDOMNodeId").and_then(Value::as_u64)
{
output.insert(
"nodeId".to_string(),
Value::String(handles.insert(backend_node_id)),
);
}
if let Some(disabled) = ax_property(node, "disabled").and_then(Value::as_bool) {
output.insert("disabled".to_string(), Value::Bool(disabled));
}
if let Some(checked) = ax_property(node, "checked") {
output.insert("checked".to_string(), checked.clone());
}
let value = ax_value(node, "value");
if !value.is_empty() {
output.insert(
"value".to_string(),
Value::String(if is_editable_role(&role) {
"<redacted>".to_string()
} else {
clipped(&value, /*max_chars*/ 200)
}),
);
}
if actionable {
actionable_nodes.push((index, Value::Object(output)));
} else if contextual_nodes.len() < MAX_AX_NODES {
contextual_nodes.push((index, Value::Object(output)));
}
}
contextual_nodes.truncate(MAX_AX_NODES.saturating_sub(actionable_nodes.len()));
actionable_nodes.extend(contextual_nodes);
actionable_nodes.sort_by_key(|(index, _)| *index);
let nodes = actionable_nodes
.into_iter()
.map(|(_, node)| node)
.collect::<Vec<_>>();
let snapshot = json!({
"url": metadata.url,
"title": metadata.title,
"nodes": nodes,
"text": clipped(&page_text, MAX_SNAPSHOT_TEXT_CHARS),
});
Ok(BrowserToolOutput::Text(bounded_snapshot_json(snapshot)?))
}
pub(crate) async fn click(
client: &CdpClient,
handles: &BrowserHandles,
node_id: &str,
) -> Result<BrowserToolOutput> {
let backend_node_id = handles.resolve(node_id)?;
client
.call(
"DOM.scrollIntoViewIfNeeded",
json!({ "backendNodeId": backend_node_id }),
)
.await
.context("scroll target node into view")?;
let (x, y) = node_center(client, backend_node_id).await?;
client
.call(
"Input.dispatchMouseEvent",
json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" }),
)
.await?;
client
.call(
"Input.dispatchMouseEvent",
json!({ "type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1 }),
)
.await?;
client
.call(
"Input.dispatchMouseEvent",
json!({ "type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1 }),
)
.await?;
Ok(BrowserToolOutput::Text(format!("clicked {node_id}")))
}
pub(crate) async fn fill(
client: &CdpClient,
handles: &BrowserHandles,
node_id: &str,
text: &str,
) -> Result<BrowserToolOutput> {
let backend_node_id = handles.resolve(node_id)?;
client
.call("DOM.focus", json!({ "backendNodeId": backend_node_id }))
.await?;
let select_modifier = if cfg!(target_os = "macos") { 4 } else { 2 };
client
.call(
"Input.dispatchKeyEvent",
json!({ "type": "rawKeyDown", "key": "a", "code": "KeyA", "modifiers": select_modifier }),
)
.await?;
client
.call(
"Input.dispatchKeyEvent",
json!({ "type": "keyUp", "key": "a", "code": "KeyA", "modifiers": select_modifier }),
)
.await?;
client
.call(
"Input.dispatchKeyEvent",
json!({ "type": "rawKeyDown", "key": "Backspace", "code": "Backspace" }),
)
.await?;
client
.call(
"Input.dispatchKeyEvent",
json!({ "type": "keyUp", "key": "Backspace", "code": "Backspace" }),
)
.await?;
client
.call("Input.insertText", json!({ "text": text }))
.await?;
Ok(BrowserToolOutput::Text(format!("filled {node_id}")))
}
pub(crate) async fn node_center(client: &CdpClient, backend_node_id: u64) -> Result<(f64, f64)> {
let model = client
.call(
"DOM.getBoxModel",
json!({ "backendNodeId": backend_node_id }),
)
.await?;
let quad = model
.pointer("/model/content")
.or_else(|| model.pointer("/model/border"))
.and_then(Value::as_array)
.context("node has no visible box model")?;
anyhow::ensure!(quad.len() == 8, "node box model has an invalid quad");
let coordinates = quad
.iter()
.map(|coordinate| {
coordinate
.as_f64()
.context("node box model coordinate is not numeric")
})
.collect::<Result<Vec<_>>>()?;
let x = coordinates.iter().step_by(2).sum::<f64>() / 4.0;
let y = coordinates.iter().skip(1).step_by(2).sum::<f64>() / 4.0;
Ok((x, y))
}
pub(crate) async fn node_is_attached(client: &CdpClient, backend_node_id: u64) -> Result<bool> {
match client
.call(
"DOM.describeNode",
json!({ "backendNodeId": backend_node_id, "depth": 0 }),
)
.await
{
Ok(_) => Ok(true),
Err(error)
if error.to_string().contains("Could not find node")
|| error.to_string().contains("No node with given id") =>
{
Ok(false)
}
Err(error) => Err(error),
}
}
fn ax_value(node: &Value, field: &str) -> String {
node.pointer(&format!("/{field}/value"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn ax_property<'a>(node: &'a Value, name: &str) -> Option<&'a Value> {
node.get("properties")
.and_then(Value::as_array)?
.iter()
.find(|property| property.get("name").and_then(Value::as_str) == Some(name))?
.pointer("/value/value")
}
fn is_actionable_role(role: &str) -> bool {
matches!(
role,
"button"
| "checkbox"
| "combobox"
| "link"
| "menuitem"
| "option"
| "radio"
| "searchbox"
| "slider"
| "spinbutton"
| "switch"
| "tab"
| "textbox"
| "treeitem"
)
}
fn is_editable_role(role: &str) -> bool {
matches!(role, "combobox" | "searchbox" | "spinbutton" | "textbox")
}
fn clipped(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
+268
View File
@@ -0,0 +1,268 @@
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use serde_json::json;
use crate::cdp::CdpClient;
use crate::input::BrowserKeyInput;
use crate::input::BrowserMouseInput;
use crate::input::BrowserMouseKind;
use crate::scripts;
const MAX_SCREENSHOT_BYTES: usize = 4 * 1024 * 1024;
const MAX_SNAPSHOT_OUTPUT_BYTES: usize = 8 * 1024;
const MAX_SNAPSHOT_URL_CHARS: usize = 2_048;
const MAX_SNAPSHOT_TITLE_CHARS: usize = 512;
const MAX_SNAPSHOT_TEXT_CHARS: usize = 6_000;
#[derive(Default)]
pub(crate) struct HumanMouseDispatchState {
viewport: Option<(u16, u16, f64, f64)>,
buttons: u8,
}
pub(crate) struct PageMetadata {
pub(crate) url: Option<String>,
pub(crate) title: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BrowserToolOutput {
Text(String),
ImageDataUrl(String),
}
pub(crate) async fn page_metadata(client: &CdpClient) -> Result<PageMetadata> {
let metadata = client
.evaluate("({ url: location.href, title: document.title })")
.await?;
Ok(PageMetadata {
url: metadata
.get("url")
.and_then(Value::as_str)
.map(|url| clipped(url, MAX_SNAPSHOT_URL_CHARS)),
title: metadata
.get("title")
.and_then(Value::as_str)
.map(|title| clipped(title, MAX_SNAPSHOT_TITLE_CHARS)),
})
}
pub(crate) async fn press(client: &CdpClient, key: &str) -> Result<BrowserToolOutput> {
anyhow::ensure!(!key.is_empty(), "key must not be empty");
anyhow::ensure!(key.chars().count() <= 32, "key is too long");
let code = scripts::key_code(key);
let text = if key.chars().count() == 1 { key } else { "" };
client
.call(
"Input.dispatchKeyEvent",
json!({
"type": "rawKeyDown",
"key": key,
"code": code,
"text": text,
"unmodifiedText": text,
}),
)
.await?;
client
.call(
"Input.dispatchKeyEvent",
json!({ "type": "keyUp", "key": key, "code": code }),
)
.await?;
Ok(BrowserToolOutput::Text(format!("pressed {key}")))
}
pub(crate) async fn scroll(
client: &CdpClient,
delta_x: i64,
delta_y: i64,
) -> Result<BrowserToolOutput> {
let result = client
.evaluate(&scripts::scroll_expression(delta_x, delta_y))
.await?;
Ok(BrowserToolOutput::Text(serde_json::to_string(&result)?))
}
pub(crate) async fn screenshot(client: &CdpClient) -> Result<BrowserToolOutput> {
let result = client
.call(
"Page.captureScreenshot",
json!({ "format": "png", "captureBeyondViewport": false }),
)
.await?;
let data = result
.get("data")
.and_then(Value::as_str)
.context("screenshot response did not include image data")?;
let decoded_size_estimate = data.len().saturating_mul(/*rhs*/ 3) / 4;
anyhow::ensure!(
decoded_size_estimate <= MAX_SCREENSHOT_BYTES,
"screenshot exceeds the 4 MiB tool-output limit"
);
Ok(BrowserToolOutput::ImageDataUrl(format!(
"data:image/png;base64,{data}"
)))
}
pub(crate) async fn dispatch_human_key(client: &CdpClient, input: &BrowserKeyInput) -> Result<()> {
let text = input.text.as_deref().unwrap_or_default();
let modifiers = input.modifiers.cdp_mask();
client
.call(
"Input.dispatchKeyEvent",
json!({
"type": "rawKeyDown",
"key": input.key,
"code": input.code,
"text": text,
"unmodifiedText": text,
"modifiers": modifiers,
}),
)
.await?;
client
.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"key": input.key,
"code": input.code,
"modifiers": modifiers,
}),
)
.await?;
Ok(())
}
pub(crate) async fn insert_human_text(client: &CdpClient, text: &str) -> Result<()> {
client
.call("Input.insertText", json!({ "text": text }))
.await?;
Ok(())
}
pub(crate) async fn dispatch_human_mouse(
client: &CdpClient,
input: BrowserMouseInput,
state: &mut HumanMouseDispatchState,
) -> Result<()> {
anyhow::ensure!(
input.viewport_cols > 0 && input.viewport_rows > 0,
"browser viewport must be non-zero"
);
let (width, height) = if let Some((cols, rows, width, height)) = state.viewport
&& cols == input.viewport_cols
&& rows == input.viewport_rows
{
(width, height)
} else {
let metrics = client.call("Page.getLayoutMetrics", json!({})).await?;
let width = metrics
.pointer("/cssLayoutViewport/clientWidth")
.or_else(|| metrics.pointer("/layoutViewport/clientWidth"))
.and_then(Value::as_f64)
.context("browser layout metrics omitted viewport width")?;
let height = metrics
.pointer("/cssLayoutViewport/clientHeight")
.or_else(|| metrics.pointer("/layoutViewport/clientHeight"))
.and_then(Value::as_f64)
.context("browser layout metrics omitted viewport height")?;
state.viewport = Some((input.viewport_cols, input.viewport_rows, width, height));
(width, height)
};
let x = f64::from(input.column) * width / f64::from(input.viewport_cols);
let y = f64::from(input.row) * height / f64::from(input.viewport_rows);
let modifiers = input.modifiers.cdp_mask();
let (event_type, delta_x, delta_y) = match input.kind {
BrowserMouseKind::Move => ("mouseMoved", 0.0, 0.0),
BrowserMouseKind::Down => ("mousePressed", 0.0, 0.0),
BrowserMouseKind::Up => ("mouseReleased", 0.0, 0.0),
BrowserMouseKind::Wheel { delta_x, delta_y } => ("mouseWheel", delta_x, delta_y),
};
match input.kind {
BrowserMouseKind::Down => state.buttons |= input.button.cdp_buttons_mask(),
BrowserMouseKind::Up => state.buttons &= !input.button.cdp_buttons_mask(),
BrowserMouseKind::Move | BrowserMouseKind::Wheel { .. } => {}
}
client
.call(
"Input.dispatchMouseEvent",
json!({
"type": event_type,
"x": x,
"y": y,
"button": input.button.as_cdp(),
"buttons": state.buttons,
"modifiers": modifiers,
"clickCount": if matches!(input.kind, BrowserMouseKind::Down | BrowserMouseKind::Up) { 1 } else { 0 },
"deltaX": delta_x,
"deltaY": delta_y,
}),
)
.await?;
Ok(())
}
pub(crate) fn bounded_snapshot_json(mut snapshot: Value) -> Result<String> {
truncate_string_field(&mut snapshot, "url", MAX_SNAPSHOT_URL_CHARS);
truncate_string_field(&mut snapshot, "title", MAX_SNAPSHOT_TITLE_CHARS);
truncate_string_field(&mut snapshot, "text", MAX_SNAPSHOT_TEXT_CHARS);
let mut output = serde_json::to_string_pretty(&snapshot)?;
if output.len() <= MAX_SNAPSHOT_OUTPUT_BYTES {
return Ok(output);
}
if let Some(object) = snapshot.as_object_mut() {
object.insert("truncated".to_string(), Value::Bool(true));
if let Some(text) = object.get("text").and_then(Value::as_str) {
object.insert(
"text".to_string(),
Value::String(text.chars().take(/*n*/ 8_000).collect()),
);
}
}
loop {
output = serde_json::to_string_pretty(&snapshot)?;
if output.len() <= MAX_SNAPSHOT_OUTPUT_BYTES {
return Ok(output);
}
let removed = snapshot
.get_mut("nodes")
.and_then(Value::as_array_mut)
.and_then(Vec::pop)
.is_some();
if !removed {
break;
}
}
Ok(serde_json::to_string_pretty(&json!({
"url": snapshot.get("url").cloned().unwrap_or(Value::Null),
"title": snapshot.get("title").cloned().unwrap_or(Value::Null),
"truncated": true,
"text": "Snapshot exceeded the output limit. Narrow the page state and take another snapshot."
}))?)
}
fn truncate_string_field(value: &mut Value, field: &str, max_chars: usize) {
let Some(text) = value
.get(field)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
return;
};
if let Some(object) = value.as_object_mut() {
object.insert(field.to_string(), Value::String(clipped(&text, max_chars)));
}
}
fn clipped(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
#[cfg(test)]
#[path = "actions_tests.rs"]
mod tests;
@@ -0,0 +1,311 @@
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use super::BrowserToolOutput;
use super::HumanMouseDispatchState;
use super::dispatch_human_mouse;
use crate::accessibility::click;
use crate::accessibility::snapshot;
use crate::cdp::CdpClient;
use crate::handles::BrowserHandles;
use crate::input::BrowserInputModifiers;
use crate::input::BrowserMouseButton;
use crate::input::BrowserMouseInput;
use crate::input::BrowserMouseKind;
use crate::navigation::LoadState;
use crate::navigation::NavigateRequest;
use crate::navigation::NavigationAction;
use crate::navigation::navigate;
use crate::navigation::navigate_request;
type TestSocket = tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>;
async fn test_server(
handler: impl FnOnce(TestSocket) -> JoinHandle<()> + Send + 'static,
) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test websocket");
let address = listener.local_addr().expect("test websocket address");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept test websocket");
let socket = accept_async(stream).await.expect("upgrade test websocket");
handler(socket).await.expect("test handler task");
});
(format!("ws://{address}"), server)
}
async fn request(socket: &mut TestSocket) -> Value {
let Message::Text(text) = socket
.next()
.await
.expect("request message")
.expect("valid request message")
else {
panic!("expected text request");
};
serde_json::from_str(text.as_str()).expect("decode request")
}
async fn respond(socket: &mut TestSocket, request: &Value, result: Value) {
socket
.send(Message::Text(
json!({ "id": request["id"], "result": result })
.to_string()
.into(),
))
.await
.expect("send response");
}
async fn initialize(socket: &mut TestSocket) {
for method in [
"Page.enable",
"Runtime.enable",
"DOM.enable",
"Accessibility.enable",
"Page.setLifecycleEventsEnabled",
] {
let request = request(socket).await;
assert_eq!(request["method"], method);
respond(socket, &request, json!({})).await;
}
}
#[tokio::test]
async fn accessibility_snapshot_creates_host_owned_handles_and_redacts_editable_values() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
initialize(&mut socket).await;
let tree = request(&mut socket).await;
assert_eq!(tree["method"], "Accessibility.getFullAXTree");
respond(
&mut socket,
&tree,
json!({
"nodes": [
{
"ignored": false,
"role": { "value": "textbox" },
"name": { "value": "Password" },
"value": { "value": "super-secret" },
"backendDOMNodeId": 7
},
{
"ignored": false,
"role": { "value": "button" },
"name": { "value": "Submit" },
"backendDOMNodeId": 8
},
{
"ignored": false,
"role": { "value": "StaticText" },
"name": { "value": "Hello" }
}
]
}),
)
.await;
let metadata = request(&mut socket).await;
assert_eq!(metadata["method"], "Runtime.evaluate");
respond(
&mut socket,
&metadata,
json!({ "result": { "value": { "url": "https://example.test", "title": "Example" } } }),
)
.await;
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let mut handles = BrowserHandles::default();
let BrowserToolOutput::Text(output) = snapshot(&client, &mut handles).await.expect("snapshot")
else {
panic!("expected text snapshot");
};
let output: Value = serde_json::from_str(&output).expect("snapshot JSON");
assert_eq!(output["nodes"][0]["value"], "<redacted>");
assert_eq!(output["nodes"][1]["name"], "Submit");
assert_eq!(output["text"], "Hello");
let node_id = output["nodes"][1]["nodeId"]
.as_str()
.expect("button node id");
assert_eq!(handles.resolve(node_id).expect("resolve button"), 8);
server.await.expect("server task");
}
#[tokio::test]
async fn native_click_uses_the_accessibility_handle_box_center() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
initialize(&mut socket).await;
let scroll = request(&mut socket).await;
assert_eq!(scroll["method"], "DOM.scrollIntoViewIfNeeded");
respond(&mut socket, &scroll, json!({})).await;
let box_model = request(&mut socket).await;
assert_eq!(box_model["method"], "DOM.getBoxModel");
respond(
&mut socket,
&box_model,
json!({ "model": { "content": [10, 20, 30, 20, 30, 40, 10, 40] } }),
)
.await;
for expected_type in ["mouseMoved", "mousePressed", "mouseReleased"] {
let input = request(&mut socket).await;
assert_eq!(input["method"], "Input.dispatchMouseEvent");
assert_eq!(input["params"]["type"], expected_type);
assert_eq!(input["params"]["x"], 20.0);
assert_eq!(input["params"]["y"], 30.0);
respond(&mut socket, &input, json!({})).await;
}
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let mut handles = BrowserHandles::default();
let node_id = handles.insert(/*backend_node_id*/ 9);
let output = click(&client, &handles, &node_id)
.await
.expect("native click");
assert_eq!(
output,
BrowserToolOutput::Text(format!("clicked {node_id}"))
);
server.await.expect("server task");
}
#[tokio::test]
async fn human_mouse_caches_metrics_and_preserves_drag_buttons() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
initialize(&mut socket).await;
let metrics = request(&mut socket).await;
assert_eq!(metrics["method"], "Page.getLayoutMetrics");
respond(
&mut socket,
&metrics,
json!({ "cssLayoutViewport": { "clientWidth": 800, "clientHeight": 600 } }),
)
.await;
for (expected_type, expected_buttons) in
[("mousePressed", 1), ("mouseMoved", 1), ("mouseReleased", 0)]
{
let input = request(&mut socket).await;
assert_eq!(input["method"], "Input.dispatchMouseEvent");
assert_eq!(input["params"]["type"], expected_type);
assert_eq!(input["params"]["buttons"], expected_buttons);
respond(&mut socket, &input, json!({})).await;
}
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let mut state = HumanMouseDispatchState::default();
for kind in [
BrowserMouseKind::Down,
BrowserMouseKind::Move,
BrowserMouseKind::Up,
] {
dispatch_human_mouse(
&client,
BrowserMouseInput {
kind,
button: BrowserMouseButton::Left,
column: 10,
row: 5,
viewport_cols: 80,
viewport_rows: 24,
modifiers: BrowserInputModifiers::default(),
},
&mut state,
)
.await
.expect("dispatch mouse input");
}
server.await.expect("server task");
}
#[tokio::test]
async fn navigation_waits_for_the_cdp_lifecycle_event() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
initialize(&mut socket).await;
let navigation = request(&mut socket).await;
assert_eq!(navigation["method"], "Page.navigate");
respond(
&mut socket,
&navigation,
json!({ "frameId": "frame", "loaderId": "loader" }),
)
.await;
socket
.send(Message::Text(
json!({
"method": "Page.lifecycleEvent",
"params": {
"frameId": "frame",
"loaderId": "loader",
"name": "DOMContentLoaded",
"timestamp": 1,
}
})
.to_string()
.into(),
))
.await
.expect("send lifecycle event");
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
navigate(&client, "https://example.test")
.await
.expect("navigation");
server.await.expect("server task");
}
#[tokio::test]
async fn navigation_timeout_bounds_the_cdp_command_and_wait() {
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
initialize(&mut socket).await;
let navigation = request(&mut socket).await;
assert_eq!(navigation["method"], "Page.navigate");
release_rx.await.expect("release server");
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let started = std::time::Instant::now();
let error = navigate_request(
&client,
&NavigateRequest {
action: NavigationAction::Goto,
url: Some("https://example.test".to_string()),
wait_until: LoadState::Load,
timeout_ms: Some(/*value*/ 10),
},
)
.await
.expect_err("navigation must time out");
assert!(error.to_string().contains("navigation_timeout"));
assert!(started.elapsed() < std::time::Duration::from_millis(/*millis*/ 500));
release_tx.send(()).expect("release server");
server.await.expect("server task");
}
+254
View File
@@ -0,0 +1,254 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use futures::SinkExt;
use futures::StreamExt;
use serde_json::Value;
use serde_json::json;
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
type PendingCalls = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const CALL_TIMEOUT: Duration = Duration::from_secs(15);
const OUTBOUND_CAPACITY: usize = 64;
const EVENT_CAPACITY: usize = 256;
const MAX_EVENT_BYTES: usize = 16 * 1024;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum CdpEvent {
Message(Value),
Disconnected(String),
}
#[derive(Clone)]
pub(crate) struct CdpClient {
outbound: mpsc::Sender<Message>,
pending: PendingCalls,
events: broadcast::Sender<CdpEvent>,
next_id: Arc<AtomicU64>,
call_timeout: Duration,
}
impl CdpClient {
pub(crate) async fn connect(websocket_url: &str) -> Result<Self> {
Self::connect_with_call_timeout(websocket_url, CALL_TIMEOUT).await
}
async fn connect_with_call_timeout(
websocket_url: &str,
call_timeout: Duration,
) -> Result<Self> {
let (socket, _) = timeout(CONNECT_TIMEOUT, connect_async(websocket_url))
.await
.context("timed out connecting to Carbonyl DevTools")??;
let (outbound, outbound_rx) = mpsc::channel(OUTBOUND_CAPACITY);
let (events, _) = broadcast::channel(EVENT_CAPACITY);
let client = Self {
outbound,
pending: Arc::new(Mutex::new(HashMap::new())),
events,
next_id: Arc::new(AtomicU64::new(/*v*/ 1)),
call_timeout,
};
tokio::spawn(run_pump(
socket,
outbound_rx,
client.pending.clone(),
client.events.clone(),
));
client.call("Page.enable", json!({})).await?;
client.call("Runtime.enable", json!({})).await?;
client.call("DOM.enable", json!({})).await?;
client.call("Accessibility.enable", json!({})).await?;
client
.call("Page.setLifecycleEventsEnabled", json!({ "enabled": true }))
.await?;
Ok(client)
}
pub(crate) fn subscribe_events(&self) -> broadcast::Receiver<CdpEvent> {
self.events.subscribe()
}
pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(/*val*/ 1, Ordering::Relaxed);
let request = json!({
"id": id,
"method": method,
"params": params,
});
let (response_tx, response_rx) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(id, response_tx);
let _pending_call = PendingCallGuard {
pending: self.pending.clone(),
id,
};
let result = timeout(self.call_timeout, async {
if self
.outbound
.send(Message::Text(request.to_string().into()))
.await
.is_err()
{
bail!("Carbonyl closed the DevTools connection");
}
response_rx
.await
.context("Carbonyl closed the DevTools connection")?
.map_err(anyhow::Error::msg)
})
.await;
let response = match result {
Ok(response) => response?,
Err(_) => {
bail!("timed out waiting for CDP {method}");
}
};
if let Some(error) = response.get("error") {
bail!("CDP {method} failed: {error}");
}
Ok(response.get("result").cloned().unwrap_or(Value::Null))
}
pub(crate) async fn evaluate(&self, expression: &str) -> Result<Value> {
let result = self
.call(
"Runtime.evaluate",
json!({
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
}),
)
.await?;
if let Some(details) = result.get("exceptionDetails") {
bail!("browser script failed: {details}");
}
Ok(result
.pointer("/result/value")
.cloned()
.unwrap_or(Value::Null))
}
}
struct PendingCallGuard {
pending: PendingCalls,
id: u64,
}
impl Drop for PendingCallGuard {
fn drop(&mut self) {
self.pending
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&self.id);
}
}
async fn run_pump(
mut socket: Socket,
mut outbound: mpsc::Receiver<Message>,
pending: PendingCalls,
events: broadcast::Sender<CdpEvent>,
) {
let disconnect_reason = loop {
tokio::select! {
outgoing = outbound.recv() => match outgoing {
Some(message) => {
if let Err(error) = socket.send(message).await {
break format!("failed to send DevTools message: {error}");
}
}
None => {
let _ = socket.close(None).await;
break "DevTools client closed".to_string();
}
},
incoming = socket.next() => match incoming {
Some(Ok(Message::Text(text))) => {
let response = match serde_json::from_str::<Value>(text.as_str()) {
Ok(response) => response,
Err(error) => break format!("failed to decode DevTools message: {error}"),
};
dispatch_message(response, &pending, &events);
}
Some(Ok(Message::Ping(bytes))) => {
if let Err(error) = socket.send(Message::Pong(bytes)).await {
break format!("failed to reply to DevTools ping: {error}");
}
}
Some(Ok(Message::Close(frame))) => {
break format!("Carbonyl closed the DevTools connection: {frame:?}");
}
Some(Ok(Message::Binary(_) | Message::Pong(_) | Message::Frame(_))) => {}
Some(Err(error)) => break format!("DevTools connection failed: {error}"),
None => break "Carbonyl closed the DevTools connection".to_string(),
}
}
};
fail_pending(&pending, &disconnect_reason);
let _ = events.send(CdpEvent::Disconnected(disconnect_reason));
}
fn dispatch_message(response: Value, pending: &PendingCalls, events: &broadcast::Sender<CdpEvent>) {
let response_tx = response.get("id").and_then(Value::as_u64).and_then(|id| {
pending
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&id)
});
if let Some(response_tx) = response_tx {
let _ = response_tx.send(Ok(response));
} else if should_broadcast_event(&response) {
let _ = events.send(CdpEvent::Message(response));
}
}
fn should_broadcast_event(message: &Value) -> bool {
let Some(method) = message.get("method").and_then(Value::as_str) else {
return false;
};
matches!(
method,
"Page.domContentEventFired"
| "Page.frameNavigated"
| "Page.lifecycleEvent"
| "Page.loadEventFired"
| "Page.navigatedWithinDocument"
) && serde_json::to_vec(message).is_ok_and(|encoded| encoded.len() <= MAX_EVENT_BYTES)
}
fn fail_pending(pending: &PendingCalls, reason: &str) {
let pending = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
for response_tx in pending.into_values() {
let _ = response_tx.send(Err(reason.to_string()));
}
}
#[cfg(test)]
#[path = "cdp_tests.rs"]
mod tests;
+277
View File
@@ -0,0 +1,277 @@
use std::time::Duration;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use super::CdpClient;
use super::CdpEvent;
async fn test_server(
handler: impl FnOnce(tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> JoinHandle<()>
+ Send
+ 'static,
) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test websocket");
let address = listener.local_addr().expect("test websocket address");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept test websocket");
let socket = accept_async(stream).await.expect("upgrade test websocket");
handler(socket).await.expect("test handler task");
});
(format!("ws://{address}"), server)
}
async fn receive_request(
socket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) -> Value {
let Message::Text(text) = socket
.next()
.await
.expect("request message")
.expect("valid request message")
else {
panic!("expected text request");
};
serde_json::from_str(text.as_str()).expect("decode request")
}
async fn respond(
socket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
request: &Value,
result: Value,
) {
socket
.send(Message::Text(
json!({ "id": request["id"], "result": result })
.to_string()
.into(),
))
.await
.expect("send response");
}
async fn complete_initialization(
socket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) {
for expected_method in [
"Page.enable",
"Runtime.enable",
"DOM.enable",
"Accessibility.enable",
"Page.setLifecycleEventsEnabled",
] {
let request = receive_request(socket).await;
assert_eq!(request["method"], expected_method);
respond(socket, &request, json!({})).await;
}
}
#[tokio::test]
async fn lifecycle_messages_are_broadcast_without_leaking_unknown_responses() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
complete_initialization(&mut socket).await;
let request = receive_request(&mut socket).await;
socket
.send(Message::Text(
json!({ "method": "Page.loadEventFired", "params": { "timestamp": 1 } })
.to_string()
.into(),
))
.await
.expect("send notification");
socket
.send(Message::Text(
json!({ "id": 999, "result": { "late": true } })
.to_string()
.into(),
))
.await
.expect("send unknown response");
respond(&mut socket, &request, json!({ "ok": true })).await;
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let mut events = client.subscribe_events();
let result = client
.call("Test.command", json!({}))
.await
.expect("command response");
assert_eq!(result, json!({ "ok": true }));
assert_eq!(
events.recv().await.expect("notification"),
CdpEvent::Message(json!({ "method": "Page.loadEventFired", "params": { "timestamp": 1 } }))
);
assert!(matches!(
events.recv().await.expect("disconnect event"),
CdpEvent::Disconnected(_)
));
server.await.expect("server task");
}
#[tokio::test]
async fn cdp_errors_keep_the_method_name() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
complete_initialization(&mut socket).await;
let request = receive_request(&mut socket).await;
socket
.send(Message::Text(
json!({ "id": request["id"], "error": { "code": -1, "message": "nope" } })
.to_string()
.into(),
))
.await
.expect("send error response");
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let error = client
.call("Test.failure", json!({}))
.await
.expect_err("command should fail");
assert!(error.to_string().contains("CDP Test.failure failed"));
server.await.expect("server task");
}
#[tokio::test]
async fn disconnect_fails_pending_calls_and_emits_an_event() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
complete_initialization(&mut socket).await;
let _request = receive_request(&mut socket).await;
socket.close(None).await.expect("close websocket");
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let mut events = client.subscribe_events();
let error = client
.call("Test.disconnect", json!({}))
.await
.expect_err("disconnect should fail the call");
assert!(error.to_string().contains("closed the DevTools connection"));
assert!(matches!(
events.recv().await.expect("disconnect event"),
CdpEvent::Disconnected(_)
));
server.await.expect("server task");
}
#[tokio::test]
async fn late_responses_after_timeout_are_discarded() {
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
complete_initialization(&mut socket).await;
let request = receive_request(&mut socket).await;
release_rx.await.expect("release late response");
respond(&mut socket, &request, json!({ "late": true })).await;
})
})
.await;
let client = CdpClient::connect_with_call_timeout(&url, Duration::from_millis(20))
.await
.expect("connect client");
let mut events = client.subscribe_events();
let error = client
.call("Test.timeout", json!({}))
.await
.expect_err("command should time out");
assert!(error.to_string().contains("timed out waiting for CDP"));
release_tx.send(()).expect("release server response");
assert!(matches!(
events.recv().await.expect("disconnect event"),
CdpEvent::Disconnected(_)
));
server.await.expect("server task");
}
#[tokio::test]
async fn cancelling_a_call_removes_its_pending_entry() {
let (seen_tx, seen_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
complete_initialization(&mut socket).await;
let _request = receive_request(&mut socket).await;
seen_tx.send(()).expect("mark request seen");
release_rx.await.expect("release server");
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let caller = {
let client = client.clone();
tokio::spawn(async move { client.call("Test.cancel", json!({})).await })
};
seen_rx.await.expect("request seen");
caller.abort();
let _ = caller.await;
assert!(
client
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
);
release_tx.send(()).expect("release server");
server.await.expect("server task");
}
#[tokio::test]
async fn concurrent_calls_match_reverse_order_responses() {
let (url, server) = test_server(|mut socket| {
tokio::spawn(async move {
complete_initialization(&mut socket).await;
let first = receive_request(&mut socket).await;
let second = receive_request(&mut socket).await;
respond(&mut socket, &second, json!({ "order": 2 })).await;
socket
.send(Message::Text(
json!({ "method": "Page.loadEventFired", "params": { "timestamp": 1 } })
.to_string()
.into(),
))
.await
.expect("send lifecycle event");
respond(&mut socket, &first, json!({ "order": 1 })).await;
})
})
.await;
let client = CdpClient::connect(&url).await.expect("connect client");
let mut events = client.subscribe_events();
let (first, second) = tokio::join!(
client.call("Test.first", json!({})),
client.call("Test.second", json!({}))
);
assert_eq!(first.expect("first response"), json!({ "order": 1 }));
assert_eq!(second.expect("second response"), json!({ "order": 2 }));
assert!(matches!(
events.recv().await.expect("lifecycle event"),
CdpEvent::Message(_)
));
server.await.expect("server task");
}
+141
View File
@@ -0,0 +1,141 @@
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use reqwest::Client;
use serde::Deserialize;
use url::Host;
use url::Url;
use crate::cdp::CdpClient;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DevtoolsTarget {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
title: String,
web_socket_debugger_url: Option<String>,
}
pub(crate) struct PageTarget {
pub(crate) title: String,
pub(crate) websocket_url: String,
}
pub(crate) fn prepare_devtools_active_port(profile: &Path) -> Result<()> {
let active_port_path = profile.join("DevToolsActivePort");
let metadata = match std::fs::symlink_metadata(&active_port_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error).context("inspect Carbonyl DevToolsActivePort before startup");
}
};
anyhow::ensure!(
!metadata.file_type().is_symlink(),
"refusing symbolic-link Carbonyl DevToolsActivePort"
);
anyhow::ensure!(
metadata.is_file(),
"refusing non-file Carbonyl DevToolsActivePort"
);
std::fs::remove_file(&active_port_path)
.context("remove stale Carbonyl DevToolsActivePort before startup")
}
pub(crate) async fn discover_page_target(profile: &Path) -> Result<PageTarget> {
let client = Client::builder()
.no_proxy()
.timeout(Duration::from_secs(/*secs*/ 2))
.build()?;
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 12);
let active_port_path = profile.join("DevToolsActivePort");
let port = loop {
match std::fs::read_to_string(&active_port_path) {
Ok(contents) => {
let port = contents
.lines()
.next()
.context("DevToolsActivePort did not contain a port")?
.parse::<u16>()
.context("DevToolsActivePort contained an invalid port")?;
anyhow::ensure!(port != 0, "DevToolsActivePort contained port zero");
break port;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).context("read Carbonyl DevToolsActivePort");
}
}
anyhow::ensure!(
Instant::now() < deadline,
"timed out waiting for Carbonyl DevToolsActivePort"
);
tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await;
};
let endpoint = format!("http://127.0.0.1:{port}/json/list");
loop {
if let Ok(response) = client.get(&endpoint).send().await
&& let Ok(targets) = response.json::<Vec<DevtoolsTarget>>().await
{
for target in targets {
if target.kind != "page" {
continue;
}
let Some(websocket_url) = target.web_socket_debugger_url else {
continue;
};
return Ok(PageTarget {
title: target.title,
websocket_url: validated_websocket_url(&websocket_url, port)?,
});
}
}
anyhow::ensure!(
Instant::now() < deadline,
"timed out waiting for Carbonyl DevTools on {endpoint}"
);
tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await;
}
}
pub(crate) fn validated_websocket_url(websocket_url: &str, expected_port: u16) -> Result<String> {
let parsed = Url::parse(websocket_url).context("parse Carbonyl DevTools WebSocket URL")?;
anyhow::ensure!(parsed.scheme() == "ws", "Carbonyl DevTools URL must use ws");
let loopback = match parsed.host() {
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
Some(Host::Domain(_)) | None => false,
};
anyhow::ensure!(loopback, "Carbonyl DevTools URL must use a loopback host");
anyhow::ensure!(
parsed.port() == Some(expected_port),
"Carbonyl DevTools URL used an unexpected port"
);
Ok(parsed.to_string())
}
pub(crate) async fn deny_downloads(client: &CdpClient) -> Result<()> {
if client
.call(
"Browser.setDownloadBehavior",
serde_json::json!({ "behavior": "deny" }),
)
.await
.is_ok()
{
return Ok(());
}
client
.call(
"Page.setDownloadBehavior",
serde_json::json!({ "behavior": "deny" }),
)
.await
.context("disable Carbonyl downloads")?;
Ok(())
}
@@ -0,0 +1,140 @@
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use crate::network::BrowserNetworkPolicy;
use crate::runtime::BrowserRuntime;
use crate::sandbox::BrowserLaunchContext;
use crate::sandbox::prepare_browser_launch;
const VERSION_TIMEOUT: Duration = Duration::from_secs(3);
const SUPPORTED_VERSION: &str = "0.0.3";
const MAX_VERSION_OUTPUT_CHARS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
/// Result of validating the external Carbonyl installation used by the experiment.
pub struct BrowserDoctorReport {
/// Whether the executable passed all launch compatibility checks.
pub healthy: bool,
/// Human-readable local diagnostic suitable for the `/browser doctor` surface.
pub summary: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct BrowserInstallation {
pub(crate) path: PathBuf,
pub(crate) version: String,
}
pub(crate) async fn inspect_installation(
binary: &Path,
launch_context: &BrowserLaunchContext,
) -> Result<BrowserInstallation> {
let path = std::fs::canonicalize(binary).context("canonicalize Carbonyl executable")?;
ensure_executable(&path)?;
let runtime = BrowserRuntime::create(/*persistent_profile*/ None)?;
let network_policy = BrowserNetworkPolicy::Disabled;
let launch = prepare_browser_launch(
&path,
vec!["--version".to_string()],
&runtime.root,
&runtime.profile,
runtime.environment(&network_policy),
&network_policy,
launch_context,
)?;
let mut command = tokio::process::Command::new(&launch.program);
#[cfg(unix)]
if let Some(arg0) = launch.arg0.as_deref() {
command.arg0(arg0);
}
command
.args(&launch.args)
.current_dir(launch.cwd.as_path())
.env_clear()
.envs(&launch.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(/*kill_on_drop*/ true);
let output = tokio::time::timeout(VERSION_TIMEOUT, command.output())
.await
.context("Carbonyl version probe timed out")?
.context("run Carbonyl version probe")?;
anyhow::ensure!(
output.status.success(),
"Carbonyl version probe exited with {}",
output.status
);
let version = first_nonempty_line(&output.stdout)
.or_else(|| first_nonempty_line(&output.stderr))
.context("Carbonyl version probe returned no version")?;
anyhow::ensure!(
has_supported_version(&version),
"unsupported Carbonyl version `{version}`; this experiment currently requires {SUPPORTED_VERSION}"
);
Ok(BrowserInstallation { path, version })
}
pub(crate) fn unavailable_report(reason: &str) -> BrowserDoctorReport {
BrowserDoctorReport {
healthy: false,
summary: format!("Carbonyl unavailable: {reason}"),
}
}
pub(crate) fn installation_report(result: &Result<BrowserInstallation>) -> BrowserDoctorReport {
match result {
Ok(installation) => BrowserDoctorReport {
healthy: true,
summary: format!(
"Carbonyl is ready: {} ({}, {})",
installation.path.display(),
installation.version,
std::env::consts::ARCH
),
},
Err(error) => BrowserDoctorReport {
healthy: false,
summary: format!("Carbonyl validation failed: {error}"),
},
}
}
fn first_nonempty_line(bytes: &[u8]) -> Option<String> {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| line.chars().take(MAX_VERSION_OUTPUT_CHARS).collect())
}
fn has_supported_version(version_line: &str) -> bool {
version_line.split_ascii_whitespace().last() == Some(SUPPORTED_VERSION)
}
#[cfg(unix)]
fn ensure_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path)
.context("read Carbonyl executable metadata")?
.permissions()
.mode();
anyhow::ensure!(mode & 0o111 != 0, "Carbonyl path is not executable");
Ok(())
}
#[cfg(not(unix))]
fn ensure_executable(path: &Path) -> Result<()> {
anyhow::ensure!(path.is_file(), "Carbonyl path is not an executable file");
Ok(())
}
#[cfg(test)]
#[path = "diagnostics_tests.rs"]
mod tests;
@@ -0,0 +1,36 @@
use pretty_assertions::assert_eq;
use super::first_nonempty_line;
use super::has_supported_version;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::inspect_installation;
#[test]
fn version_probe_uses_the_first_nonempty_line() {
assert_eq!(
first_nonempty_line(b"\n Carbonyl 0.0.3 \nextra\n"),
Some("Carbonyl 0.0.3".to_string())
);
}
#[test]
fn version_probe_requires_the_exact_pinned_version() {
assert!(has_supported_version("Carbonyl 0.0.3"));
assert!(!has_supported_version("Carbonyl 0.0.30"));
assert!(!has_supported_version("Carbonyl 1.0.0"));
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
#[ignore = "opt-in smoke test requiring CODEX_CARBONYL_BINARY and a host sandbox"]
async fn configured_real_carbonyl_passes_the_sandboxed_version_probe() {
let binary = std::env::var_os("CODEX_CARBONYL_BINARY")
.map(std::path::PathBuf::from)
.expect("set CODEX_CARBONYL_BINARY to the real Carbonyl executable");
let installation = inspect_installation(&binary, &Default::default())
.await
.expect("supported installation");
assert_eq!(installation.version, "Carbonyl 0.0.3");
}
+132
View File
@@ -0,0 +1,132 @@
use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
enum BrowserErrorCode {
Unavailable,
UnsupportedVersion,
BrowserBusy,
NavigationTimeout,
StaleHandle,
Crashed,
PolicyChanged,
ApprovalDenied,
InvalidInput,
Internal,
}
/// Sanitized error returned to a model-facing terminal-browser tool call.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserToolFailure {
code: BrowserErrorCode,
message: &'static str,
retryable: bool,
}
/// Classifies an internal browser error into a bounded, secret-free tool failure.
pub fn classify_browser_error(error: &anyhow::Error) -> BrowserToolFailure {
let message = error.to_string().to_ascii_lowercase();
if message.contains("browser_busy")
|| message.contains("another terminal browser action")
|| message.contains("human_control_active")
{
return failure(
BrowserErrorCode::BrowserBusy,
"another browser action is still running",
/*retryable*/ true,
);
}
if message.contains("unsupported carbonyl version") {
return failure(
BrowserErrorCode::UnsupportedVersion,
"the installed Carbonyl version is not supported",
/*retryable*/ false,
);
}
if message.contains("timed out") || message.contains("navigation_timeout") {
return failure(
BrowserErrorCode::NavigationTimeout,
"the browser did not reach the requested state before the timeout",
/*retryable*/ true,
);
}
if message.contains("node_not_found")
|| message.contains("invalid nodeid")
|| message.contains("could not find node")
|| message.contains("no node with given id")
{
return failure(
BrowserErrorCode::StaleHandle,
"the page changed or the node handle is stale; take a new snapshot",
/*retryable*/ true,
);
}
if message.contains("network policy changed")
|| message.contains("permission profile")
|| message.contains("permission policy")
|| message.contains("sandbox network namespace")
|| message.contains("managed terminal-browser networking is not yet supported")
{
return failure(
BrowserErrorCode::PolicyChanged,
"the active browser permission policy does not allow this action",
/*retryable*/ false,
);
}
if message.contains("approval") && message.contains("denied") {
return failure(
BrowserErrorCode::ApprovalDenied,
"the user did not approve this browser action",
/*retryable*/ false,
);
}
if message.contains("carbonyl exited") || message.contains("devtools connection") {
return failure(
BrowserErrorCode::Crashed,
"the Carbonyl browser process is no longer available",
/*retryable*/ true,
);
}
if message.contains("carbonyl")
&& (message.contains("unavailable")
|| message.contains("not found")
|| message.contains("not discovered"))
{
return failure(
BrowserErrorCode::Unavailable,
"Carbonyl is unavailable; run /browser doctor for local diagnostics",
/*retryable*/ false,
);
}
if message.contains("unknown field")
|| message.contains("missing field")
|| message.contains("invalid type")
|| message.contains("does not accept")
|| message.contains("must not")
|| message.contains("requires a url")
{
return failure(
BrowserErrorCode::InvalidInput,
"the terminal-browser tool arguments are invalid",
/*retryable*/ false,
);
}
failure(
BrowserErrorCode::Internal,
"the terminal-browser action failed; inspect local logs for details",
/*retryable*/ false,
)
}
fn failure(code: BrowserErrorCode, message: &'static str, retryable: bool) -> BrowserToolFailure {
BrowserToolFailure {
code,
message,
retryable,
}
}
#[cfg(test)]
#[path = "error_tests.rs"]
mod tests;
@@ -0,0 +1,38 @@
use pretty_assertions::assert_eq;
use serde_json::json;
use super::BrowserErrorCode;
use super::classify_browser_error;
#[test]
fn model_facing_failures_are_structured_and_sanitized() {
let error = anyhow::anyhow!("CDP Runtime.evaluate failed with secret=do-not-leak");
let failure = classify_browser_error(&error);
assert_eq!(
serde_json::to_value(failure).expect("serialize failure"),
json!({
"code": "internal",
"message": "the terminal-browser action failed; inspect local logs for details",
"retryable": false,
})
);
}
#[test]
fn busy_errors_are_retryable() {
let failure = classify_browser_error(&anyhow::anyhow!("browser_busy: already running"));
assert_eq!(failure.code, BrowserErrorCode::BrowserBusy);
assert!(failure.retryable);
}
#[test]
fn policy_and_stale_node_errors_use_actionable_codes() {
let policy = classify_browser_error(&anyhow::anyhow!(
"browser navigation blocked by the active permission policy"
));
let stale = classify_browser_error(&anyhow::anyhow!("CDP: Could not find node with id 7"));
assert_eq!(policy.code, BrowserErrorCode::PolicyChanged);
assert_eq!(stale.code, BrowserErrorCode::StaleHandle);
}
+57
View File
@@ -0,0 +1,57 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use anyhow::Result;
static NEXT_DOCUMENT_GENERATION: AtomicU64 = AtomicU64::new(1);
#[derive(Debug)]
pub(crate) struct BrowserHandles {
document_generation: u64,
next_node: u64,
backend_nodes: HashMap<String, u64>,
}
impl Default for BrowserHandles {
fn default() -> Self {
Self {
document_generation: next_document_generation(),
next_node: 1,
backend_nodes: HashMap::new(),
}
}
}
impl BrowserHandles {
pub(crate) fn begin_snapshot(&mut self) {
self.clear();
}
pub(crate) fn insert(&mut self, backend_node_id: u64) -> String {
let node_id = format!("d{:016x}n{}", self.document_generation, self.next_node);
self.next_node += 1;
self.backend_nodes.insert(node_id.clone(), backend_node_id);
node_id
}
pub(crate) fn resolve(&self, node_id: &str) -> Result<u64> {
self.backend_nodes.get(node_id).copied().ok_or_else(|| {
anyhow::anyhow!("node_not_found: take a new snapshot and use its nodeId")
})
}
pub(crate) fn clear(&mut self) {
self.document_generation = next_document_generation();
self.next_node = 1;
self.backend_nodes.clear();
}
}
fn next_document_generation() -> u64 {
NEXT_DOCUMENT_GENERATION.fetch_add(/*val*/ 1, Ordering::Relaxed)
}
#[cfg(test)]
#[path = "handles_tests.rs"]
mod tests;
@@ -0,0 +1,17 @@
use pretty_assertions::assert_eq;
use super::BrowserHandles;
#[test]
fn handles_are_document_scoped_and_stale_after_refresh() {
let mut handles = BrowserHandles::default();
let first = handles.insert(/*backend_node_id*/ 42);
assert_eq!(handles.resolve(&first).expect("resolve handle"), 42);
handles.begin_snapshot();
let second = handles.insert(/*backend_node_id*/ 42);
assert_ne!(first, second);
assert!(handles.resolve(&first).is_err());
assert_eq!(handles.resolve(&second).expect("resolve new handle"), 42);
}
@@ -0,0 +1,248 @@
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::Ordering;
use anyhow::Result;
use anyhow::anyhow;
use tokio::sync::mpsc;
use crate::actions;
use crate::actions::HumanMouseDispatchState;
use crate::input::BrowserKeyInput;
use crate::input::BrowserMouseInput;
use crate::input::BrowserMouseKind;
use crate::session::Inner;
use crate::session::TerminalBrowser;
const HUMAN_INPUT_CAPACITY: usize = 128;
const MAX_HUMAN_TEXT_BYTES: usize = 64 * 1024;
pub(crate) struct HumanInputSender(mpsc::Sender<QueuedHumanInput>);
enum HumanInput {
Key(BrowserKeyInput),
Text(String),
Mouse(BrowserMouseInput),
}
pub(crate) struct QueuedHumanInput {
generation: u64,
input: HumanInput,
}
impl TerminalBrowser {
pub(crate) fn human_input_channel() -> (HumanInputSender, mpsc::Receiver<QueuedHumanInput>) {
let (sender, receiver) = mpsc::channel(HUMAN_INPUT_CAPACITY);
(HumanInputSender(sender), receiver)
}
pub fn is_human_control_active(&self) -> bool {
self.inner.human_control.load(Ordering::SeqCst)
}
pub async fn toggle_human_control(&self) -> Result<bool> {
self.inner
.human_control_transition
.compare_exchange(
/*current*/ false,
/*new*/ true,
Ordering::SeqCst,
Ordering::SeqCst,
)
.map_err(|_| anyhow!("browser_busy: browser control is already changing"))?;
let _transition = HumanControlTransition {
active: &self.inner.human_control_transition,
};
if self.is_human_control_active() {
self.end_human_control().await?;
} else {
self.begin_human_control().await?;
}
Ok(self.is_human_control_active())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "entering human control must serialize the live-session check with model actions"
)]
pub async fn begin_human_control(&self) -> Result<()> {
if let Some(receiver) = self
.inner
.human_input_rx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
tokio::spawn(run_human_input_worker(
Arc::downgrade(&self.inner),
receiver,
));
}
let _operation = self
.inner
.operation
.try_lock()
.map_err(|_| anyhow!("browser_busy: another terminal browser action is running"))?;
anyhow::ensure!(
self.inner.session.lock().await.is_some(),
"terminal browser is not open"
);
let process_running = self
.inner
.process
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(|process| !process.has_exited());
let view_running = matches!(
&self
.inner
.view
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.status,
crate::screen::BrowserStatus::Running
);
anyhow::ensure!(
process_running && view_running,
"terminal browser is not running"
);
self.inner
.human_control_generation
.fetch_add(/*val*/ 1, Ordering::SeqCst);
self.inner
.human_control
.store(/*val*/ true, Ordering::SeqCst);
self.inner.update_view(|view| {
view.human_control = true;
view.visible = true;
});
Ok(())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "ending human control must serialize handle invalidation with model actions"
)]
pub async fn end_human_control(&self) -> Result<()> {
self.inner
.human_control_generation
.fetch_add(/*val*/ 1, Ordering::SeqCst);
self.inner
.human_control
.store(/*val*/ false, Ordering::SeqCst);
self.inner.update_view(|view| view.human_control = false);
let _operation = self.inner.operation.lock().await;
let mut session = self.inner.session.lock().await;
if let Some(session) = session.as_mut() {
session.handles.clear();
}
Ok(())
}
pub fn send_human_key(&self, input: BrowserKeyInput) -> Result<()> {
self.queue_human_input(HumanInput::Key(input))
}
pub fn send_human_text(&self, text: &str) -> Result<()> {
anyhow::ensure!(
text.len() <= MAX_HUMAN_TEXT_BYTES,
"browser text input exceeds the 64 KiB limit"
);
self.queue_human_input(HumanInput::Text(text.to_string()))
}
pub fn send_human_mouse(&self, input: BrowserMouseInput) -> Result<()> {
self.queue_human_input(HumanInput::Mouse(input))
}
fn queue_human_input(&self, input: HumanInput) -> Result<()> {
anyhow::ensure!(
self.is_human_control_active(),
"human control is not active"
);
let is_motion = matches!(
input,
HumanInput::Mouse(BrowserMouseInput {
kind: BrowserMouseKind::Move,
..
})
);
let queued = QueuedHumanInput {
generation: self.inner.human_control_generation.load(Ordering::SeqCst),
input,
};
match self.inner.human_input_tx.0.try_send(queued) {
Ok(()) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) if is_motion => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => {
anyhow::bail!("browser_busy: human input queue is full")
}
Err(mpsc::error::TrySendError::Closed(_)) => {
anyhow::bail!("terminal browser input worker stopped")
}
}
}
}
struct HumanControlTransition<'a> {
active: &'a std::sync::atomic::AtomicBool,
}
impl Drop for HumanControlTransition<'_> {
fn drop(&mut self) {
self.active.store(/*val*/ false, Ordering::SeqCst);
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "human input must remain serialized with model actions until its CDP call completes"
)]
async fn run_human_input_worker(
inner: Weak<Inner>,
mut receiver: mpsc::Receiver<QueuedHumanInput>,
) {
let mut mouse_state = HumanMouseDispatchState::default();
let mut mouse_generation = None;
while let Some(queued) = receiver.recv().await {
let Some(inner) = inner.upgrade() else {
return;
};
if !inner.human_control.load(Ordering::SeqCst)
|| inner.human_control_generation.load(Ordering::SeqCst) != queued.generation
{
continue;
}
if mouse_generation != Some(queued.generation) {
mouse_state = HumanMouseDispatchState::default();
mouse_generation = Some(queued.generation);
}
let result = {
let _operation = inner.operation.lock().await;
if !inner.human_control.load(Ordering::SeqCst)
|| inner.human_control_generation.load(Ordering::SeqCst) != queued.generation
{
continue;
}
let cdp = {
let session = inner.session.lock().await;
let Some(session) = session.as_ref() else {
continue;
};
session.cdp.clone()
};
match queued.input {
HumanInput::Key(input) => actions::dispatch_human_key(&cdp, &input).await,
HumanInput::Text(text) => actions::insert_human_text(&cdp, &text).await,
HumanInput::Mouse(input) => {
actions::dispatch_human_mouse(&cdp, input, &mut mouse_state).await
}
}
};
if let Err(error) = result {
tracing::warn!(%error, "terminal-browser human input failed");
inner.set_crashed("Terminal browser input connection failed".to_string());
}
}
}
+96
View File
@@ -0,0 +1,96 @@
/// Modifier keys held while forwarding input to the browser.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BrowserInputModifiers {
/// Alt/Option is held.
pub alt: bool,
/// Control is held.
pub control: bool,
/// Command/Super is held.
pub meta: bool,
/// Shift is held.
pub shift: bool,
}
impl BrowserInputModifiers {
pub(crate) fn cdp_mask(self) -> u8 {
u8::from(self.alt)
| (u8::from(self.control) << 1)
| (u8::from(self.meta) << 2)
| (u8::from(self.shift) << 3)
}
}
/// One keyboard event forwarded while the user exclusively controls the browser.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowserKeyInput {
/// DOM key value, such as `Enter` or `a`.
pub key: String,
/// DOM physical-key code, such as `Enter` or `KeyA`.
pub code: String,
/// Text to insert for printable keys; absent for shortcuts and control keys.
pub text: Option<String>,
/// Modifier state accompanying the key.
pub modifiers: BrowserInputModifiers,
}
/// Mouse button used by a forwarded terminal mouse event.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BrowserMouseButton {
#[default]
None,
Left,
Middle,
Right,
}
impl BrowserMouseButton {
pub(crate) fn as_cdp(self) -> &'static str {
match self {
Self::None => "none",
Self::Left => "left",
Self::Middle => "middle",
Self::Right => "right",
}
}
pub(crate) fn cdp_buttons_mask(self) -> u8 {
match self {
Self::None => 0,
Self::Left => 1,
Self::Right => 2,
Self::Middle => 4,
}
}
}
/// Kind of mouse event forwarded during exclusive human control.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BrowserMouseKind {
/// Pointer movement without a button transition.
Move,
/// Mouse-button press.
Down,
/// Mouse-button release.
Up,
/// Scroll-wheel movement in CSS pixels.
Wheel { delta_x: f64, delta_y: f64 },
}
/// Terminal-relative mouse event that Carbonyl maps into its current CSS viewport.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BrowserMouseInput {
/// Mouse operation to perform.
pub kind: BrowserMouseKind,
/// Button associated with the event.
pub button: BrowserMouseButton,
/// Zero-based terminal column inside the rendered browser viewport.
pub column: u16,
/// Zero-based terminal row inside the rendered browser viewport.
pub row: u16,
/// Width of the rendered browser viewport in terminal cells.
pub viewport_cols: u16,
/// Height of the rendered browser viewport in terminal cells.
pub viewport_rows: u16,
/// Modifier state accompanying the mouse event.
pub modifiers: BrowserInputModifiers,
}
+46
View File
@@ -0,0 +1,46 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod accessibility;
mod actions;
mod cdp;
mod devtools;
mod diagnostics;
mod error;
mod handles;
mod human_control;
mod input;
mod navigation;
mod network;
mod process;
mod profile;
mod profile_control;
mod runtime;
mod sandbox;
mod screen;
mod scripts;
mod session;
mod tool_dispatch;
mod url_policy;
pub use actions::BrowserToolOutput;
pub use diagnostics::BrowserDoctorReport;
pub use error::BrowserToolFailure;
pub use error::classify_browser_error;
pub use input::BrowserInputModifiers;
pub use input::BrowserKeyInput;
pub use input::BrowserMouseButton;
pub use input::BrowserMouseInput;
pub use input::BrowserMouseKind;
pub use network::BrowserNetworkPolicy;
pub use sandbox::BrowserLaunchContext;
pub use screen::BrowserCell;
pub use screen::BrowserColor;
pub use screen::BrowserScreen;
pub use screen::BrowserStatus;
pub use screen::BrowserView;
pub use screen::TerminalSize;
pub use session::TerminalBrowser;
#[cfg(test)]
#[path = "terminal_browser_tests.rs"]
mod tests;
+423
View File
@@ -0,0 +1,423 @@
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use serde_json::json;
use tokio::sync::broadcast;
use crate::accessibility::node_center;
use crate::accessibility::node_is_attached;
use crate::actions::BrowserToolOutput;
use crate::actions::page_metadata;
use crate::cdp::CdpClient;
use crate::cdp::CdpEvent;
use crate::handles::BrowserHandles;
const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum LoadState {
DomContentLoaded,
#[default]
Load,
}
impl LoadState {
fn event_method(self) -> &'static str {
match self {
Self::DomContentLoaded => "Page.domContentEventFired",
Self::Load => "Page.loadEventFired",
}
}
fn is_reached(self, ready_state: &str) -> bool {
match self {
Self::DomContentLoaded => matches!(ready_state, "interactive" | "complete"),
Self::Load => ready_state == "complete",
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum NavigationAction {
Goto,
Back,
Forward,
Reload,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct NavigateRequest {
pub(crate) action: NavigationAction,
pub(crate) url: Option<String>,
#[serde(default)]
pub(crate) wait_until: LoadState,
pub(crate) timeout_ms: Option<u64>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum UrlMatch {
#[default]
Exact,
Contains,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum TextState {
Present,
Absent,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum NodeState {
Visible,
Hidden,
Attached,
Detached,
}
#[derive(Debug, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub(crate) enum WaitRequest {
Url {
value: String,
#[serde(default, rename = "match")]
comparison: UrlMatch,
timeout_ms: Option<u64>,
},
LoadState {
state: LoadState,
timeout_ms: Option<u64>,
},
Text {
value: String,
state: TextState,
timeout_ms: Option<u64>,
},
Node {
node_id: String,
state: NodeState,
timeout_ms: Option<u64>,
},
}
pub(crate) async fn navigate(client: &CdpClient, url: &str) -> Result<()> {
tokio::time::timeout(Duration::from_secs(15), async {
let events = client.subscribe_events();
let result = client.call("Page.navigate", json!({ "url": url })).await?;
if let Some(error) = result.get("errorText").and_then(Value::as_str) {
anyhow::bail!("navigation failed: {error}");
}
if let Some(loader_id) = result.get("loaderId").and_then(Value::as_str) {
wait_for_load_event(
events,
LoadState::DomContentLoaded,
Duration::from_secs(15),
Some(loader_id),
)
.await?;
}
Ok(())
})
.await
.context("navigation_timeout: navigation did not complete within 15 seconds")?
}
pub(crate) async fn navigate_request(
client: &CdpClient,
request: &NavigateRequest,
) -> Result<BrowserToolOutput> {
let timeout = wait_timeout(request.timeout_ms)?;
tokio::time::timeout(timeout, navigate_request_inner(client, request, timeout))
.await
.context("navigation_timeout: navigation did not complete before timeoutMs")?
}
async fn navigate_request_inner(
client: &CdpClient,
request: &NavigateRequest,
timeout: Duration,
) -> Result<BrowserToolOutput> {
let mut events = client.subscribe_events();
let (expected_loader, target_url) = match request.action {
NavigationAction::Goto => {
let url = request
.url
.as_deref()
.context("navigate goto requires a URL")?;
let result = client.call("Page.navigate", json!({ "url": url })).await?;
if let Some(error) = result.get("errorText").and_then(Value::as_str) {
anyhow::bail!("navigation failed: {error}");
}
(
result
.get("loaderId")
.and_then(Value::as_str)
.map(str::to_string),
Some(url.to_string()),
)
}
NavigationAction::Back => {
anyhow::ensure!(request.url.is_none(), "navigate back does not accept a URL");
(None, Some(navigate_history(client, /*offset*/ -1).await?))
}
NavigationAction::Forward => {
anyhow::ensure!(
request.url.is_none(),
"navigate forward does not accept a URL"
);
(None, Some(navigate_history(client, /*offset*/ 1).await?))
}
NavigationAction::Reload => {
anyhow::ensure!(
request.url.is_none(),
"navigate reload does not accept a URL"
);
client.call("Page.reload", json!({})).await?;
(None, None)
}
};
if expected_loader.is_none()
&& let Some(target_url) = target_url.as_deref()
{
wait_for_url_ready_state(client, target_url, request.wait_until).await?;
} else {
wait_for_load_event_receiver(
&mut events,
request.wait_until,
timeout,
expected_loader.as_deref(),
)
.await?;
}
let metadata = page_metadata(client).await?;
Ok(BrowserToolOutput::Text(serde_json::to_string(&json!({
"action": request.action,
"url": metadata.url,
"title": metadata.title,
"loadState": request.wait_until,
}))?))
}
pub(crate) async fn wait(
client: &CdpClient,
handles: &BrowserHandles,
request: &WaitRequest,
) -> Result<BrowserToolOutput> {
let timeout = match request {
WaitRequest::Url { timeout_ms, .. }
| WaitRequest::LoadState { timeout_ms, .. }
| WaitRequest::Text { timeout_ms, .. }
| WaitRequest::Node { timeout_ms, .. } => wait_timeout(*timeout_ms)?,
};
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
anyhow::ensure!(
!remaining.is_zero(),
"navigation_timeout: wait condition timed out"
);
let matched = tokio::time::timeout(remaining, wait_condition_met(client, handles, request))
.await
.context("navigation_timeout: wait condition timed out")??;
if matched {
return Ok(BrowserToolOutput::Text(serde_json::to_string(&json!({
"matched": true,
"elapsedMs": timeout.saturating_sub(deadline.saturating_duration_since(Instant::now())).as_millis(),
}))?));
}
anyhow::ensure!(
Instant::now() < deadline,
"navigation_timeout: wait condition timed out"
);
tokio::time::sleep(
WAIT_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())),
)
.await;
}
}
async fn navigate_history(client: &CdpClient, offset: i64) -> Result<String> {
let history = client.call("Page.getNavigationHistory", json!({})).await?;
let current_index = history
.get("currentIndex")
.and_then(Value::as_i64)
.context("navigation history omitted currentIndex")?;
let target_index = current_index + offset;
let entries = history
.get("entries")
.and_then(Value::as_array)
.context("navigation history omitted entries")?;
anyhow::ensure!(
target_index >= 0 && usize::try_from(target_index).is_ok_and(|index| index < entries.len()),
"navigation history has no entry in that direction"
);
let entry_id = entries[usize::try_from(target_index)?]
.get("id")
.and_then(Value::as_i64)
.context("navigation history entry omitted id")?;
let target_url = entries[usize::try_from(target_index)?]
.get("url")
.and_then(Value::as_str)
.context("navigation history entry omitted URL")?
.to_string();
client
.call(
"Page.navigateToHistoryEntry",
json!({ "entryId": entry_id }),
)
.await?;
Ok(target_url)
}
async fn wait_for_url_ready_state(
client: &CdpClient,
target_url: &str,
load_state: LoadState,
) -> Result<()> {
loop {
let state = client
.evaluate("({ url: location.href, readyState: document.readyState })")
.await?;
if state.get("url").and_then(Value::as_str) == Some(target_url)
&& state
.get("readyState")
.and_then(Value::as_str)
.is_some_and(|state| load_state.is_reached(state))
{
return Ok(());
}
tokio::time::sleep(WAIT_POLL_INTERVAL).await;
}
}
async fn wait_for_load_event(
mut events: broadcast::Receiver<CdpEvent>,
load_state: LoadState,
timeout: Duration,
expected_loader: Option<&str>,
) -> Result<()> {
wait_for_load_event_receiver(&mut events, load_state, timeout, expected_loader).await
}
async fn wait_for_load_event_receiver(
events: &mut broadcast::Receiver<CdpEvent>,
load_state: LoadState,
wait_timeout: Duration,
expected_loader: Option<&str>,
) -> Result<()> {
tokio::time::timeout(wait_timeout, async {
loop {
match events.recv().await {
Ok(CdpEvent::Message(message))
if navigation_event_matches(&message, load_state, expected_loader) =>
{
return Ok(());
}
Ok(CdpEvent::Message(_)) | Err(broadcast::error::RecvError::Lagged(_)) => {}
Ok(CdpEvent::Disconnected(reason)) => anyhow::bail!(reason),
Err(broadcast::error::RecvError::Closed) => {
anyhow::bail!("Carbonyl closed the DevTools connection")
}
}
}
})
.await
.context("navigation_timeout: navigation did not reach the requested load state")?
}
fn navigation_event_matches(
message: &Value,
load_state: LoadState,
expected_loader: Option<&str>,
) -> bool {
if message.get("method").and_then(Value::as_str) == Some("Page.lifecycleEvent") {
let name = message.pointer("/params/name").and_then(Value::as_str);
let reached = match load_state {
LoadState::DomContentLoaded => matches!(name, Some("DOMContentLoaded" | "load")),
LoadState::Load => name == Some("load"),
};
return reached
&& expected_loader.is_none_or(|loader| {
message.pointer("/params/loaderId").and_then(Value::as_str) == Some(loader)
});
}
expected_loader.is_none()
&& message.get("method").and_then(Value::as_str) == Some(load_state.event_method())
}
fn wait_timeout(timeout_ms: Option<u64>) -> Result<Duration> {
let timeout = timeout_ms.map_or(DEFAULT_WAIT_TIMEOUT, Duration::from_millis);
anyhow::ensure!(!timeout.is_zero(), "timeoutMs must be greater than zero");
anyhow::ensure!(timeout <= MAX_WAIT_TIMEOUT, "timeoutMs cannot exceed 30000");
Ok(timeout)
}
async fn wait_condition_met(
client: &CdpClient,
handles: &BrowserHandles,
request: &WaitRequest,
) -> Result<bool> {
match request {
WaitRequest::Url {
value, comparison, ..
} => {
anyhow::ensure!(value.len() <= 4 * 1024, "URL wait value exceeds 4 KiB");
let current = client.evaluate("location.href").await?;
let current = current.as_str().unwrap_or_default();
Ok(match comparison {
UrlMatch::Exact => current == value,
UrlMatch::Contains => current.contains(value),
})
}
WaitRequest::LoadState { state, .. } => {
let ready_state = client.evaluate("document.readyState").await?;
Ok(ready_state
.as_str()
.is_some_and(|value| state.is_reached(value)))
}
WaitRequest::Text { value, state, .. } => {
anyhow::ensure!(value.len() <= 4 * 1024, "text wait value exceeds 4 KiB");
let value = serde_json::to_string(value)?;
let present = client
.evaluate(&format!(
"Boolean(document.body?.innerText.includes({value}))"
))
.await?
.as_bool()
.unwrap_or(/*default*/ false);
Ok(match state {
TextState::Present => present,
TextState::Absent => !present,
})
}
WaitRequest::Node { node_id, state, .. } => {
let backend_node_id = handles.resolve(node_id)?;
let attached = node_is_attached(client, backend_node_id).await?;
let visible = attached && node_center(client, backend_node_id).await.is_ok();
Ok(match state {
NodeState::Visible => visible,
NodeState::Hidden => attached && !visible,
NodeState::Attached => attached,
NodeState::Detached => !attached,
})
}
}
}
+11
View File
@@ -0,0 +1,11 @@
use std::net::SocketAddr;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum BrowserNetworkPolicy {
#[default]
Disabled,
Direct,
ManagedProxy {
http_addr: SocketAddr,
},
}
+493
View File
@@ -0,0 +1,493 @@
use std::sync::Arc;
use std::sync::PoisonError;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_utils_pty::ProcessHandle;
use codex_utils_pty::spawn_pty_process;
use tokio::sync::mpsc;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use crate::cdp::CdpClient;
use crate::devtools::deny_downloads;
use crate::devtools::discover_page_target;
use crate::devtools::prepare_devtools_active_port;
use crate::handles::BrowserHandles;
use crate::network::BrowserNetworkPolicy;
use crate::profile::BrowserProfileLock;
use crate::runtime::BrowserRuntime;
use crate::sandbox::prepare_browser_launch;
use crate::screen::BrowserStatus;
use crate::screen::TerminalScreen;
use crate::screen::TerminalSize;
use crate::session::Inner;
use crate::session::RenderMode;
use crate::session::SessionConfig;
use crate::url_policy::spawn_navigation_policy_task;
pub(crate) struct BrowserSession {
pub(crate) config: SessionConfig,
pub(crate) cdp: CdpClient,
pub(crate) handles: BrowserHandles,
_runtime: BrowserRuntime,
_profile_lock: Option<BrowserProfileLock>,
output_task: JoinHandle<()>,
exit_task: JoinHandle<()>,
navigation_policy_task: JoinHandle<()>,
}
struct StartupGuard<'a> {
process_slot: &'a std::sync::Mutex<Option<Arc<ProcessHandle>>>,
process: Option<Arc<ProcessHandle>>,
profile_lock: Option<BrowserProfileLock>,
output_task: Option<JoinHandle<()>>,
exit_task: Option<JoinHandle<()>>,
navigation_policy_task: Option<JoinHandle<()>>,
}
struct CommittedStartup {
profile_lock: Option<BrowserProfileLock>,
output_task: JoinHandle<()>,
exit_task: JoinHandle<()>,
navigation_policy_task: JoinHandle<()>,
}
impl<'a> StartupGuard<'a> {
fn new(
process_slot: &'a std::sync::Mutex<Option<Arc<ProcessHandle>>>,
process: Arc<ProcessHandle>,
profile_lock: Option<BrowserProfileLock>,
) -> Self {
Self {
process_slot,
process: Some(process),
profile_lock,
output_task: None,
exit_task: None,
navigation_policy_task: None,
}
}
fn set_output_task(&mut self, output_task: JoinHandle<()>) {
self.output_task = Some(output_task);
}
fn set_exit_task(&mut self, exit_task: JoinHandle<()>) {
self.exit_task = Some(exit_task);
}
fn set_navigation_policy_task(&mut self, task: JoinHandle<()>) {
self.navigation_policy_task = Some(task);
}
fn commit(mut self) -> Result<CommittedStartup> {
let output_task = self
.output_task
.take()
.context("startup output task must be present")?;
let exit_task = self
.exit_task
.take()
.context("startup exit task must be present")?;
let navigation_policy_task = self
.navigation_policy_task
.take()
.context("startup navigation policy task must be present")?;
self.process.take();
Ok(CommittedStartup {
profile_lock: self.profile_lock.take(),
output_task,
exit_task,
navigation_policy_task,
})
}
}
impl Drop for StartupGuard<'_> {
fn drop(&mut self) {
let Some(process) = self.process.take() else {
return;
};
if let Some(task) = self.output_task.take() {
task.abort();
}
if let Some(task) = self.exit_task.take() {
task.abort();
}
if let Some(task) = self.navigation_policy_task.take() {
task.abort();
}
process.terminate();
let mut current = self
.process_slot
.lock()
.unwrap_or_else(PoisonError::into_inner);
if current
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, &process))
{
current.take();
}
}
}
impl Inner {
pub(crate) async fn ensure_session(self: &Arc<Self>, config: SessionConfig) -> Result<()> {
let expected_network_policy = config.network_policy.clone();
let process_exited = self
.process
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.is_some_and(|process| process.has_exited());
let existing_config = self
.session
.lock()
.await
.as_ref()
.map(|session| session.config.clone());
match existing_config {
None => self.start_session(config).await?,
Some(existing) if process_exited || existing != config => {
let (visible, url) = {
let view = self.view.read().unwrap_or_else(PoisonError::into_inner);
(view.visible, view.url.clone())
};
self.close_session().await;
self.update_view(|view| {
view.status = BrowserStatus::Starting;
view.visible = visible;
view.url = url;
});
self.start_session(config).await?;
}
Some(_) => {}
}
if self.network_policy() != expected_network_policy {
self.close_session().await;
anyhow::bail!("browser network policy changed while Carbonyl was starting");
}
Ok(())
}
async fn validated_binary(&self) -> Result<std::path::PathBuf> {
let binary = self
.binary
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
.context("Carbonyl is unavailable; install it or set CODEX_CARBONYL_BINARY")?;
let installation =
crate::diagnostics::inspect_installation(&binary, &self.launch_context).await?;
let path = installation.path.clone();
*self.installation.lock().await = Some(installation);
Ok(path)
}
async fn start_session(self: &Arc<Self>, config: SessionConfig) -> Result<()> {
let binary = self.validated_binary().await?;
self.closing.store(/*val*/ false, Ordering::SeqCst);
let persistent_profile = self.selected_profile_resources()?;
let runtime =
BrowserRuntime::create(persistent_profile.as_ref().map(|(profile, _lock)| profile))?;
prepare_devtools_active_port(&runtime.profile)?;
let args = carbonyl_args(
/*debugging_port*/ 0,
runtime.profile.as_path().to_string_lossy().as_ref(),
&config.network_policy,
config.render_mode,
);
// Carbonyl is an external browser process. Pass only the process basics it needs instead
// of forwarding Codex credentials and service-specific environment variables.
let env = runtime.environment(&config.network_policy);
let launch = prepare_browser_launch(
&binary,
args,
&runtime.root,
&runtime.profile,
env,
&config.network_policy,
&self.launch_context,
)?;
let size = *self.resize_tx.borrow();
let spawned = match spawn_pty_process(
&launch.program,
&launch.args,
launch.cwd.as_path(),
&launch.env,
&launch.arg0,
size.into(),
)
.await
{
Ok(spawned) => spawned,
Err(error) => {
return Err(error).context("launch Carbonyl");
}
};
let process = Arc::new(spawned.session);
let writer = process.writer_sender();
let profile_lock = persistent_profile.map(|(_profile, lock)| lock);
let mut startup = StartupGuard::new(&self.process, process.clone(), profile_lock);
let resize_result = {
let mut current_process = self.process.lock().unwrap_or_else(PoisonError::into_inner);
*current_process = Some(process.clone());
let current_size = *self.resize_tx.borrow();
(current_size != size)
.then(|| process.resize(current_size.into()))
.transpose()
};
if let Err(error) = resize_result {
return Err(error).context("resize Carbonyl after startup");
}
if self.network_policy() != config.network_policy {
self.closing.store(/*val*/ true, Ordering::SeqCst);
anyhow::bail!("browser network policy changed while Carbonyl was starting");
}
let output_task = spawn_screen_task(
self.clone(),
spawned.stdout_rx,
writer,
self.resize_tx.subscribe(),
);
startup.set_output_task(output_task);
let mut exit_rx = spawned.exit_rx;
let target = tokio::select! {
target = discover_page_target(&runtime.profile) => target,
exit = &mut exit_rx => Err(anyhow!("Carbonyl exited during startup: {exit:?}")),
};
let target = target?;
let cdp = CdpClient::connect(&target.websocket_url).await?;
deny_downloads(&cdp).await?;
startup.set_navigation_policy_task(spawn_navigation_policy_task(self.clone(), cdp.clone()));
let exit_inner = self.clone();
let exit_task = tokio::spawn(async move {
let exit = exit_rx.await;
if !exit_inner.closing.load(Ordering::SeqCst) {
exit_inner.set_crashed(match exit {
Ok(code) => format!("Carbonyl exited with status {code}"),
Err(_) => "lost Carbonyl exit status".to_string(),
});
}
});
startup.set_exit_task(exit_task);
let mut session_slot = self.session.lock().await;
let CommittedStartup {
profile_lock,
output_task,
exit_task,
navigation_policy_task,
} = startup.commit()?;
*session_slot = Some(BrowserSession {
config,
cdp,
handles: BrowserHandles::default(),
_runtime: runtime,
_profile_lock: profile_lock,
output_task,
exit_task,
navigation_policy_task,
});
drop(session_slot);
self.update_view(|view| {
view.status = BrowserStatus::Running;
if !target.title.is_empty() {
view.title = Some(target.title);
}
});
Ok(())
}
fn selected_profile_resources(
&self,
) -> Result<
Option<(
codex_utils_absolute_path::AbsolutePathBuf,
BrowserProfileLock,
)>,
> {
let selected = self
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let Some(selected) = selected else {
return Ok(None);
};
let store = self
.profile_store
.as_ref()
.context("named terminal-browser profiles are unavailable")?;
store.lock_existing(&selected).map(Some)
}
pub(crate) async fn close_session(&self) {
self.closing.store(/*val*/ true, Ordering::SeqCst);
self.human_control.store(/*val*/ false, Ordering::SeqCst);
self.human_control_generation
.fetch_add(/*val*/ 1, Ordering::SeqCst);
let session = self.session.lock().await.take();
let process = self
.process
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(process) = process {
let _ = process.writer_sender().send(vec![b'\x03']).await;
let graceful = tokio::time::timeout(
Duration::from_millis(/*millis*/ 500),
wait_for_exit(process.clone()),
)
.await
.is_ok();
if !graceful {
process.terminate();
}
}
if let Some(session) = session {
session.output_task.abort();
session.exit_task.abort();
session.navigation_policy_task.abort();
}
let status = if self
.binary
.read()
.unwrap_or_else(PoisonError::into_inner)
.is_some()
{
BrowserStatus::Idle
} else {
self.view
.read()
.unwrap_or_else(PoisonError::into_inner)
.status
.clone()
};
self.update_view(|view| {
view.status = status;
view.visible = false;
view.title = None;
view.url = None;
view.human_control = false;
});
}
}
fn spawn_screen_task(
inner: Arc<Inner>,
mut output: mpsc::Receiver<Vec<u8>>,
writer: mpsc::Sender<Vec<u8>>,
mut resize_rx: watch::Receiver<TerminalSize>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let size = *resize_rx.borrow_and_update();
let mut terminal = TerminalScreen::new(size);
let mut interval = tokio::time::interval(Duration::from_millis(/*millis*/ 33));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut dirty = true;
loop {
tokio::select! {
chunk = output.recv() => match chunk {
Some(chunk) => {
for response in terminal.process(&chunk) {
let _ = writer.send(response).await;
}
let starting = matches!(
&inner
.view
.read()
.unwrap_or_else(PoisonError::into_inner)
.status,
BrowserStatus::Starting
);
if starting {
update_screen_view(&inner, &terminal);
dirty = false;
} else {
dirty = true;
}
}
None => break,
},
changed = resize_rx.changed() => match changed {
Ok(()) => {
let size = *resize_rx.borrow_and_update();
terminal.resize(size);
dirty = true;
}
Err(_) => break,
},
_ = interval.tick(), if dirty => {
update_screen_view(&inner, &terminal);
dirty = false;
}
}
}
})
}
fn update_screen_view(inner: &Inner, terminal: &TerminalScreen) {
inner.update_view(|view| {
view.screen = terminal.snapshot();
if let Some(title) = terminal.title() {
view.title = Some(title);
}
});
}
async fn wait_for_exit(process: Arc<ProcessHandle>) {
while !process.has_exited() {
tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await;
}
}
pub(crate) fn carbonyl_args(
debugging_port: u16,
profile: &str,
network_policy: &BrowserNetworkPolicy,
render_mode: RenderMode,
) -> Vec<String> {
let mut args = vec![
"--remote-debugging-address=127.0.0.1".to_string(),
format!("--remote-debugging-port={debugging_port}"),
format!("--user-data-dir={profile}"),
"--disable-extensions".to_string(),
"--disable-background-networking".to_string(),
"--disable-sync".to_string(),
"--disable-default-apps".to_string(),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-component-update".to_string(),
"--password-store=basic".to_string(),
// Every Carbonyl process already inherits the Codex platform sandbox. Chromium cannot
// initialize a second Seatbelt profile from inside that sandbox on macOS.
"--no-sandbox".to_string(),
// Carbonyl does not need a hardware GPU for terminal rendering, and keeping rendering in
// software avoids granting the browser access to platform GPU device interfaces.
"--disable-gpu".to_string(),
"--disable-quic".to_string(),
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string(),
];
match network_policy {
BrowserNetworkPolicy::Disabled | BrowserNetworkPolicy::Direct => {}
BrowserNetworkPolicy::ManagedProxy { http_addr } => {
args.push(format!("--proxy-server=http://{http_addr}"));
args.push("--proxy-bypass-list=<-loopback>".to_string());
}
}
if render_mode == RenderMode::Bitmap {
args.push("--bitmap".to_string());
}
args.push("about:blank".to_string());
args
}
#[cfg(test)]
#[path = "process_tests.rs"]
mod tests;
@@ -0,0 +1,98 @@
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::spawn_from_driver;
use pretty_assertions::assert_eq;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use super::StartupGuard;
use crate::devtools::prepare_devtools_active_port;
#[tokio::test]
async fn canceled_startup_terminates_the_process_and_aborts_output() {
let (writer_tx, _writer_rx) = mpsc::channel(/*buffer*/ 1);
let (_output_tx, output_rx) = broadcast::channel(/*capacity*/ 1);
let (_exit_tx, exit_rx) = oneshot::channel();
let terminated = Arc::new(AtomicBool::new(/*v*/ false));
let terminate_flag = terminated.clone();
let spawned = spawn_from_driver(ProcessDriver {
writer_tx,
stdout_rx: output_rx,
stderr_rx: None,
exit_rx,
terminator: Some(Box::new(move || {
terminate_flag.store(/*val*/ true, Ordering::SeqCst);
})),
writer_handle: None,
resizer: None,
});
let process = Arc::new(spawned.session);
let process_slot = Mutex::new(Some(process.clone()));
let output_task = tokio::spawn(std::future::pending::<()>());
let output_abort = output_task.abort_handle();
let mut startup = StartupGuard::new(&process_slot, process, /*profile_lock*/ None);
startup.set_output_task(output_task);
drop(startup);
tokio::task::yield_now().await;
assert!(terminated.load(Ordering::SeqCst));
assert!(output_abort.is_finished());
assert!(process_slot.lock().expect("process slot").is_none());
}
#[test]
fn stale_devtools_active_port_is_removed_before_spawn() {
let profile = tempfile::tempdir().expect("profile directory");
let active_port = profile.path().join("DevToolsActivePort");
std::fs::write(&active_port, "9222\n").expect("stale active port");
prepare_devtools_active_port(profile.path()).expect("prepare active port");
assert!(!active_port.exists());
prepare_devtools_active_port(profile.path()).expect("missing active port is valid");
}
#[test]
fn non_file_devtools_active_port_is_rejected() {
let profile = tempfile::tempdir().expect("profile directory");
let active_port = profile.path().join("DevToolsActivePort");
std::fs::create_dir(&active_port).expect("active port directory");
let error = prepare_devtools_active_port(profile.path())
.expect_err("active port directory must be rejected");
assert_eq!(
error.to_string(),
"refusing non-file Carbonyl DevToolsActivePort"
);
}
#[cfg(unix)]
#[test]
fn symbolic_link_devtools_active_port_is_rejected() {
use std::os::unix::fs::symlink;
let profile = tempfile::tempdir().expect("profile directory");
let target = profile.path().join("target");
let active_port = profile.path().join("DevToolsActivePort");
std::fs::write(&target, "9222\n").expect("target active port");
symlink(&target, &active_port).expect("active port symlink");
let error = prepare_devtools_active_port(profile.path())
.expect_err("active port symlink must be rejected");
assert_eq!(
error.to_string(),
"refusing symbolic-link Carbonyl DevToolsActivePort"
);
assert_eq!(
std::fs::read_to_string(target).expect("target remains"),
"9222\n"
);
}
+228
View File
@@ -0,0 +1,228 @@
use std::fmt::Write;
#[cfg(unix)]
use std::fs::File;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use sha2::Digest;
use sha2::Sha256;
use crate::sandbox::BrowserLaunchContext;
const MAX_PROFILE_NAME_BYTES: usize = 64;
const MAX_LISTED_PROFILES: usize = 100;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct BrowserProfileName(String);
impl BrowserProfileName {
pub(crate) fn parse(name: &str) -> Result<Self> {
let mut bytes = name.bytes();
let first = bytes.next().context("profile name must not be empty")?;
let valid_first = first.is_ascii_alphanumeric();
let valid_rest =
bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
anyhow::ensure!(
name.len() <= MAX_PROFILE_NAME_BYTES && valid_first && valid_rest,
"profile names must start with a letter or number and contain at most 64 ASCII letters, numbers, dots, underscores, or dashes"
);
Ok(Self(name.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug)]
pub(crate) struct BrowserProfileStore {
root: AbsolutePathBuf,
}
pub(crate) struct BrowserProfileListing {
pub(crate) profiles: Vec<String>,
pub(crate) total: usize,
pub(crate) truncated: bool,
}
pub(crate) struct BrowserProfileLock {
#[cfg(unix)]
file: File,
}
impl BrowserProfileStore {
pub(crate) fn from_context(context: &BrowserLaunchContext) -> Result<Option<Self>> {
let (Some(codex_home), Some(workspace_root)) =
(&context.codex_home, &context.workspace_root)
else {
return Ok(None);
};
let mut workspace_hash = String::with_capacity(/*capacity*/ 16);
let digest = Sha256::digest(workspace_root.as_os_str().as_encoded_bytes());
for byte in &digest[..8] {
write!(&mut workspace_hash, "{byte:02x}").context("format workspace profile hash")?;
}
let root = codex_home
.join("browser-profiles")
.join("terminal-browser")
.join(workspace_hash);
Ok(Some(Self { root }))
}
pub(crate) fn list(&self) -> Result<BrowserProfileListing> {
let entries = match std::fs::read_dir(self.root.as_path()) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(BrowserProfileListing {
profiles: Vec::new(),
total: 0,
truncated: false,
});
}
Err(error) => return Err(error).context("list terminal-browser profiles"),
};
let mut profiles = Vec::new();
let mut total = 0;
for entry in entries {
let entry = entry.context("read terminal-browser profile entry")?;
let file_type = entry
.file_type()
.context("read terminal-browser profile type")?;
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if file_type.is_dir() && BrowserProfileName::parse(&name).is_ok() {
total += 1;
if profiles.len() < MAX_LISTED_PROFILES {
profiles.push(name);
}
}
}
profiles.sort_unstable();
Ok(BrowserProfileListing {
profiles,
total,
truncated: total > MAX_LISTED_PROFILES,
})
}
pub(crate) fn create(&self, name: &BrowserProfileName) -> Result<AbsolutePathBuf> {
create_private_directory(self.root.as_path())?;
let path = self.root.join(name.as_str());
anyhow::ensure!(
!std::fs::symlink_metadata(path.as_path())
.is_ok_and(|metadata| metadata.file_type().is_symlink()),
"profile path must not be a symbolic link"
);
create_private_directory(path.as_path())?;
Ok(path)
}
pub(crate) fn existing_path(&self, name: &BrowserProfileName) -> Result<AbsolutePathBuf> {
let path = self.root.join(name.as_str());
let metadata = std::fs::symlink_metadata(path.as_path())
.with_context(|| format!("browser profile `{}` does not exist", name.as_str()))?;
anyhow::ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"browser profile path is not a safe directory"
);
Ok(path)
}
pub(crate) fn lock_existing(
&self,
name: &BrowserProfileName,
) -> Result<(AbsolutePathBuf, BrowserProfileLock)> {
self.existing_path(name)?;
let lock = self.acquire_lock(name)?;
// A cooperating process cannot delete the profile after the lock is acquired, but
// rechecking keeps us fail-closed if an older Codex process raced with this one.
let path = self.existing_path(name)?;
Ok((path, lock))
}
pub(crate) fn forget(&self, name: &BrowserProfileName) -> Result<()> {
let _lock = self.acquire_lock(name)?;
let path = self.existing_path(name)?;
std::fs::remove_dir_all(path.as_path())
.with_context(|| format!("delete browser profile `{}`", name.as_str()))
}
#[cfg(unix)]
fn acquire_lock(&self, name: &BrowserProfileName) -> Result<BrowserProfileLock> {
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
create_private_directory(self.root.as_path())?;
let lock_path = self.root.join(format!(".{}.lock", name.as_str()));
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(/*mode*/ 0o600)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(lock_path.as_path())
.with_context(|| format!("open browser profile lock for `{}`", name.as_str()))?;
anyhow::ensure!(
file.metadata()
.context("read browser profile lock metadata")?
.is_file(),
"browser profile lock path is not a regular file"
);
// SAFETY: `file` owns a valid descriptor for the duration of this call and remains owned
// by `BrowserProfileLock` for as long as the advisory lock must be held.
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
return Ok(BrowserProfileLock { file });
}
let error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::WouldBlock {
anyhow::bail!("browser profile `{}` is already in use", name.as_str());
}
Err(error).with_context(|| format!("lock browser profile `{}`", name.as_str()))
}
#[cfg(not(unix))]
fn acquire_lock(&self, _name: &BrowserProfileName) -> Result<BrowserProfileLock> {
anyhow::bail!("named terminal-browser profiles are unsupported on this platform")
}
}
#[cfg(unix)]
impl Drop for BrowserProfileLock {
fn drop(&mut self) {
use std::os::fd::AsRawFd;
// SAFETY: `self.file` remains open for the duration of the call. Closing the file would
// also release the lock, but an explicit unlock makes the lifetime boundary unambiguous.
let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
}
}
fn create_private_directory(path: &Path) -> Result<()> {
std::fs::create_dir_all(path)
.with_context(|| format!("create browser profile directory {}", path.display()))?;
set_private_permissions(path)
}
#[cfg(unix)]
fn set_private_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(/*mode*/ 0o700))
.with_context(|| format!("restrict browser profile directory {}", path.display()))
}
#[cfg(not(unix))]
fn set_private_permissions(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
#[path = "profile_tests.rs"]
mod tests;
@@ -0,0 +1,199 @@
use std::sync::PoisonError;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use serde::Deserialize;
use serde_json::Value;
use crate::actions::BrowserToolOutput;
use crate::profile::BrowserProfileName;
use crate::session::TerminalBrowser;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
enum ProfileAction {
List,
RequestCreate,
RequestSelect,
RequestEphemeral,
RequestForget,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProfileArgs {
action: ProfileAction,
name: Option<String>,
}
impl TerminalBrowser {
pub fn profiles(&self) -> Result<Vec<String>> {
Ok(self
.inner
.profile_store
.as_ref()
.context("named terminal-browser profiles are unavailable")?
.list()?
.profiles)
}
pub fn selected_profile(&self) -> Option<String> {
self.inner
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.map(|name| name.as_str().to_string())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "profile changes must serialize with browser actions and lifecycle transitions"
)]
pub async fn create_profile(&self, name: &str) -> Result<()> {
let _operation = self.inner.operation.lock().await;
let name = BrowserProfileName::parse(name)?;
let store = self
.inner
.profile_store
.as_ref()
.context("named terminal-browser profiles are unavailable")?;
store.create(&name)?;
self.inner.close_session().await;
*self
.inner
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(name);
Ok(())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "profile changes must serialize with browser actions and lifecycle transitions"
)]
pub async fn select_profile(&self, name: &str) -> Result<()> {
let _operation = self.inner.operation.lock().await;
let name = BrowserProfileName::parse(name)?;
let store = self
.inner
.profile_store
.as_ref()
.context("named terminal-browser profiles are unavailable")?;
store.existing_path(&name)?;
self.inner.close_session().await;
*self
.inner
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(name);
Ok(())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "profile changes must serialize with browser actions and lifecycle transitions"
)]
pub async fn select_ephemeral_profile(&self) -> Result<()> {
let _operation = self.inner.operation.lock().await;
self.inner.close_session().await;
self.inner
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
Ok(())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "profile changes must serialize with browser actions and lifecycle transitions"
)]
pub async fn forget_profile(&self, name: &str) -> Result<()> {
let _operation = self.inner.operation.lock().await;
let name = BrowserProfileName::parse(name)?;
let selected = self
.inner
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
== Some(&name);
if selected {
self.inner.close_session().await;
self.inner
.selected_profile
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
}
self.inner
.profile_store
.as_ref()
.context("named terminal-browser profiles are unavailable")?
.forget(&name)
}
pub(crate) fn execute_profile_tool(&self, arguments: Value) -> Result<BrowserToolOutput> {
let args: ProfileArgs = serde_json::from_value(arguments)?;
match args.action {
ProfileAction::List => {
anyhow::ensure!(args.name.is_none(), "profile list does not accept a name");
let listing = self
.inner
.profile_store
.as_ref()
.context("named terminal-browser profiles are unavailable")?
.list()?;
Ok(BrowserToolOutput::Text(serde_json::to_string(
&serde_json::json!({
"profiles": listing.profiles,
"total": listing.total,
"truncated": listing.truncated,
"selected": self.selected_profile(),
}),
)?))
}
ProfileAction::RequestEphemeral => {
anyhow::ensure!(
args.name.is_none(),
"profile requestEphemeral does not accept a name"
);
approval_required("ephemeral", "/browser profile ephemeral")
}
action => {
let name = args
.name
.as_deref()
.context("profile mutation requests require a name")?;
let name = BrowserProfileName::parse(name)?;
let command = match action {
ProfileAction::RequestCreate => {
format!("/browser profile create {}", name.as_str())
}
ProfileAction::RequestSelect => {
format!("/browser profile use {}", name.as_str())
}
ProfileAction::RequestForget => {
format!("/browser profile forget {} --confirm", name.as_str())
}
ProfileAction::List | ProfileAction::RequestEphemeral => {
return Err(anyhow!("invalid profile action"));
}
};
approval_required(name.as_str(), &command)
}
}
}
}
fn approval_required(profile: &str, command: &str) -> Result<BrowserToolOutput> {
Ok(BrowserToolOutput::Text(serde_json::to_string(
&serde_json::json!({
"status": "approvalRequired",
"profile": profile,
"command": command,
}),
)?))
}
@@ -0,0 +1,74 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use super::BrowserProfileName;
use super::BrowserProfileStore;
use crate::sandbox::BrowserLaunchContext;
#[test]
fn profile_names_reject_traversal_and_non_ascii_input() {
for name in ["", ".hidden", "../other", "name/child", "café"] {
assert!(
BrowserProfileName::parse(name).is_err(),
"accepted {name:?}"
);
}
assert_eq!(
BrowserProfileName::parse("work-login_2")
.expect("valid profile")
.as_str(),
"work-login_2"
);
}
#[test]
fn profiles_are_scoped_by_workspace_and_can_be_forgotten() {
let codex_home = tempfile::tempdir().expect("codex home");
let codex_home = AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("absolute home");
let workspace_root = codex_home.join("workspace");
let context = BrowserLaunchContext {
codex_home: Some(codex_home),
workspace_root: Some(workspace_root),
..Default::default()
};
let store = BrowserProfileStore::from_context(&context)
.expect("profile store")
.expect("configured profile store");
let profile = BrowserProfileName::parse("work").expect("profile name");
let path = store.create(&profile).expect("create profile");
assert!(path.as_path().is_dir());
assert_eq!(store.list().expect("list profiles").profiles, vec!["work"]);
store.forget(&profile).expect("forget profile");
assert!(store.list().expect("list profiles").profiles.is_empty());
}
#[cfg(unix)]
#[test]
fn forgetting_a_locked_profile_is_refused() {
let codex_home = tempfile::tempdir().expect("codex home");
let codex_home = AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("absolute home");
let workspace_root = codex_home.join("workspace");
let context = BrowserLaunchContext {
codex_home: Some(codex_home),
workspace_root: Some(workspace_root),
..Default::default()
};
let store = BrowserProfileStore::from_context(&context)
.expect("profile store")
.expect("configured profile store");
let profile = BrowserProfileName::parse("work").expect("profile name");
store.create(&profile).expect("create profile");
let (_path, profile_lock) = store.lock_existing(&profile).expect("lock profile");
let error = store
.forget(&profile)
.expect_err("locked profile must not be deleted");
assert_eq!(
error.to_string(),
"browser profile `work` is already in use"
);
drop(profile_lock);
store.forget(&profile).expect("forget unlocked profile");
}
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::TempDir;
use crate::network::BrowserNetworkPolicy;
pub(crate) struct BrowserRuntime {
_root: TempDir,
pub(crate) root: AbsolutePathBuf,
pub(crate) profile: AbsolutePathBuf,
home: AbsolutePathBuf,
temporary: AbsolutePathBuf,
xdg_runtime: AbsolutePathBuf,
}
impl BrowserRuntime {
pub(crate) fn create(persistent_profile: Option<&AbsolutePathBuf>) -> Result<Self> {
let root = tempfile::Builder::new()
.prefix("codex-carbonyl-")
.tempdir()
.context("create Carbonyl runtime directory")?;
let absolute_root = AbsolutePathBuf::from_absolute_path(root.path())
.context("resolve Carbonyl runtime directory")?;
let profile = persistent_profile
.cloned()
.unwrap_or_else(|| absolute_root.join("profile"));
let home = absolute_root.join("home");
let temporary = absolute_root.join("tmp");
let xdg_runtime = absolute_root.join("runtime");
for directory in [&profile, &home, &temporary, &xdg_runtime] {
std::fs::create_dir_all(directory.as_path()).with_context(|| {
format!(
"create Carbonyl runtime path {}",
directory.as_path().display()
)
})?;
set_private_permissions(directory.as_path())?;
}
Ok(Self {
_root: root,
root: absolute_root,
profile,
home,
temporary,
xdg_runtime,
})
}
pub(crate) fn environment(
&self,
network_policy: &BrowserNetworkPolicy,
) -> HashMap<String, String> {
let mut env = HashMap::new();
for key in [
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
] {
if let Ok(value) = std::env::var(key)
&& (!matches!(key, "SSL_CERT_FILE" | "SSL_CERT_DIR")
|| Path::new(&value).is_absolute())
{
env.insert(key.to_string(), value);
}
}
env.insert(
"HOME".to_string(),
self.home.as_path().display().to_string(),
);
for key in ["TMPDIR", "TMP", "TEMP"] {
env.insert(
key.to_string(),
self.temporary.as_path().display().to_string(),
);
}
env.insert(
"XDG_RUNTIME_DIR".to_string(),
self.xdg_runtime.as_path().display().to_string(),
);
env.insert("TERM".to_string(), "xterm-256color".to_string());
env.insert("COLORTERM".to_string(), "truecolor".to_string());
if let BrowserNetworkPolicy::ManagedProxy { http_addr } = network_policy {
let proxy = format!("http://{http_addr}");
env.insert("HTTP_PROXY".to_string(), proxy.clone());
env.insert("HTTPS_PROXY".to_string(), proxy);
env.insert("NO_PROXY".to_string(), "127.0.0.1,localhost".to_string());
}
env
}
}
#[cfg(unix)]
fn set_private_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(/*mode*/ 0o700))
.with_context(|| format!("restrict Carbonyl runtime path {}", path.display()))
}
#[cfg(not(unix))]
fn set_private_permissions(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
@@ -0,0 +1,20 @@
use pretty_assertions::assert_eq;
use super::BrowserRuntime;
use crate::network::BrowserNetworkPolicy;
#[test]
fn runtime_replaces_the_user_home_and_working_storage() {
let runtime = BrowserRuntime::create(/*persistent_profile*/ None).expect("create runtime");
let env = runtime.environment(&BrowserNetworkPolicy::Direct);
assert_eq!(
env.get("HOME"),
Some(&runtime.home.as_path().display().to_string())
);
assert_eq!(
env.get("TMPDIR"),
Some(&runtime.temporary.as_path().display().to_string())
);
assert!(runtime.profile.starts_with(runtime.root.as_path()));
}
+259
View File
@@ -0,0 +1,259 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxDirectSpawnTransformRequest;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::WindowsSandboxProxySettingsMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use crate::network::BrowserNetworkPolicy;
/// Host-local inputs needed to wrap Carbonyl in the Codex platform sandbox.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BrowserLaunchContext {
/// Codex data directory used to store optional named browser profiles.
pub codex_home: Option<AbsolutePathBuf>,
/// Workspace whose identity scopes optional named browser profiles.
pub workspace_root: Option<AbsolutePathBuf>,
/// Absolute path to the Codex Linux sandbox helper, when running on Linux.
pub codex_linux_sandbox_exe: Option<AbsolutePathBuf>,
/// Whether to request the deprecated Landlock implementation instead of bubblewrap.
pub use_legacy_landlock: bool,
}
pub(crate) struct PreparedBrowserLaunch {
pub(crate) program: String,
pub(crate) args: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) env: HashMap<String, String>,
pub(crate) arg0: Option<String>,
}
pub(crate) fn prepare_browser_launch(
binary: &Path,
args: Vec<String>,
browser_root: &AbsolutePathBuf,
profile_root: &AbsolutePathBuf,
env: HashMap<String, String>,
network_policy: &BrowserNetworkPolicy,
context: &BrowserLaunchContext,
) -> Result<PreparedBrowserLaunch> {
if matches!(network_policy, BrowserNetworkPolicy::ManagedProxy { .. }) {
anyhow::bail!(
"managed terminal-browser networking is not yet supported because Carbonyl needs a loopback CDP listener that the current sandbox cannot permit without bypassing the managed proxy"
);
}
let binary = AbsolutePathBuf::from_absolute_path(binary)
.context("resolve Carbonyl executable path")?
.canonicalize()
.context("canonicalize Carbonyl executable")?;
let binary_root = binary
.parent()
.context("Carbonyl executable has no parent directory")?;
ensure_isolated_binary_root(&binary_root, browser_root, profile_root, context)?;
let file_system_policy =
browser_file_system_policy(browser_root, profile_root, &binary_root, &env)?;
let network_sandbox_policy = match network_policy {
BrowserNetworkPolicy::Disabled | BrowserNetworkPolicy::ManagedProxy { .. } => {
NetworkSandboxPolicy::Restricted
}
BrowserNetworkPolicy::Direct => NetworkSandboxPolicy::Enabled,
};
let permissions =
PermissionProfile::from_runtime_permissions(&file_system_policy, network_sandbox_policy);
let enforce_managed_network = false;
let manager = SandboxManager::new();
let sandbox = manager.select_initial(
&file_system_policy,
network_sandbox_policy,
SandboxablePreference::Require,
WindowsSandboxLevel::Disabled,
enforce_managed_network,
);
anyhow::ensure!(
sandbox != SandboxType::None,
"terminal browser sandbox is unavailable on this platform"
);
let browser_root_uri = PathUri::from_abs_path(browser_root);
let mut request = manager
.transform_for_direct_spawn(SandboxDirectSpawnTransformRequest {
transform: SandboxTransformRequest {
command: SandboxCommand {
program: OsString::from(binary.as_os_str()),
args,
cwd: browser_root_uri.clone(),
env,
managed_network: None,
additional_permissions: None,
},
permissions: &permissions,
sandbox,
enforce_managed_network,
environment_id: None,
network: None,
sandbox_policy_cwd: &browser_root_uri,
codex_linux_sandbox_exe: context
.codex_linux_sandbox_exe
.as_ref()
.map(AbsolutePathBuf::as_path),
use_legacy_landlock: context.use_legacy_landlock,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
},
workspace_roots: &[],
windows_sandbox_proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile,
})
.context("prepare Carbonyl sandbox")?;
#[cfg(target_os = "macos")]
if request.sandbox == SandboxType::MacosSeatbelt {
anyhow::ensure!(
request.command.get(1).map(String::as_str) == Some("-p"),
"Carbonyl sandbox returned an unexpected Seatbelt command"
);
let profile = request
.command
.get_mut(2)
.context("Carbonyl sandbox omitted the Seatbelt profile")?;
// Chromium's browser process registers a PID-scoped rendezvous service that its children
// look up to exchange Mach ports. Keep both grants specific to Carbonyl's fixed service
// name; the shared Codex Seatbelt policy intentionally does not permit arbitrary Mach IPC.
profile.push_str(
r#"
(allow mach-register mach-lookup
(global-name-prefix "org.chromium.Chromium.MachPortRendezvousServer."))"#,
);
}
let (program, args) = request
.command
.split_first()
.context("Carbonyl sandbox returned an empty command")?;
let cwd = request
.cwd
.to_abs_path()
.context("resolve sandboxed Carbonyl working directory")?;
Ok(PreparedBrowserLaunch {
program: program.clone(),
args: args.to_vec(),
cwd,
env: request.env,
arg0: request.arg0,
})
}
fn ensure_isolated_binary_root(
binary_root: &AbsolutePathBuf,
browser_root: &AbsolutePathBuf,
profile_root: &AbsolutePathBuf,
context: &BrowserLaunchContext,
) -> Result<()> {
for protected_root in [
Some(browser_root),
Some(profile_root),
context.codex_home.as_ref(),
context.workspace_root.as_ref(),
]
.into_iter()
.flatten()
{
let canonical_root = protected_root
.canonicalize()
.unwrap_or_else(|_| protected_root.clone());
if binary_root.as_path().starts_with(canonical_root.as_path())
|| canonical_root.as_path().starts_with(binary_root.as_path())
{
anyhow::bail!(
"Carbonyl must be installed in a dedicated bundle outside the workspace, Codex home, and browser data directories"
);
}
}
Ok(())
}
fn browser_file_system_policy(
browser_root: &AbsolutePathBuf,
profile_root: &AbsolutePathBuf,
binary_root: &AbsolutePathBuf,
env: &HashMap<String, String>,
) -> Result<FileSystemSandboxPolicy> {
let mut entries = vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: binary_root.clone(),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: browser_root.clone(),
},
access: FileSystemAccessMode::Write,
},
];
#[cfg(target_os = "macos")]
for (path, label) in [
(
"/System/Library/CoreServices/SystemAppearance.bundle",
"macOS system appearance bundle",
),
(
"/System/Library/CoreServices/SystemVersion.bundle",
"macOS system version bundle",
),
("/System/Library/Fonts", "macOS system fonts"),
] {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::from_absolute_path(path)
.with_context(|| format!("resolve the {label}"))?,
},
access: FileSystemAccessMode::Read,
});
}
if profile_root != browser_root && !profile_root.as_path().starts_with(browser_root.as_path()) {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: profile_root.clone(),
},
access: FileSystemAccessMode::Write,
});
}
for key in ["SSL_CERT_FILE", "SSL_CERT_DIR"] {
let Some(path) = env.get(key) else {
continue;
};
let path = AbsolutePathBuf::from_absolute_path_checked(path)
.with_context(|| format!("resolve {key} for the Carbonyl sandbox"))?;
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
});
}
Ok(FileSystemSandboxPolicy::restricted(entries))
}
#[cfg(test)]
#[path = "sandbox_tests.rs"]
mod tests;
@@ -0,0 +1,119 @@
use std::collections::HashMap;
use codex_protocol::permissions::FileSystemAccessMode;
use pretty_assertions::assert_eq;
use super::browser_file_system_policy;
use super::ensure_isolated_binary_root;
use super::prepare_browser_launch;
use crate::network::BrowserNetworkPolicy;
use crate::sandbox::BrowserLaunchContext;
use codex_utils_absolute_path::AbsolutePathBuf;
#[test]
fn browser_policy_can_write_only_its_runtime_root() {
let root = tempfile::tempdir().expect("test root");
let root = AbsolutePathBuf::from_absolute_path(root.path()).expect("absolute test root");
let browser_root = root.join("browser-runtime");
let binary_root = root.join("carbonyl-bundle");
let workspace_root = root.join("workspace");
let policy = browser_file_system_policy(
&browser_root,
&browser_root.join("profile"),
&binary_root,
&HashMap::new(),
)
.expect("browser policy");
assert_eq!(
policy.resolve_access_with_cwd(browser_root.as_path(), browser_root.as_path()),
FileSystemAccessMode::Write
);
assert_eq!(
policy.resolve_access_with_cwd(binary_root.as_path(), browser_root.as_path()),
FileSystemAccessMode::Read
);
assert_eq!(
policy.resolve_access_with_cwd(
browser_root.join("workspace").as_path(),
browser_root.as_path()
),
FileSystemAccessMode::Write
);
assert_eq!(
policy.resolve_access_with_cwd(workspace_root.as_path(), browser_root.as_path()),
FileSystemAccessMode::Deny
);
#[cfg(target_os = "macos")]
{
let readable_system_resources = [
"/System/Library/CoreServices/SystemAppearance.bundle/Contents/Resources/SystemAppearance.car",
"/System/Library/CoreServices/SystemVersion.bundle/Contents/Resources/English.lproj/SystemVersion.strings",
"/System/Library/Fonts/LastResort.otf",
];
let unrelated_core_service = AbsolutePathBuf::from_absolute_path(
"/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder",
)
.expect("absolute unrelated CoreServices path");
for path in readable_system_resources {
let path = AbsolutePathBuf::from_absolute_path(path).expect("absolute system path");
assert_eq!(
policy.resolve_access_with_cwd(path.as_path(), browser_root.as_path()),
FileSystemAccessMode::Read
);
}
assert_eq!(
policy
.resolve_access_with_cwd(unrelated_core_service.as_path(), browser_root.as_path()),
FileSystemAccessMode::Deny
);
}
}
#[test]
fn managed_proxy_launches_fail_closed() {
let root = tempfile::tempdir().expect("test root");
let root = AbsolutePathBuf::from_absolute_path(root.path()).expect("absolute test root");
let Err(error) = prepare_browser_launch(
root.join("carbonyl").as_path(),
Vec::new(),
&root.join("runtime"),
&root.join("runtime/profile"),
HashMap::new(),
&BrowserNetworkPolicy::ManagedProxy {
http_addr: "127.0.0.1:8080".parse().expect("proxy address"),
},
&BrowserLaunchContext::default(),
) else {
panic!("managed proxy must be rejected");
};
assert!(
error
.to_string()
.contains("without bypassing the managed proxy")
);
}
#[test]
fn binary_bundle_must_not_contain_the_workspace() {
let root = tempfile::tempdir().expect("test root");
let root = AbsolutePathBuf::from_absolute_path(root.path()).expect("absolute test root");
let bundle = root.join("bundle");
let workspace = bundle.join("workspace");
let context = BrowserLaunchContext {
workspace_root: Some(workspace),
..Default::default()
};
let error = ensure_isolated_binary_root(
&bundle,
&root.join("runtime"),
&root.join("runtime/profile"),
&context,
)
.expect_err("overlapping bundle must be rejected");
assert!(error.to_string().contains("dedicated bundle"));
}
+236
View File
@@ -0,0 +1,236 @@
use vt100::Callbacks;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TerminalSize {
pub rows: u16,
pub cols: u16,
}
impl From<TerminalSize> for codex_utils_pty::TerminalSize {
fn from(value: TerminalSize) -> Self {
Self {
rows: value.rows,
cols: value.cols,
}
}
}
impl Default for TerminalSize {
fn default() -> Self {
Self {
rows: 30,
cols: 100,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BrowserStatus {
Unavailable { reason: String },
Idle,
Starting,
Running,
Crashed { message: String },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BrowserColor {
#[default]
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BrowserCell {
pub text: String,
pub foreground: BrowserColor,
pub background: BrowserColor,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underlined: bool,
pub reversed: bool,
pub wide_continuation: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowserScreen {
pub rows: u16,
pub cols: u16,
pub cells: Vec<BrowserCell>,
pub cursor: Option<(u16, u16)>,
}
impl BrowserScreen {
pub fn blank(size: TerminalSize) -> Self {
let len = usize::from(size.rows) * usize::from(size.cols);
Self {
rows: size.rows,
cols: size.cols,
cells: vec![BrowserCell::default(); len],
cursor: None,
}
}
pub fn cell(&self, row: u16, col: u16) -> Option<&BrowserCell> {
if row >= self.rows || col >= self.cols {
return None;
}
let index = usize::from(row) * usize::from(self.cols) + usize::from(col);
self.cells.get(index)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowserView {
pub status: BrowserStatus,
pub title: Option<String>,
pub url: Option<String>,
pub visible: bool,
pub human_control: bool,
pub screen: BrowserScreen,
}
impl BrowserView {
pub(crate) fn new(status: BrowserStatus, size: TerminalSize) -> Self {
Self {
status,
title: None,
url: None,
visible: false,
human_control: false,
screen: BrowserScreen::blank(size),
}
}
}
#[derive(Default)]
struct ScreenCallbacks {
title: Option<String>,
}
impl Callbacks for ScreenCallbacks {
fn set_window_title(&mut self, _screen: &mut vt100::Screen, title: &[u8]) {
let title = String::from_utf8_lossy(title);
self.title = Some(title.chars().take(/*n*/ 512).collect());
}
}
pub(crate) struct TerminalScreen {
parser: vt100::Parser<ScreenCallbacks>,
query_responder: TerminalQueryResponder,
}
impl TerminalScreen {
pub(crate) fn new(size: TerminalSize) -> Self {
Self {
parser: vt100::Parser::new_with_callbacks(
size.rows,
size.cols,
/*scrollback_len*/ 0,
ScreenCallbacks::default(),
),
query_responder: TerminalQueryResponder::default(),
}
}
pub(crate) fn process(&mut self, bytes: &[u8]) -> Vec<Vec<u8>> {
self.parser.process(bytes);
self.query_responder.process(bytes)
}
pub(crate) fn resize(&mut self, size: TerminalSize) {
self.parser.screen_mut().set_size(size.rows, size.cols);
}
pub(crate) fn title(&self) -> Option<String> {
self.parser.callbacks().title.clone()
}
pub(crate) fn snapshot(&self) -> BrowserScreen {
let screen = self.parser.screen();
let (rows, cols) = screen.size();
let mut cells = Vec::with_capacity(usize::from(rows) * usize::from(cols));
for row in 0..rows {
for col in 0..cols {
cells.push(screen.cell(row, col).map(cell_from_vt).unwrap_or_default());
}
}
let cursor = (!screen.hide_cursor()).then(|| screen.cursor_position());
BrowserScreen {
rows,
cols,
cells,
cursor,
}
}
}
fn cell_from_vt(cell: &vt100::Cell) -> BrowserCell {
BrowserCell {
text: cell.contents().to_string(),
foreground: color_from_vt(cell.fgcolor()),
background: color_from_vt(cell.bgcolor()),
bold: cell.bold(),
dim: cell.dim(),
italic: cell.italic(),
underlined: cell.underline(),
reversed: cell.inverse(),
wide_continuation: cell.is_wide_continuation(),
}
}
fn color_from_vt(color: vt100::Color) -> BrowserColor {
match color {
vt100::Color::Default => BrowserColor::Default,
vt100::Color::Idx(index) => BrowserColor::Indexed(index),
vt100::Color::Rgb(red, green, blue) => BrowserColor::Rgb(red, green, blue),
}
}
const TRUE_COLOR_QUERY: &[u8] = b"\x1bP$qm\x1b\\";
const TRUE_COLOR_RESPONSE: &[u8] = b"\x1bP1$r48:2:0:0:0m\x1b\\";
const TERMINAL_NAME_QUERY: &[u8] = b"\x1bP+q544e\x1b\\";
const TERMINAL_NAME_RESPONSE: &[u8] = b"\x1bP1+r544e=787465726d2d323536636f6c6f72\x1b\\";
const QUERY_TAIL_LEN: usize = 32;
#[derive(Default)]
pub(crate) struct TerminalQueryResponder {
pending: Vec<u8>,
}
impl TerminalQueryResponder {
pub(crate) fn process(&mut self, bytes: &[u8]) -> Vec<Vec<u8>> {
self.pending.extend_from_slice(bytes);
let mut responses = Vec::new();
drain_query(
&mut self.pending,
TRUE_COLOR_QUERY,
TRUE_COLOR_RESPONSE,
&mut responses,
);
drain_query(
&mut self.pending,
TERMINAL_NAME_QUERY,
TERMINAL_NAME_RESPONSE,
&mut responses,
);
if self.pending.len() > QUERY_TAIL_LEN {
let start = self.pending.len() - QUERY_TAIL_LEN;
self.pending.drain(..start);
}
responses
}
}
fn drain_query(pending: &mut Vec<u8>, query: &[u8], response: &[u8], responses: &mut Vec<Vec<u8>>) {
while let Some(start) = pending
.windows(query.len())
.position(|window| window == query)
{
let end = start + query.len();
pending.drain(start..end);
responses.push(response.to_vec());
}
}
+22
View File
@@ -0,0 +1,22 @@
pub(crate) fn scroll_expression(delta_x: i64, delta_y: i64) -> String {
format!("(() => {{ window.scrollBy({delta_x}, {delta_y}); return {{ scrollX, scrollY }}; }})()")
}
pub(crate) fn key_code(key: &str) -> &str {
match key {
"Enter" => "Enter",
"Tab" => "Tab",
"Escape" => "Escape",
"Backspace" => "Backspace",
"Delete" => "Delete",
"ArrowUp" => "ArrowUp",
"ArrowDown" => "ArrowDown",
"ArrowLeft" => "ArrowLeft",
"ArrowRight" => "ArrowRight",
"Home" => "Home",
"End" => "End",
"PageUp" => "PageUp",
"PageDown" => "PageDown",
_ => key,
}
}
+278
View File
@@ -0,0 +1,278 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use crate::diagnostics::BrowserDoctorReport;
use crate::diagnostics::BrowserInstallation;
use crate::diagnostics::inspect_installation;
use crate::diagnostics::installation_report;
use crate::diagnostics::unavailable_report;
use crate::human_control::HumanInputSender;
use crate::human_control::QueuedHumanInput;
use crate::network::BrowserNetworkPolicy;
use crate::process::BrowserSession;
use crate::profile::BrowserProfileName;
use crate::profile::BrowserProfileStore;
use crate::sandbox::BrowserLaunchContext;
use crate::screen::BrowserStatus;
use crate::screen::BrowserView;
use crate::screen::TerminalSize;
use anyhow::Result;
use codex_utils_pty::ProcessHandle;
use serde::Deserialize;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::watch;
const CARBONYL_BINARY_ENV: &str = "CODEX_CARBONYL_BINARY";
#[derive(Clone)]
pub struct TerminalBrowser {
pub(crate) inner: Arc<Inner>,
}
pub(crate) struct Inner {
pub(crate) binary: RwLock<Option<PathBuf>>,
pub(crate) installation: AsyncMutex<Option<BrowserInstallation>>,
pub(crate) launch_context: BrowserLaunchContext,
pub(crate) profile_store: Option<BrowserProfileStore>,
pub(crate) selected_profile: Mutex<Option<BrowserProfileName>>,
pub(crate) session_key: Mutex<Option<String>>,
pub(crate) network_policy: RwLock<BrowserNetworkPolicy>,
pub(crate) view: RwLock<BrowserView>,
pub(crate) updates: watch::Sender<u64>,
pub(crate) update_sequence: AtomicU64,
pub(crate) operation: AsyncMutex<()>,
pub(crate) session: AsyncMutex<Option<BrowserSession>>,
pub(crate) process: Mutex<Option<Arc<ProcessHandle>>>,
pub(crate) resize_tx: watch::Sender<TerminalSize>,
pub(crate) closing: AtomicBool,
pub(crate) human_control: AtomicBool,
pub(crate) human_control_transition: AtomicBool,
pub(crate) human_control_generation: AtomicU64,
pub(crate) human_input_tx: HumanInputSender,
pub(crate) human_input_rx: Mutex<Option<tokio::sync::mpsc::Receiver<QueuedHumanInput>>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SessionConfig {
pub(crate) render_mode: RenderMode,
pub(crate) network_policy: BrowserNetworkPolicy,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum RenderMode {
#[default]
NativeText,
Bitmap,
}
impl TerminalBrowser {
pub fn discover() -> Self {
Self::discover_with_launch_context(BrowserLaunchContext::default())
}
pub fn discover_with_launch_context(launch_context: BrowserLaunchContext) -> Self {
let size = TerminalSize::default();
let profile_store =
BrowserProfileStore::from_context(&launch_context).unwrap_or_else(|error| {
tracing::warn!(%error, "failed to configure terminal-browser profile storage");
None
});
let binary = discover_binary();
let status = match &binary {
Ok(_) => BrowserStatus::Idle,
Err(reason) => BrowserStatus::Unavailable {
reason: reason.clone(),
},
};
let (updates, _) = watch::channel(/*init*/ 0);
let (resize_tx, _) = watch::channel(size);
let (human_input_tx, human_input_rx) = Self::human_input_channel();
Self {
inner: Arc::new(Inner {
binary: RwLock::new(binary.ok()),
installation: AsyncMutex::new(/*value*/ None),
launch_context,
profile_store,
selected_profile: Mutex::new(/*value*/ None),
session_key: Mutex::new(/*t*/ None),
network_policy: RwLock::new(/*t*/ BrowserNetworkPolicy::Disabled),
view: RwLock::new(BrowserView::new(status, size)),
updates,
update_sequence: AtomicU64::new(/*v*/ 0),
operation: AsyncMutex::new(()),
session: AsyncMutex::new(/*t*/ None),
process: Mutex::new(/*t*/ None),
resize_tx,
closing: AtomicBool::new(/*v*/ false),
human_control: AtomicBool::new(/*v*/ false),
human_control_transition: AtomicBool::new(/*v*/ false),
human_control_generation: AtomicU64::new(/*v*/ 0),
human_input_tx,
human_input_rx: Mutex::new(Some(human_input_rx)),
}),
}
}
pub fn is_available(&self) -> bool {
self.inner
.binary
.read()
.unwrap_or_else(PoisonError::into_inner)
.is_some()
}
pub async fn doctor(&self) -> BrowserDoctorReport {
let binary = match discover_binary() {
Ok(binary) => binary,
Err(reason) => {
self.inner.update_view(|view| {
view.status = BrowserStatus::Unavailable {
reason: reason.clone(),
};
});
return unavailable_report(&reason);
}
};
let result = inspect_installation(&binary, &self.inner.launch_context).await;
let report = installation_report(&result);
if let Ok(installation) = result {
*self
.inner
.binary
.write()
.unwrap_or_else(PoisonError::into_inner) = Some(installation.path.clone());
*self.inner.installation.lock().await = Some(installation);
self.inner.update_view(|view| {
if matches!(view.status, BrowserStatus::Unavailable { .. }) {
view.status = BrowserStatus::Idle;
}
});
}
report
}
pub fn subscribe(&self) -> watch::Receiver<u64> {
self.inner.updates.subscribe()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "policy changes must serialize with browser spawn and stateful CDP calls"
)]
pub async fn set_network_policy(&self, network_policy: BrowserNetworkPolicy) {
let _operation = self.inner.operation.lock().await;
let changed = {
let mut current = self
.inner
.network_policy
.write()
.unwrap_or_else(PoisonError::into_inner);
if *current == network_policy {
false
} else {
*current = network_policy;
true
}
};
if !changed {
return;
}
self.inner.close_session().await;
}
pub fn view(&self) -> BrowserView {
self.inner
.view
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub fn set_visibility(&self, visible: bool) {
self.inner.update_view(|view| view.visible = visible);
}
pub fn resize(&self, size: TerminalSize) -> Result<()> {
anyhow::ensure!(
size.rows > 0 && size.cols > 0,
"browser size must be non-zero"
);
let process = self
.inner
.process
.lock()
.unwrap_or_else(PoisonError::into_inner);
let changed = self.inner.resize_tx.send_if_modified(|current| {
if *current == size {
false
} else {
*current = size;
true
}
});
if !changed {
return Ok(());
}
if let Some(process) = process.as_ref() {
process.resize(size.into())?;
}
Ok(())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "the operation guard prevents close from racing a stateful CDP call"
)]
pub async fn close(&self) {
let _operation = self.inner.operation.lock().await;
self.inner.close_session().await;
}
}
impl Inner {
pub(crate) fn network_policy(&self) -> BrowserNetworkPolicy {
self.network_policy
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub(crate) fn set_crashed(&self, message: String) {
self.human_control.store(/*val*/ false, Ordering::SeqCst);
self.human_control_generation
.fetch_add(/*val*/ 1, Ordering::SeqCst);
self.update_view(|view| {
view.human_control = false;
view.status = BrowserStatus::Crashed { message };
});
}
pub(crate) fn update_view(&self, update: impl FnOnce(&mut BrowserView)) {
update(&mut self.view.write().unwrap_or_else(PoisonError::into_inner));
let sequence = self.update_sequence.fetch_add(/*val*/ 1, Ordering::Relaxed) + 1;
self.updates.send_replace(sequence);
}
}
fn discover_binary() -> std::result::Result<PathBuf, String> {
if !cfg!(any(target_os = "macos", target_os = "linux")) {
return Err("Carbonyl terminal browsing is only supported on macOS and Linux".to_string());
}
if let Some(path) = std::env::var_os(CARBONYL_BINARY_ENV) {
let path = PathBuf::from(path);
return path.is_file().then_some(path).ok_or_else(|| {
format!("{CARBONYL_BINARY_ENV} does not point to a Carbonyl executable")
});
}
which::which("carbonyl").map_err(|_| {
"Carbonyl was not found on PATH; install it or set CODEX_CARBONYL_BINARY".to_string()
})
}
@@ -0,0 +1,349 @@
use pretty_assertions::assert_eq;
use crate::BrowserColor;
use crate::BrowserLaunchContext;
use crate::BrowserNetworkPolicy;
use crate::TerminalBrowser;
use crate::TerminalSize;
use crate::actions::bounded_snapshot_json;
use crate::devtools::validated_websocket_url;
use crate::handles::BrowserHandles;
use crate::process::carbonyl_args;
use crate::screen::TerminalQueryResponder;
use crate::screen::TerminalScreen;
use crate::session::RenderMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::sync::atomic::Ordering;
#[tokio::test]
async fn open_requires_network_enabled_by_the_active_permission_profile() {
let browser = TerminalBrowser::discover();
let error = browser
.execute(
"test-session",
"open",
serde_json::json!({ "url": "https://example.com" }),
)
.await
.expect_err("disabled network policy should reject browser navigation");
assert_eq!(
error.to_string(),
"terminal browser network access is disabled by the active permission profile"
);
}
#[tokio::test]
async fn model_actions_are_rejected_during_human_control() {
let browser = TerminalBrowser::discover();
browser
.inner
.human_control
.store(/*val*/ true, Ordering::SeqCst);
let error = browser
.execute("test-session", "snapshot", serde_json::json!({}))
.await
.expect_err("model action should be rejected");
assert!(error.to_string().contains("human_control_active"));
}
#[test]
fn terminal_browser_ignores_duplicate_resize_events() {
let browser = TerminalBrowser::discover();
let mut resize_rx = browser.inner.resize_tx.subscribe();
let size = TerminalSize {
rows: 40,
cols: 120,
};
browser.resize(size).expect("first resize should succeed");
assert!(resize_rx.has_changed().expect("resize sender is open"));
assert_eq!(*resize_rx.borrow_and_update(), size);
browser
.resize(size)
.expect("duplicate resize should succeed");
assert!(!resize_rx.has_changed().expect("resize sender is open"));
}
#[tokio::test]
async fn model_profile_requests_do_not_mutate_without_user_approval() {
let root = tempfile::tempdir().expect("test root");
let root = AbsolutePathBuf::from_absolute_path(root.path()).expect("absolute test root");
let browser = TerminalBrowser::discover_with_launch_context(BrowserLaunchContext {
codex_home: Some(root.join("codex-home")),
workspace_root: Some(root.join("workspace")),
..Default::default()
});
let output = browser
.execute(
"test-session",
"profile",
serde_json::json!({ "action": "requestCreate", "name": "work" }),
)
.await
.expect("profile request");
let crate::BrowserToolOutput::Text(output) = output else {
panic!("expected text output");
};
let output: serde_json::Value = serde_json::from_str(&output).expect("approval JSON");
assert_eq!(output["status"], "approvalRequired");
assert_eq!(output["command"], "/browser profile create work");
assert!(browser.profiles().expect("profile listing").is_empty());
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
#[ignore = "opt-in smoke test requiring CODEX_CARBONYL_BINARY and a host sandbox"]
async fn configured_real_carbonyl_opens_local_page_and_snapshots() {
use std::io::Read;
use std::io::Write;
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind smoke server");
listener
.set_nonblocking(/*nonblocking*/ true)
.expect("configure smoke server");
let address = listener.local_addr().expect("smoke server address");
let server = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 15);
loop {
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(/*secs*/ 2)))
.expect("set smoke read timeout");
let mut request = [0_u8; 2_048];
let _ = stream.read(&mut request);
let body =
"<!doctype html><title>Carbonyl smoke</title><button>Smoke button</button>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("write smoke response");
return;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
std::time::Instant::now() < deadline,
"Carbonyl did not connect to the smoke server"
);
std::thread::sleep(std::time::Duration::from_millis(/*millis*/ 10));
}
Err(error) => panic!("accept smoke request: {error}"),
}
}
});
let browser = TerminalBrowser::discover();
browser
.resize(TerminalSize {
rows: 40,
cols: 120,
})
.expect("enlarge startup diagnostics");
browser
.set_network_policy(BrowserNetworkPolicy::Direct)
.await;
let open_result = browser
.execute(
"real-smoke",
"open",
serde_json::json!({
"url": format!("http://{address}"),
"visible": false,
}),
)
.await;
if let Err(error) = open_result {
let view = browser.view();
let screen = (0..view.screen.rows)
.map(|row| {
(0..view.screen.cols)
.filter_map(|col| view.screen.cell(row, col))
.map(|cell| cell.text.as_str())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
panic!("open local smoke page: {error}\nCarbonyl output:\n{screen}");
}
let snapshot = browser
.execute("real-smoke", "snapshot", serde_json::json!({}))
.await
.expect("snapshot local smoke page");
let crate::BrowserToolOutput::Text(snapshot) = snapshot else {
panic!("expected text snapshot");
};
assert!(snapshot.contains("Smoke button"));
browser.close().await;
server.join().expect("smoke server thread");
}
#[tokio::test]
async fn terminal_browser_resize_updates_coalesce_to_the_latest_size() {
let browser = TerminalBrowser::discover();
let mut resize_rx = browser.inner.resize_tx.subscribe();
let sizes = [
TerminalSize {
rows: 31,
cols: 101,
},
TerminalSize {
rows: 32,
cols: 102,
},
TerminalSize {
rows: 33,
cols: 103,
},
];
for size in sizes {
browser.resize(size).expect("resize should succeed");
}
resize_rx.changed().await.expect("resize sender is open");
assert_eq!(*resize_rx.borrow_and_update(), sizes[2]);
assert!(!resize_rx.has_changed().expect("resize sender is open"));
}
#[test]
fn terminal_screen_preserves_styles_wide_cells_cursor_and_title() {
let mut terminal = TerminalScreen::new(TerminalSize { rows: 2, cols: 8 });
terminal.process(b"\x1b]2;Example");
terminal.process(b"\x07\x1b[31;44;1;3;4m");
terminal.process("\u{754c}".as_bytes());
let screen = terminal.snapshot();
let cell = screen.cell(/*row*/ 0, /*col*/ 0).expect("first cell");
assert_eq!(cell.text, "\u{754c}");
assert_eq!(cell.foreground, BrowserColor::Indexed(1));
assert_eq!(cell.background, BrowserColor::Indexed(4));
assert!(cell.bold);
assert!(cell.italic);
assert!(cell.underlined);
assert!(
screen
.cell(/*row*/ 0, /*col*/ 1)
.expect("wide continuation")
.wide_continuation
);
assert_eq!(screen.cursor, Some((0, 2)));
assert_eq!(terminal.title().as_deref(), Some("Example"));
}
#[test]
fn terminal_query_responder_handles_queries_split_across_chunks() {
let mut responder = TerminalQueryResponder::default();
assert_eq!(responder.process(b"prefix\x1bP$q"), Vec::<Vec<u8>>::new());
assert_eq!(
responder.process(b"m\x1b\\suffix"),
vec![b"\x1bP1$r48:2:0:0:0m\x1b\\".to_vec()]
);
assert_eq!(responder.process(b"\x1bP+q54"), Vec::<Vec<u8>>::new());
assert_eq!(
responder.process(b"4e\x1b\\"),
vec![b"\x1bP1+r544e=787465726d2d323536636f6c6f72\x1b\\".to_vec()]
);
}
#[test]
fn carbonyl_uses_the_managed_proxy_only_when_policy_provides_one() {
let direct = carbonyl_args(
/*debugging_port*/ 9_222,
"/tmp/profile",
&BrowserNetworkPolicy::Direct,
RenderMode::NativeText,
);
assert!(!direct.iter().any(|arg| arg.starts_with("--proxy-server=")));
let http_addr = "127.0.0.1:43128".parse().expect("valid proxy address");
let proxied = carbonyl_args(
/*debugging_port*/ 9_222,
"/tmp/profile",
&BrowserNetworkPolicy::ManagedProxy { http_addr },
RenderMode::NativeText,
);
assert!(proxied.contains(&"--proxy-server=http://127.0.0.1:43128".to_string()));
assert!(proxied.contains(&"--proxy-bypass-list=<-loopback>".to_string()));
}
#[test]
fn carbonyl_devtools_websocket_must_match_the_private_loopback_listener() {
assert_eq!(
validated_websocket_url(
"ws://127.0.0.1:9222/devtools/page/1",
/*expected_port*/ 9_222,
)
.unwrap(),
"ws://127.0.0.1:9222/devtools/page/1"
);
assert!(
validated_websocket_url(
"ws://192.0.2.10:9222/devtools/page/1",
/*expected_port*/ 9_222,
)
.is_err()
);
assert!(
validated_websocket_url(
"ws://127.0.0.1:9223/devtools/page/1",
/*expected_port*/ 9_222,
)
.is_err()
);
assert!(
validated_websocket_url(
"wss://127.0.0.1:9222/devtools/page/1",
/*expected_port*/ 9_222,
)
.is_err()
);
}
#[test]
fn browser_node_handles_reject_unknown_and_stale_ids() {
let mut handles = BrowserHandles::default();
let node_id = handles.insert(/*backend_node_id*/ 42);
assert_eq!(handles.resolve(&node_id).expect("current handle"), 42);
assert!(handles.resolve("body > button").is_err());
handles.clear();
assert!(handles.resolve(&node_id).is_err());
}
#[test]
fn serialized_snapshot_has_a_hard_output_cap_and_remains_valid_json() {
let nodes = (0..100)
.map(|index| {
serde_json::json!({
"nodeId": format!("d0123456789abcdefn{index}"),
"text": "x".repeat(/*n*/ 1_000),
"value": "y".repeat(/*n*/ 1_000),
})
})
.collect::<Vec<_>>();
let output = bounded_snapshot_json(serde_json::json!({
"url": format!("https://example.com/{}", "u".repeat(/*n*/ 100_000)),
"title": "t".repeat(/*n*/ 100_000),
"nodes": nodes,
"text": "body ".repeat(/*n*/ 10_000),
}))
.unwrap();
assert!(output.len() <= 8 * 1024);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(
parsed.get("truncated"),
Some(&serde_json::Value::Bool(true))
);
}
@@ -0,0 +1,318 @@
use std::sync::PoisonError;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use serde::Deserialize;
use serde_json::Value;
use url::Url;
use crate::accessibility;
use crate::actions;
use crate::actions::BrowserToolOutput;
use crate::navigation;
use crate::navigation::NavigateRequest;
use crate::navigation::NavigationAction;
use crate::navigation::WaitRequest;
use crate::network::BrowserNetworkPolicy;
use crate::process::BrowserSession;
use crate::screen::BrowserStatus;
use crate::session::RenderMode;
use crate::session::SessionConfig;
use crate::session::TerminalBrowser;
use crate::url_policy::is_allowed_browser_url;
const MAX_URL_INPUT_BYTES: usize = 4 * 1024;
const MAX_FILL_TEXT_BYTES: usize = 64 * 1024;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct OpenArgs {
url: String,
visible: Option<bool>,
render_mode: Option<RenderMode>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct NodeArgs {
node_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FillArgs {
node_id: String,
text: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PressArgs {
key: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ScrollArgs {
#[serde(default)]
delta_x: i64,
#[serde(default)]
delta_y: i64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct VisibilityArgs {
visible: bool,
}
impl TerminalBrowser {
#[expect(
clippy::await_holding_invalid_type,
reason = "the operation and session guards serialize stateful CDP calls with lifecycle changes"
)]
pub async fn execute(
&self,
session_key: &str,
tool: &str,
arguments: Value,
) -> Result<BrowserToolOutput> {
if self.is_human_control_active() && !matches!(tool, "set_visibility" | "close") {
anyhow::bail!("human_control_active: return control to the agent before using tools");
}
let _operation = self
.inner
.operation
.try_lock()
.map_err(|_| anyhow!("browser_busy: another terminal browser action is running"))?;
let session_changed = {
let mut current = self
.inner
.session_key
.lock()
.unwrap_or_else(PoisonError::into_inner);
if current.as_deref() == Some(session_key) {
false
} else {
*current = Some(session_key.to_string());
true
}
};
if session_changed {
self.inner.close_session().await;
}
if !matches!(tool, "open" | "set_visibility" | "close") {
let network_policy = self.inner.network_policy();
let policy_changed = self
.inner
.session
.lock()
.await
.as_ref()
.is_some_and(|session| session.config.network_policy != network_policy);
if policy_changed {
self.inner.close_session().await;
anyhow::bail!("browser network policy changed; reopen the page before continuing");
}
}
match tool {
"open" => self.open(serde_json::from_value(arguments)?).await,
"navigate" => {
let request: NavigateRequest = serde_json::from_value(arguments)?;
if request.action == NavigationAction::Goto {
let url = request
.url
.as_deref()
.context("navigate goto requires a URL")?;
validate_browser_url(url)?;
}
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
session.handles.clear();
let output = navigation::navigate_request(&session.cdp, &request).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"wait" => {
let request: WaitRequest = serde_json::from_value(arguments)?;
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output = navigation::wait(&session.cdp, &session.handles, &request).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"profile" => self.execute_profile_tool(arguments),
"snapshot" => {
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output = accessibility::snapshot(&session.cdp, &mut session.handles).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"click" => {
let args: NodeArgs = serde_json::from_value(arguments)?;
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output =
accessibility::click(&session.cdp, &session.handles, &args.node_id).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"fill" => {
let args: FillArgs = serde_json::from_value(arguments)?;
anyhow::ensure!(
args.text.len() <= MAX_FILL_TEXT_BYTES,
"fill text exceeds the 64 KiB input limit"
);
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output =
accessibility::fill(&session.cdp, &session.handles, &args.node_id, &args.text)
.await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"press" => {
let args: PressArgs = serde_json::from_value(arguments)?;
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output = actions::press(&session.cdp, &args.key).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"scroll" => {
let args: ScrollArgs = serde_json::from_value(arguments)?;
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output = actions::scroll(&session.cdp, args.delta_x, args.delta_y).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"screenshot" => {
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser is not open")?;
let output = actions::screenshot(&session.cdp).await?;
self.refresh_page_metadata(session).await?;
Ok(output)
}
"set_visibility" => {
let args: VisibilityArgs = serde_json::from_value(arguments)?;
self.set_visibility(args.visible);
Ok(BrowserToolOutput::Text(format!(
"terminal browser visibility set to {}",
args.visible
)))
}
"close" => {
self.inner.close_session().await;
Ok(BrowserToolOutput::Text(
"terminal browser closed".to_string(),
))
}
_ => anyhow::bail!("unknown terminal browser tool: {tool}"),
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "the session guard keeps navigation and metadata refresh on the same CDP client"
)]
async fn open(&self, args: OpenArgs) -> Result<BrowserToolOutput> {
let url = validate_browser_url(&args.url)?;
let network_policy = self.inner.network_policy();
anyhow::ensure!(
!matches!(&network_policy, BrowserNetworkPolicy::Disabled),
"terminal browser network access is disabled by the active permission profile"
);
let config = SessionConfig {
render_mode: args.render_mode.unwrap_or_default(),
network_policy,
};
let visible = args.visible.unwrap_or(/*default*/ true);
self.inner.update_view(|view| {
view.status = BrowserStatus::Starting;
view.visible = visible;
view.url = Some(url.as_str().to_string());
});
if let Err(error) = self.inner.ensure_session(config).await {
self.inner.set_crashed(error.to_string());
return Err(error);
}
let mut session = self.inner.session.lock().await;
let session = session.as_mut().context("terminal browser did not start")?;
session.handles.clear();
let metadata = match async {
navigation::navigate(&session.cdp, url.as_str()).await?;
let metadata = actions::page_metadata(&session.cdp).await?;
if let Some(final_url) = metadata.url.as_deref()
&& !is_allowed_browser_url(final_url)
{
blank_disallowed_page(session).await;
anyhow::bail!("browser navigation blocked by the active permission policy");
}
Ok::<_, anyhow::Error>(metadata)
}
.await
{
Ok(metadata) => metadata,
Err(error) => {
self.inner
.update_view(|view| view.status = BrowserStatus::Running);
return Err(error);
}
};
let final_url = metadata.url.unwrap_or_else(|| url.as_str().to_string());
self.inner.update_view(|view| {
view.status = BrowserStatus::Running;
view.title = metadata.title;
view.url = Some(final_url);
view.visible = visible;
});
Ok(BrowserToolOutput::Text(format!(
"opened {} in the terminal browser",
url.as_str()
)))
}
async fn refresh_page_metadata(&self, session: &mut BrowserSession) -> Result<()> {
let metadata = actions::page_metadata(&session.cdp).await?;
if let Some(url) = metadata.url.as_deref()
&& !is_allowed_browser_url(url)
{
blank_disallowed_page(session).await;
anyhow::bail!("browser navigation blocked by the active permission policy");
}
self.inner.update_view(|view| {
view.url = metadata.url;
view.title = metadata.title;
});
Ok(())
}
}
async fn blank_disallowed_page(session: &mut BrowserSession) {
let _ = session
.cdp
.call("Page.stopLoading", serde_json::json!({}))
.await;
let _ = session
.cdp
.call("Page.navigate", serde_json::json!({ "url": "about:blank" }))
.await;
session.handles.clear();
}
fn validate_browser_url(raw_url: &str) -> Result<Url> {
anyhow::ensure!(
raw_url.len() <= MAX_URL_INPUT_BYTES,
"URL exceeds the 4 KiB input limit"
);
let url = Url::parse(raw_url).context("parse terminal browser URL")?;
anyhow::ensure!(
is_allowed_browser_url(url.as_str()),
"terminal_browser only supports http and https URLs"
);
Ok(url)
}
@@ -0,0 +1,56 @@
use std::sync::Arc;
use tokio::task::JoinHandle;
use url::Url;
use crate::cdp::CdpClient;
use crate::cdp::CdpEvent;
use crate::session::Inner;
pub(crate) fn is_allowed_browser_url(raw_url: &str) -> bool {
matches!(
Url::parse(raw_url).ok().as_ref().map(Url::scheme),
Some("http" | "https")
)
}
pub(crate) fn is_allowed_observed_url(raw_url: &str) -> bool {
raw_url == "about:blank" || is_allowed_browser_url(raw_url)
}
pub(crate) fn spawn_navigation_policy_task(inner: Arc<Inner>, cdp: CdpClient) -> JoinHandle<()> {
let mut events = cdp.subscribe_events();
tokio::spawn(async move {
loop {
match events.recv().await {
Ok(CdpEvent::Message(message)) => {
let is_main_frame = message.get("method").and_then(serde_json::Value::as_str)
== Some("Page.frameNavigated")
&& message.pointer("/params/frame/parentId").is_none();
let Some(url) = is_main_frame
.then(|| message.pointer("/params/frame/url"))
.flatten()
.and_then(serde_json::Value::as_str)
else {
continue;
};
if is_allowed_observed_url(url) {
continue;
}
tracing::warn!("blocked disallowed terminal-browser main-frame navigation");
let _ = cdp.call("Page.stopLoading", serde_json::json!({})).await;
let _ = cdp
.call("Page.navigate", serde_json::json!({ "url": "about:blank" }))
.await;
inner.update_view(|view| {
view.url = Some("about:blank".to_string());
view.title = Some("Blocked navigation".to_string());
});
}
Ok(CdpEvent::Disconnected(_))
| Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
}
}
})
}