diff --git a/codex-rs/code-bridge-client/tests/fixtures/live_browser_witness.html b/codex-rs/code-bridge-client/tests/fixtures/live_browser_witness.html
new file mode 100644
index 000000000000..be11d71c28f9
--- /dev/null
+++ b/codex-rs/code-bridge-client/tests/fixtures/live_browser_witness.html
@@ -0,0 +1,311 @@
+
+
+
+
+
+ Code Bridge Live Browser Witness
+
+
+
+
+
+
+
+
+
diff --git a/codex-rs/code-bridge-client/tests/live_browser_witness.rs b/codex-rs/code-bridge-client/tests/live_browser_witness.rs
new file mode 100644
index 000000000000..195685bfb145
--- /dev/null
+++ b/codex-rs/code-bridge-client/tests/live_browser_witness.rs
@@ -0,0 +1,301 @@
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
+use codex_code_bridge_client::CodeBridgeClient;
+use codex_code_bridge_client::ControlRequest;
+use codex_code_bridge_client::ScreenshotRequest;
+use codex_code_bridge_protocol::BridgeDescriptor;
+use codex_code_bridge_protocol::BridgeEndpoint;
+use codex_code_bridge_protocol::BridgeEvent;
+use codex_code_bridge_protocol::BridgePayload;
+use codex_code_bridge_protocol::CapabilitySet;
+use codex_code_bridge_protocol::ClientMetadata;
+use codex_code_bridge_protocol::ClientRole;
+use codex_code_bridge_protocol::ConsoleEvent;
+use codex_code_bridge_protocol::ControlCommand;
+use codex_code_bridge_protocol::ControlResponseMessage;
+use codex_code_bridge_protocol::EventKind;
+use codex_code_bridge_protocol::EventPublishMessage;
+use codex_code_bridge_protocol::PageviewEvent;
+use codex_code_bridge_protocol::ScreenshotEvent;
+use codex_code_bridge_protocol::ScreenshotMediaType;
+use codex_code_bridge_protocol::ScreenshotPayload;
+use codex_code_bridge_protocol::ScreenshotResponseMessage;
+use codex_code_bridge_protocol::SourceKind;
+use codex_code_bridge_protocol::SubscriptionFilter;
+use image::ImageFormat;
+use std::io::Read;
+use std::io::Write;
+use std::net::Ipv4Addr;
+use std::net::TcpListener;
+use std::net::TcpStream;
+use std::sync::Arc;
+use std::thread;
+use std::time::Duration;
+use tempfile::TempDir;
+use tokio::time::timeout;
+
+const FIXTURE_HTML: &str = include_str!("fixtures/live_browser_witness.html");
+const BROWSER_CLIENT_ID: &str = "live-browser-witness";
+
+#[tokio::test]
+#[ignore = "requires opening the printed URL in a real browser"]
+async fn live_browser_witness_round_trips_events_screenshot_and_control() {
+ let temp = TempDir::new().expect("temp home");
+ let mut config = codex_code_bridge_service::BridgeServiceConfig::new(temp.path().to_path_buf());
+ config.stale_client_timeout = Duration::from_secs(120);
+ config.stale_client_sweep_interval = Duration::from_secs(120);
+ let service = codex_code_bridge_service::start(config)
+ .await
+ .expect("start Code Bridge service");
+
+ let descriptor = descriptor_from_path(service.descriptor_path());
+ let fixture = LiveBrowserFixture::start(&descriptor);
+ eprintln!(
+ "Open this URL in a real browser to run the Code Bridge live browser witness:\n{}",
+ fixture.url()
+ );
+
+ let client = CodeBridgeClient::from_descriptor(descriptor).expect("descriptor client");
+ let subscriber = client
+ .hello(
+ "live-browser-witness-subscriber",
+ ClientRole::Subscriber,
+ CapabilitySet {
+ subscribe_events: true,
+ request_screenshot: true,
+ request_control: true,
+ ..CapabilitySet::default()
+ },
+ ClientMetadata {
+ source_kind: SourceKind::TestFixture,
+ label: Some("live browser witness subscriber".to_string()),
+ provenance: None,
+ },
+ )
+ .await
+ .expect("subscriber hello");
+
+ client
+ .subscribe(
+ &subscriber,
+ SubscriptionFilter {
+ levels: Vec::new(),
+ event_kinds: vec![
+ EventKind::Console,
+ EventKind::Pageview,
+ EventKind::Screenshot,
+ EventKind::ControlResult,
+ ],
+ client_ids: vec![BROWSER_CLIENT_ID.to_string()],
+ },
+ )
+ .await
+ .expect("subscribe");
+ let mut events = client.events(&subscriber, 0).await.expect("events");
+
+ let pageview = next_message(&mut events, "pageview").await;
+ assert!(matches!(
+ pageview.envelope.payload,
+ BridgePayload::Event(EventPublishMessage {
+ event: BridgeEvent::Pageview(PageviewEvent { url, title }),
+ ..
+ }) if url.starts_with(&fixture.url())
+ && title.as_deref() == Some("Code Bridge Live Browser Witness")
+ ));
+
+ let console = next_message(&mut events, "console").await;
+ let event_sequence = console.sequence;
+ assert!(matches!(
+ console.envelope.payload,
+ BridgePayload::Event(EventPublishMessage {
+ event: BridgeEvent::Console(ConsoleEvent { text, .. }),
+ ..
+ }) if text == "live browser fixture ready"
+ ));
+ drop(events);
+
+ client
+ .request_screenshot(
+ &subscriber,
+ ScreenshotRequest {
+ request_id: "live-browser-shot-1".to_string(),
+ target_client_id: BROWSER_CLIENT_ID.to_string(),
+ timeout_ms: 5_000,
+ },
+ )
+ .await
+ .expect("request screenshot");
+
+ let mut events = client
+ .events(&subscriber, event_sequence)
+ .await
+ .expect("events after screenshot request");
+ let screenshot_response = next_message(&mut events, "screenshot response").await;
+ let BridgePayload::ScreenshotResponse(ScreenshotResponseMessage {
+ request_id,
+ screenshot: Some(screenshot),
+ ..
+ }) = screenshot_response.envelope.payload
+ else {
+ panic!("expected screenshot response");
+ };
+ assert_eq!(request_id, "live-browser-shot-1");
+ assert_nonblank_png(&screenshot);
+
+ let screenshot_event = next_message(&mut events, "screenshot event").await;
+ let control_cursor = screenshot_event.sequence;
+ assert!(matches!(
+ screenshot_event.envelope.payload,
+ BridgePayload::Event(EventPublishMessage {
+ event: BridgeEvent::Screenshot(ScreenshotEvent {
+ screenshot_id,
+ media_type: ScreenshotMediaType::Png,
+ ..
+ }),
+ ..
+ }) if screenshot_id == "live-browser-shot-1"
+ ));
+ drop(events);
+
+ client
+ .request_control(
+ &subscriber,
+ ControlRequest {
+ request_id: "live-browser-js-1".to_string(),
+ target_client_id: BROWSER_CLIENT_ID.to_string(),
+ command: ControlCommand::ExecuteJavascript {
+ code: "window.location.href".to_string(),
+ },
+ timeout_ms: 5_000,
+ },
+ )
+ .await
+ .expect("request control");
+ let mut events = client
+ .events(&subscriber, control_cursor)
+ .await
+ .expect("events after control request");
+ let control_response = next_message(&mut events, "control response").await;
+ assert!(matches!(
+ control_response.envelope.payload,
+ BridgePayload::ControlResponse(ControlResponseMessage { request_id, summary, .. })
+ if request_id == "live-browser-js-1" && summary.starts_with(&fixture.url())
+ ));
+
+ service.shutdown().await;
+}
+
+async fn next_message(
+ events: &mut codex_code_bridge_client::CodeBridgeEventStream,
+ label: &str,
+) -> codex_code_bridge_service::BridgeSseMessage {
+ timeout(Duration::from_secs(30), events.next_message())
+ .await
+ .unwrap_or_else(|_| panic!("timed out waiting for {label}"))
+ .unwrap_or_else(|error| panic!("failed waiting for {label}: {error}"))
+}
+
+fn assert_nonblank_png(screenshot: &ScreenshotPayload) {
+ assert_eq!(screenshot.media_type, ScreenshotMediaType::Png);
+ let bytes = BASE64_STANDARD
+ .decode(screenshot.data_base64.as_bytes())
+ .expect("decode screenshot");
+ let image = image::load_from_memory_with_format(&bytes, ImageFormat::Png)
+ .expect("parse screenshot")
+ .to_rgba8();
+ assert_eq!(image.width(), screenshot.width);
+ assert_eq!(image.height(), screenshot.height);
+ let first = image
+ .pixels()
+ .next()
+ .expect("screenshot has at least one pixel");
+ assert!(image.pixels().any(|pixel| pixel != first));
+}
+
+fn descriptor_from_path(path: &std::path::Path) -> BridgeDescriptor {
+ let raw = std::fs::read(path).expect("read descriptor");
+ serde_json::from_slice(&raw).expect("parse descriptor")
+}
+
+struct LiveBrowserFixture {
+ url: String,
+ _thread: thread::JoinHandle<()>,
+}
+
+impl LiveBrowserFixture {
+ fn start(descriptor: &BridgeDescriptor) -> Self {
+ let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind fixture server");
+ let url = format!(
+ "http://127.0.0.1:{}/",
+ listener.local_addr().expect("fixture addr").port()
+ );
+ let descriptor = Arc::new(descriptor.clone());
+ let thread_descriptor = Arc::clone(&descriptor);
+ let thread = thread::spawn(move || {
+ for stream in listener.incoming() {
+ match stream {
+ Ok(mut stream) => serve_fixture_request(&mut stream, &thread_descriptor),
+ Err(_) => break,
+ }
+ }
+ });
+ Self {
+ url,
+ _thread: thread,
+ }
+ }
+
+ fn url(&self) -> String {
+ self.url.clone()
+ }
+}
+
+fn serve_fixture_request(stream: &mut TcpStream, descriptor: &BridgeDescriptor) {
+ let mut buffer = Vec::new();
+ let mut chunk = [0_u8; 1024];
+ while buffer.len() < 8192 {
+ let Ok(read) = stream.read(&mut chunk) else {
+ return;
+ };
+ if read == 0 {
+ break;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
+ break;
+ }
+ }
+ let request = String::from_utf8_lossy(&buffer);
+ let path = request
+ .lines()
+ .next()
+ .and_then(|line| line.split_whitespace().nth(1))
+ .unwrap_or("/");
+
+ if path == "/config.json" {
+ let body = fixture_config_json(descriptor);
+ write_response(stream, "application/json", &body);
+ } else {
+ write_response(stream, "text/html; charset=utf-8", FIXTURE_HTML);
+ }
+}
+
+fn fixture_config_json(descriptor: &BridgeDescriptor) -> String {
+ let endpoint_url = match &descriptor.endpoint {
+ BridgeEndpoint::LoopbackHttp { url } => url,
+ endpoint => panic!("expected loopback http endpoint, got {endpoint:?}"),
+ };
+ serde_json::json!({
+ "endpointUrl": endpoint_url,
+ "authSecret": descriptor.auth_secret,
+ })
+ .to_string()
+}
+
+fn write_response(stream: &mut TcpStream, content_type: &str, body: &str) {
+ let response = format!(
+ "HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\ncache-control: no-store\r\nconnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = stream.write_all(response.as_bytes());
+}
diff --git a/scripts/local/e2e_agent_session_contract.py b/scripts/local/e2e_agent_session_contract.py
index 8a7de91aaf71..a26392a79203 100644
--- a/scripts/local/e2e_agent_session_contract.py
+++ b/scripts/local/e2e_agent_session_contract.py
@@ -55,6 +55,14 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Also run the local Code Bridge browser-app fixture witness test.",
)
+ parser.add_argument(
+ "--include-code-bridge-live-browser-witness",
+ action="store_true",
+ help=(
+ "Also run the ignored Code Bridge live browser witness test; "
+ "this waits for a browser to open the printed local URL."
+ ),
+ )
return parser.parse_args()
@@ -226,6 +234,21 @@ def run_code_bridge_browser_fixture_test(codex_lab: Path) -> None:
subprocess.run(bridge_cmd, cwd=codex_lab / "codex-rs", check=True)
+def run_code_bridge_live_browser_witness(codex_lab: Path) -> None:
+ bridge_cmd = [
+ "cargo",
+ "test",
+ "-p",
+ "codex-code-bridge-client",
+ "--test",
+ "live_browser_witness",
+ "--",
+ "--ignored",
+ "--nocapture",
+ ]
+ subprocess.run(bridge_cmd, cwd=codex_lab / "codex-rs", check=True)
+
+
def main() -> int:
args = parse_args()
launchplane = require_repo(args.launchplane, "Launchplane")
@@ -238,6 +261,8 @@ def main() -> int:
run_rust_tests(codex_lab, env)
if args.include_code_bridge_witness:
run_code_bridge_browser_fixture_test(codex_lab)
+ if args.include_code_bridge_live_browser_witness:
+ run_code_bridge_live_browser_witness(codex_lab)
print("agent session contract ok")
return 0