From 3ba1d61e48a2542c835f41d79ebc7913673ef38c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:32:20 +0900 Subject: [PATCH 01/12] test(network): require typed text-value observation transport --- ...driver_bidi_text_value_observation_send.rs | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs new file mode 100644 index 000000000..e30d7258a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs @@ -0,0 +1,220 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTextValueObservationCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_text_value_observation, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type AdmittedTextFieldFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn admitted_text_field_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session("webdriver-session")?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + let length = usize::from(u16::from_be_bytes(extended)); + if length <= 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used non-minimal 16-bit length encoding", + )); + } + length + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + if length <= u64::from(u16::MAX) || length > usize::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used invalid 64-bit length encoding", + )); + } + length as usize + } + _ => unreachable!(), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +#[test] +fn text_value_observation_writes_exact_masked_bidi_frame_and_stays_outstanding() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (registry, handle, remote) = admitted_text_field_fixture()?; + let expected_json = WebDriverBiDiTextValueObservationCommand::new_for_current_node( + 43, + "context-a", + &handle, + &remote, + ®istry, + )? + .as_json() + .as_bytes() + .to_vec(); + + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != expected_json { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected script.callFunction text-value observation command", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let _established = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("text-value observation transport test server panicked"))??; + Ok(()) +} From c5527795522fa87e71fd89e53d932a56b397dbf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:35:00 +0900 Subject: [PATCH 02/12] feat(network): add typed text-value observation transport --- ...r_bidi_text_value_observation_transport.rs | 140 +++++++ ...di_text_value_observation_send_failures.rs | 348 ++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs new file mode 100644 index 000000000..48e2f38ae --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs @@ -0,0 +1,140 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserProtocolCapability, BrowserProtocolKind, + ValidatedBrowserProtocolUse, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiTextValueObservationCommand, +}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +/// Fail-closed errors while transporting one current-authority text-value observation command. +#[derive(Debug)] +pub enum WebDriverBiDiTextValueObservationSendError { + /// The supplied protocol-use proof belongs to another browser protocol family. + UnsupportedProtocolKind(BrowserProtocolKind), + /// The supplied protocol-use proof did not validate semantic-observation capability. + UnsupportedCapability(BrowserProtocolCapability), + /// Current node, browser-context, document, or bounded command authority failed revalidation. + Authority { + /// Exact typed immediate-use authority failure. + source: WebDriverBiDiTextValueObservationAuthorityError, + }, + /// The bounded correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Writing the already-registered command frame failed and the transport is not reusable. + FrameWrite { + /// Exact typed bounded WebSocket frame-write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiTextValueObservationSendError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::UnsupportedProtocolKind(_) => { + "WebDriver BiDi text-value observation send requires a WebDriver BiDi proof" + } + Self::UnsupportedCapability(_) => { + "WebDriver BiDi text-value observation send requires semantic-observation capability" + } + Self::Authority { .. } => { + "WebDriver BiDi text-value observation authority was rejected" + } + Self::Correlation { .. } => { + "WebDriver BiDi text-value observation command correlation was rejected" + } + Self::FrameWrite { .. } => { + "WebDriver BiDi text-value observation command frame write failed" + } + }) + } +} + +impl Error for WebDriverBiDiTextValueObservationSendError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, + Self::Authority { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} + +/// Revalidate, register, and write one fixed text-value `script.callFunction` observation. +/// +/// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose protocol family +/// is exactly [`BrowserProtocolKind::WebDriverBiDi`] and whose capability is exactly +/// [`BrowserProtocolCapability::SemanticObservation`]. The proof is consumed before node +/// authority, command correlation, or frame I/O, so typed-input, navigation, CDP, or other +/// protocol proofs cannot dispatch this observation through this boundary. +/// +/// Immediately before correlation, this boundary reconstructs the fixed product-owned command +/// from the [`AdmittedNodeHandle`], exact external browsing-context identifier, remote node +/// reference, and live [`BrowserAuthorityRegistry`]. The core constructor revalidates current +/// session, context, origin, document epoch, registry provenance, and exact admitted wire node +/// identifier. Callers cannot supply function source, sandbox, or generic script arguments. +/// +/// Registration occurs before the first possible remote side effect. A correlation failure writes +/// nothing. Once registration succeeds, a frame-write failure leaves the identifier outstanding +/// because partial or complete remote execution is ambiguous and the identifier must not be +/// silently reused. +/// +/// Dispatch is only protocol-level observation transport. This function does not authenticate the +/// browser, authorize the preceding text-input action, compare the eventual remote value with the +/// intended non-secret text, infer post-condition success, retry, reconnect, select another +/// destination, or grant policy, destination, secret, process, profile, or Agent authority. +#[expect( + clippy::too_many_arguments, + reason = "this immediate-use security boundary keeps command identity, live node authority, transport, correlation, masking, and deadline inputs explicit rather than persisting a reusable prevalidated command" +)] +pub fn send_webdriver_bidi_text_value_observation( + validated: ValidatedBrowserProtocolUse, + command_id: u64, + browsing_context: &str, + handle: &AdmittedNodeHandle, + node: &WebDriverBiDiRemoteNodeReference, + registry: &BrowserAuthorityRegistry, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, +) -> Result { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiTextValueObservationSendError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiTextValueObservationSendError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_semantic_observation_proof = validated; + + let command = WebDriverBiDiTextValueObservationCommand::new_for_current_node( + command_id, + browsing_context, + handle, + node, + registry, + ) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::Authority { source })?; + + correlation + .register_command(command.command_id()) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::Correlation { source })?; + established + .write_text_frame(command.as_json(), masking_key, frame_timeout) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::FrameWrite { source }) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs new file mode 100644 index 000000000..9f67fb678 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs @@ -0,0 +1,348 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationSendError, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_text_value_observation, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type HandshakeOnlyServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); +type ObservationFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn protocol_proof( + kind: BrowserProtocolKind, + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + kind, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + kind, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn semantic_observation_proof() -> Result> { + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::SemanticObservation, + ) +} + +fn observation_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session("webdriver-session")?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn establish_with_handshake_only_server() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn observation_rejects_non_semantic_proof_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::TypedInput, + )?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("typed-input proof unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation send requires semantic-observation capability" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("semantic capability rejection server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_non_webdriver_bidi_proof_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + protocol_proof( + BrowserProtocolKind::ChromeDevToolsProtocol, + BrowserProtocolCapability::SemanticObservation, + )?, + 44, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("CDP proof unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation send requires a WebDriver BiDi proof" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("WebDriver BiDi proof rejection server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_stale_external_context_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 45, + "context-b", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("wrong context unexpectedly reached WebSocket I/O"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::Authority { + source: WebDriverBiDiTextValueObservationAuthorityError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch + ) + } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation authority was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("authority rejection server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_duplicate_correlation_before_frame_write() -> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(46)?; + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 46, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("duplicate correlation unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::Correlation { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation command correlation was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("duplicate-correlation observation server panicked"))??; + Ok(()) +} + +#[test] +fn observation_preserves_registration_when_frame_timeout_is_invalid() -> Result<(), Box> +{ + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 47, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::ZERO, + ) + .err() + .ok_or_else(|| io::Error::other("zero frame timeout unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::FrameWrite { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation command frame write failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("invalid-timeout observation server panicked"))??; + Ok(()) +} From 480d411011120b40d88beec3942aee049541b71d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:35:29 +0900 Subject: [PATCH 03/12] feat(network): export typed text-value observation transport --- crates/originweave-network/src/lib.rs | 35 +++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 71a6aa299..2075dff25 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -9,21 +9,22 @@ //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! classifies complete local-end JSON envelopes, tracks bounded command-response //! correlation, transports narrowly typed pointer-click and node-bound non-secret -//! text-input actions, admits typed correlated protocol acknowledgments, sends a -//! context-bound subscription for committed-navigation events, retains its typed -//! bounded correlated subscription identifier, binds navigation-event admission to -//! that exact active command/receipt lifecycle with bounded fail-closed navigation -//! replay prevention, explicitly tears down that exact subscription by identifier, -//! admits its typed correlated unsubscribe acknowledgment, admits a bounded -//! navigation-committed post-condition observation for one exact registered context -//! and URL, rotates the matched context's document epoch only from an exact -//! caller-captured pre-action epoch, derives and binds the committed HTTP(S) URL's -//! canonical origin to that newly advanced document, sends narrowly typed -//! `session.status` and `session.end` commands, admits typed correlated status and -//! end responses, observes bounded peer Close or clean-EOF transport cessation, and -//! keeps protocol/transport evidence separate from explicit operational teardown -//! observations without exposing generic JSON bodies or granting browser, TLS, -//! policy, secret, process, profile, or Agent authority. +//! text-input actions plus fixed sandboxed text-value observations, admits typed +//! correlated protocol acknowledgments, sends a context-bound subscription for +//! committed-navigation events, retains its typed bounded correlated subscription +//! identifier, binds navigation-event admission to that exact active command/receipt +//! lifecycle with bounded fail-closed navigation replay prevention, explicitly tears +//! down that exact subscription by identifier, admits its typed correlated +//! unsubscribe acknowledgment, admits a bounded navigation-committed post-condition +//! observation for one exact registered context and URL, rotates the matched +//! context's document epoch only from an exact caller-captured pre-action epoch, +//! derives and binds the committed HTTP(S) URL's canonical origin to that newly +//! advanced document, sends narrowly typed `session.status` and `session.end` +//! commands, admits typed correlated status and end responses, observes bounded peer +//! Close or clean-EOF transport cessation, and keeps protocol/transport evidence +//! separate from explicit operational teardown observations without exposing generic +//! JSON bodies or granting browser, TLS, policy, secret, process, profile, or Agent +//! authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -47,6 +48,7 @@ mod webdriver_bidi_session_end_response; mod webdriver_bidi_session_status_command; mod webdriver_bidi_session_status_response; mod webdriver_bidi_session_teardown; +mod webdriver_bidi_text_value_observation_transport; mod webdriver_bidi_type_text_response; mod webdriver_bidi_type_text_transport; mod webdriver_bidi_websocket_frame; @@ -139,6 +141,9 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, }; +pub use webdriver_bidi_text_value_observation_transport::{ + WebDriverBiDiTextValueObservationSendError, send_webdriver_bidi_text_value_observation, +}; pub use webdriver_bidi_type_text_response::{ WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, }; From 0667707df76db81a2ad2988ef7c0fc37d862dca5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:20:17 +0900 Subject: [PATCH 04/12] fix(network): bind postcondition correlation to command provenance --- .../src/webdriver_bidi_command_correlation.rs | 157 ++++++++++++++---- 1 file changed, 123 insertions(+), 34 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 6adc3d43d..9cb33f051 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -1,14 +1,32 @@ -use std::{collections::BTreeSet, error::Error, fmt}; +use std::{collections::BTreeMap, error::Error, fmt}; use crate::{MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeKind}; /// Maximum number of local WebDriver BiDi commands retained as outstanding at once. /// /// WebDriver BiDi permits commands to complete out of order. OriginWeave therefore keeps a -/// bounded local correlation set instead of assuming response order, while this resource ceiling +/// bounded local correlation map instead of assuming response order, while this resource ceiling /// prevents an unbounded remote-control session from growing local correlation state indefinitely. pub const MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS: usize = 256; +/// Typed WebDriver BiDi command families that require response-family provenance. +/// +/// Existing legacy command slices may still use the unclassified correlation API while they are +/// migrated independently. A typed command is deliberately segregated from that compatibility +/// path: an unclassified response consumer cannot consume its id, and a typed consumer cannot +/// consume an unclassified id merely because the numeric correlation value matches. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiCommandKind { + /// Fixed product-owned `script.callFunction` text-value observation. + TextValueObservation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OutstandingCommandKind { + Unclassified, + Typed(WebDriverBiDiCommandKind), +} + /// Outcome of a response after it has consumed the matching outstanding command identifier. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebDriverBiDiCorrelatedResponseOutcome { @@ -53,6 +71,8 @@ pub enum WebDriverBiDiCommandCorrelationError { OutstandingCommandLimit, /// No currently outstanding command matches the requested or returned identifier. CommandNotOutstanding, + /// The response consumer does not match the command provenance registered for this identifier. + CommandKindMismatch, /// An event is not a command response and cannot consume correlation state. EventIsNotResponse, /// A protocol error with a `null` id cannot be attributed to one outstanding command. @@ -66,6 +86,9 @@ impl fmt::Display for WebDriverBiDiCommandCorrelationError { Self::CommandAlreadyOutstanding => "WebDriver BiDi command id is already outstanding", Self::OutstandingCommandLimit => "WebDriver BiDi outstanding-command limit reached", Self::CommandNotOutstanding => "WebDriver BiDi command id is not outstanding", + Self::CommandKindMismatch => { + "WebDriver BiDi response command kind does not match the outstanding command" + } Self::EventIsNotResponse => "WebDriver BiDi event cannot be correlated as a response", Self::UncorrelatableErrorResponse => { "WebDriver BiDi error response has no correlatable command id" @@ -79,14 +102,16 @@ impl Error for WebDriverBiDiCommandCorrelationError {} /// Bounded local WebDriver BiDi command-response correlation state. /// -/// Register an id only after the caller has committed to one outbound command. A success or -/// correlatable error response consumes the id exactly once. Events and null-id errors leave all -/// outstanding state untouched. This type performs no I/O, retry, command serialization, browser -/// authentication, or authority grant. Debug output reports only the outstanding-count summary; -/// command identifiers remain private correlation state. +/// The compatibility [`Self::register_command`] path retains unclassified command provenance for +/// existing typed boundaries that have not yet migrated. Security-sensitive consumers that require +/// an exact command family use [`Self::register_command_for`] and [`Self::correlate_response_for`]. +/// Typed and unclassified entries cannot consume one another. Events, null-id errors, unknown ids, +/// and provenance mismatches leave outstanding state untouched. This type performs no I/O, retry, +/// command serialization, browser authentication, or authority grant. Debug output reports only +/// the outstanding-count summary; identifiers and command provenance remain private state. #[derive(Default)] pub struct WebDriverBiDiCommandCorrelation { - outstanding: BTreeSet, + outstanding: BTreeMap, } impl fmt::Debug for WebDriverBiDiCommandCorrelation { @@ -111,49 +136,94 @@ impl WebDriverBiDiCommandCorrelation { self.outstanding.len() } - /// Register one local command id before its response can be accepted. + /// Register one legacy/unclassified local command id before its response can be accepted. /// + /// This compatibility path cannot later be consumed by a typed response-family boundary. /// Identifiers are unique only while outstanding. A completed or explicitly retired id may be /// reused later, matching WebDriver BiDi's local-end correlation semantics. pub fn register_command( &mut self, command_id: u64, ) -> Result<(), WebDriverBiDiCommandCorrelationError> { - if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { - return Err(WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange); - } - if self.outstanding.contains(&command_id) { - return Err(WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding); - } - if self.outstanding.len() >= MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS { - return Err(WebDriverBiDiCommandCorrelationError::OutstandingCommandLimit); - } - self.outstanding.insert(command_id); - Ok(()) + self.register(command_id, OutstandingCommandKind::Unclassified) + } + + /// Register one local command id together with its exact typed command family. + /// + /// A typed entry cannot later be consumed by the unclassified compatibility response path or by + /// another typed command family. Registration remains local correlation bookkeeping only and + /// does not authorize or dispatch the command. + pub fn register_command_for( + &mut self, + command_id: u64, + command_kind: WebDriverBiDiCommandKind, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + self.register(command_id, OutstandingCommandKind::Typed(command_kind)) } /// Explicitly retire one outstanding command without accepting a response for it. /// /// This supports caller-owned cancellation or session teardown without retaining stale ids. + /// Retirement does not produce success evidence and therefore may remove either compatibility + /// or typed provenance when the caller owns the exact outstanding identifier. pub fn retire_command( &mut self, command_id: u64, ) -> Result<(), WebDriverBiDiCommandCorrelationError> { - if self.outstanding.remove(&command_id) { + if self.outstanding.remove(&command_id).is_some() { Ok(()) } else { Err(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding) } } - /// Correlate one already parsed local-end envelope with the outstanding command set. + /// Correlate one parsed local-end envelope with an unclassified outstanding command. /// - /// Successful responses and error responses with ids consume exactly one matching command. - /// Unknown ids fail without consuming unrelated state. Events and null-id errors fail before - /// touching the set. + /// This compatibility consumer cannot consume an id that was registered through the typed + /// provenance API. Unknown ids and provenance mismatches fail without consuming state. Events + /// and null-id errors fail before touching the map. pub fn correlate_response( &mut self, envelope: &WebDriverBiDiJsonEnvelope, + ) -> Result { + self.correlate_response_kind(envelope, OutstandingCommandKind::Unclassified) + } + + /// Correlate one parsed local-end envelope with the exact typed outstanding command family. + /// + /// A matching numeric id is insufficient: the registered family must also match. This prevents + /// a response produced for an unrelated outstanding command from being consumed as typed + /// evidence by another protocol boundary. + pub fn correlate_response_for( + &mut self, + envelope: &WebDriverBiDiJsonEnvelope, + expected_kind: WebDriverBiDiCommandKind, + ) -> Result { + self.correlate_response_kind(envelope, OutstandingCommandKind::Typed(expected_kind)) + } + + fn register( + &mut self, + command_id: u64, + command_kind: OutstandingCommandKind, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err(WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange); + } + if self.outstanding.contains_key(&command_id) { + return Err(WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding); + } + if self.outstanding.len() >= MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS { + return Err(WebDriverBiDiCommandCorrelationError::OutstandingCommandLimit); + } + let _previous = self.outstanding.insert(command_id, command_kind); + Ok(()) + } + + fn correlate_response_kind( + &mut self, + envelope: &WebDriverBiDiJsonEnvelope, + expected_kind: OutstandingCommandKind, ) -> Result { match envelope.kind() { WebDriverBiDiJsonEnvelopeKind::Event => { @@ -163,25 +233,40 @@ impl WebDriverBiDiCommandCorrelation { let Some(command_id) = envelope.command_id() else { return Err(WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse); }; - self.complete(command_id, WebDriverBiDiCorrelatedResponseOutcome::Error) + self.complete( + command_id, + expected_kind, + WebDriverBiDiCorrelatedResponseOutcome::Error, + ) + } + WebDriverBiDiJsonEnvelopeKind::Success => { + let Some(command_id) = envelope.command_id() else { + return Err(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding); + }; + self.complete( + command_id, + expected_kind, + WebDriverBiDiCorrelatedResponseOutcome::Success, + ) } - WebDriverBiDiJsonEnvelopeKind::Success => self.complete( - envelope - .command_id() - .unwrap_or(MAX_WEBDRIVER_BIDI_JS_UINT.saturating_add(1)), - WebDriverBiDiCorrelatedResponseOutcome::Success, - ), } } fn complete( &mut self, command_id: u64, + expected_kind: OutstandingCommandKind, outcome: WebDriverBiDiCorrelatedResponseOutcome, ) -> Result { - if !self.outstanding.remove(&command_id) { - return Err(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding); + let actual_kind = self + .outstanding + .get(&command_id) + .copied() + .ok_or(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding)?; + if actual_kind != expected_kind { + return Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch); } + let _removed = self.outstanding.remove(&command_id); Ok(WebDriverBiDiCorrelatedResponse { command_id, outcome, @@ -212,6 +297,10 @@ mod tests { WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, "WebDriver BiDi command id is not outstanding", ), + ( + WebDriverBiDiCommandCorrelationError::CommandKindMismatch, + "WebDriver BiDi response command kind does not match the outstanding command", + ), ( WebDriverBiDiCommandCorrelationError::EventIsNotResponse, "WebDriver BiDi event cannot be correlated as a response", From e0e80ed841f18b3300c655dfc25102d94ad9b097 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:20:58 +0900 Subject: [PATCH 05/12] fix(network): expose typed observation correlation provenance --- crates/originweave-network/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 2075dff25..7a59a7a83 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -66,8 +66,8 @@ pub use connection::{ }; pub use webdriver_bidi_command_correlation::{ MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS, WebDriverBiDiCommandCorrelation, - WebDriverBiDiCommandCorrelationError, WebDriverBiDiCorrelatedResponse, - WebDriverBiDiCorrelatedResponseOutcome, + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, + WebDriverBiDiCorrelatedResponse, WebDriverBiDiCorrelatedResponseOutcome, }; pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, From 0e7ff993aaa9f9f0569a928dfbd618d419bab3e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:21:24 +0900 Subject: [PATCH 06/12] fix(network): register text observations with typed provenance --- ...ver_bidi_text_value_observation_transport.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs index 48e2f38ae..9635e02ad 100644 --- a/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs @@ -8,7 +8,7 @@ use originweave_core::{ use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiCommandKind, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; @@ -83,10 +83,12 @@ impl Error for WebDriverBiDiTextValueObservationSendError { /// session, context, origin, document epoch, registry provenance, and exact admitted wire node /// identifier. Callers cannot supply function source, sandbox, or generic script arguments. /// -/// Registration occurs before the first possible remote side effect. A correlation failure writes -/// nothing. Once registration succeeds, a frame-write failure leaves the identifier outstanding -/// because partial or complete remote execution is ambiguous and the identifier must not be -/// silently reused. +/// Registration records [`WebDriverBiDiCommandKind::TextValueObservation`] before the first +/// possible remote side effect. A later typed response boundary must match both the exact id and +/// this command provenance; an unrelated outstanding command id therefore cannot certify a text +/// post-condition. A correlation failure writes nothing. Once registration succeeds, a frame-write +/// failure leaves the identifier outstanding because partial or complete remote execution is +/// ambiguous and the identifier must not be silently reused. /// /// Dispatch is only protocol-level observation transport. This function does not authenticate the /// browser, authorize the preceding text-input action, compare the eventual remote value with the @@ -132,7 +134,10 @@ pub fn send_webdriver_bidi_text_value_observation( .map_err(|source| WebDriverBiDiTextValueObservationSendError::Authority { source })?; correlation - .register_command(command.command_id()) + .register_command_for( + command.command_id(), + WebDriverBiDiCommandKind::TextValueObservation, + ) .map_err(|source| WebDriverBiDiTextValueObservationSendError::Correlation { source })?; established .write_text_frame(command.as_json(), masking_key, frame_timeout) From e5b44e8470df5befa0bb26eb9f893e7106347f3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:21:53 +0900 Subject: [PATCH 07/12] test(network): preserve typed command provenance during correlation --- ...webdriver_bidi_command_kind_correlation.rs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs new file mode 100644 index 000000000..faa1728d9 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs @@ -0,0 +1,103 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn parse_success_over_loopback() -> Result> { + const DOCUMENT: &[u8] = br#"{"type":"success","id":42,"result":{}}"#; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.write_all(&[0x81, DOCUMENT.len() as u8])?; + stream.write_all(DOCUMENT) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let envelope = WebDriverBiDiJsonEnvelope::parse(&text)?; + server + .join() + .map_err(|_| io::Error::other("command-kind correlation test server panicked"))??; + Ok(envelope) +} + +#[test] +fn typed_response_provenance_cannot_cross_the_legacy_correlation_boundary() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(42, WebDriverBiDiCommandKind::TextValueObservation)?; + let response = parse_success_over_loopback()?; + + assert_eq!( + correlation.correlate_response(&response), + Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let completed = correlation.correlate_response_for( + &response, + WebDriverBiDiCommandKind::TextValueObservation, + )?; + assert_eq!(completed.command_id(), 42); + assert_eq!( + completed.outcome(), + WebDriverBiDiCorrelatedResponseOutcome::Success + ); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} From 64722daac0cbcbaf01375736b5f418838aa88c70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:45:32 +0900 Subject: [PATCH 08/12] fix(network): preserve parsed success id invariant Signed-off-by: Seongho Bae --- CHANGELOG.md | 3 ++- .../src/webdriver_bidi_command_correlation.rs | 15 +++++---------- .../src/webdriver_bidi_json_envelope.rs | 8 ++++++++ .../webdriver_bidi_command_kind_correlation.rs | 6 ++---- docs/doctoring/browser-agent-protocols.md | 2 ++ 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 642e59794..73dc2de2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Typed WebDriver BiDi command-family correlation for text-value post-condition observations, so a matching numeric response id cannot consume an outstanding command from another operation family; successful envelopes reuse the parser-proven non-null id invariant without an unreachable fallback branch. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. @@ -99,4 +100,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 9cb33f051..b44e852fa 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -239,16 +239,11 @@ impl WebDriverBiDiCommandCorrelation { WebDriverBiDiCorrelatedResponseOutcome::Error, ) } - WebDriverBiDiJsonEnvelopeKind::Success => { - let Some(command_id) = envelope.command_id() else { - return Err(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding); - }; - self.complete( - command_id, - expected_kind, - WebDriverBiDiCorrelatedResponseOutcome::Success, - ) - } + WebDriverBiDiJsonEnvelopeKind::Success => self.complete( + envelope.success_command_id(), + expected_kind, + WebDriverBiDiCorrelatedResponseOutcome::Success, + ), } } diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index e99adf2c9..afd60b9f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -32,6 +32,7 @@ pub enum WebDriverBiDiJsonEnvelopeKind { pub struct WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind, command_id: Option, + success_command_id: u64, method: Option, error_code: Option, } @@ -84,6 +85,10 @@ impl WebDriverBiDiJsonEnvelope { self.command_id } + pub(crate) const fn success_command_id(&self) -> u64 { + self.success_command_id + } + /// Borrow the event method when this is an event envelope. #[must_use] pub fn method(&self) -> Option<&str> { @@ -210,6 +215,7 @@ impl TopLevelFields { Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Success, command_id: Some(command_id), + success_command_id: command_id, method: None, error_code: None, }) @@ -225,6 +231,7 @@ impl TopLevelFields { Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Error, command_id, + success_command_id: 0, method: None, error_code: Some(error_code), }) @@ -236,6 +243,7 @@ impl TopLevelFields { Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Event, command_id: None, + success_command_id: 0, method: Some(method), error_code: None, }) diff --git a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs index faa1728d9..af499855f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs @@ -89,10 +89,8 @@ fn typed_response_provenance_cannot_cross_the_legacy_correlation_boundary() ); assert_eq!(correlation.outstanding_count(), 1); - let completed = correlation.correlate_response_for( - &response, - WebDriverBiDiCommandKind::TextValueObservation, - )?; + let completed = correlation + .correlate_response_for(&response, WebDriverBiDiCommandKind::TextValueObservation)?; assert_eq!(completed.command_id(), 42); assert_eq!( completed.outcome(), diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index dbf3ef731..a5afb6dc0 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -14,6 +14,8 @@ For the bounded `browsingContext.locateNodes` command-serialization boundary, th WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. +OriginWeave also retains the registered command family until correlation completes. A response id registered for typed input, navigation, or semantic observation cannot be consumed by a different family merely because the numeric id matches. + The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that currently includes `no such client window`. OriginWeave admits only the reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed; adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). From 85462d2fab4fe0d9a7f421e3eae73dbb88d99ec7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:53:56 +0900 Subject: [PATCH 09/12] test(network): reject observation transport from another session --- ...bservation_transport_session_provenance.rs | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs new file mode 100644 index 000000000..e050dbea4 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs @@ -0,0 +1,191 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTextValueObservationSendError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + send_webdriver_bidi_text_value_observation, +}; + +const REGISTRY_SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const FOREIGN_TRANSPORT_SESSION_ID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn protocol_proof( + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn current_node_fixture() -> Result< + ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, + ), + Box, +> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(REGISTRY_SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example") + .map_err(|error| io::Error::other(format!("fixture origin rejected: {error:?}")))?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + protocol_proof(BrowserProtocolCapability::SemanticObservation)?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +#[test] +fn current_node_text_value_observation_is_rejected_before_writing_to_a_foreign_session_transport() +-> Result<(), Box> { + let (registry, handle, remote) = current_node_fixture()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + let mut first_command_byte = [0_u8; 1]; + match stream.read(&mut first_command_byte) { + Ok(0) => Ok(false), + Ok(_) => Ok(true), + Err(source) + if matches!( + source.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + Ok(false) + } + Err(source) => Err(source), + } + }); + + let endpoint = format!("ws://{local_addr}/session/{FOREIGN_TRANSPORT_SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(FOREIGN_TRANSPORT_SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + + let send_result = send_webdriver_bidi_text_value_observation( + protocol_proof(BrowserProtocolCapability::SemanticObservation)?, + 42, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + let command_byte_seen = server + .join() + .map_err(|_| io::Error::other("foreign-session text-value observation server panicked"))??; + + let error = send_result.err().ok_or_else(|| { + io::Error::other( + "registry session A unexpectedly dispatched text-value observation input on transport session B", + ) + })?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::Authority { + source: WebDriverBiDiTextValueObservationAuthorityError::BrowserAuthority(_) + } + )); + assert_eq!( + correlation.outstanding_count(), + 0, + "foreign-session rejection must happen before correlation registration" + ); + assert!( + !command_byte_seen, + "foreign-session rejection must happen before any text-value observation command-frame byte" + ); + Ok(()) +} From 4cdfab10b413931a1c4dcc93a92b732dd24efdae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:01:16 +0900 Subject: [PATCH 10/12] test(network): cover observation preflight and ambiguous writes --- ...webdriver_bidi_command_kind_correlation.rs | 17 +++- ...driver_bidi_text_value_observation_send.rs | 90 +++++++++++++++++-- ...di_text_value_observation_send_failures.rs | 12 +-- ...bservation_transport_session_provenance.rs | 12 +-- 4 files changed, 112 insertions(+), 19 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs index 8a8896ce5..bc6e6820f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs @@ -87,12 +87,23 @@ fn observation_response_cannot_cross_any_sibling_command_family() -> Result<(), WebDriverBiDiCommandKind::NavigationCommittedSubscription, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, ] { - for (actual, expected) in [(WebDriverBiDiCommandKind::TextValueObservation, sibling), (sibling, WebDriverBiDiCommandKind::TextValueObservation)] { + for (actual, expected) in [ + (WebDriverBiDiCommandKind::TextValueObservation, sibling), + (sibling, WebDriverBiDiCommandKind::TextValueObservation), + ] { let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation.register_command_for(42, actual)?; - assert_eq!(correlation.correlate_response_for(&response, expected), Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { expected, actual })); + assert_eq!( + correlation.correlate_response_for(&response, expected), + Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { expected, actual }) + ); assert_eq!(correlation.outstanding_count(), 1); - assert_eq!(correlation.correlate_response_for(&response, actual)?.command_id(), 42); + assert_eq!( + correlation + .correlate_response_for(&response, actual)? + .command_id(), + 42 + ); } } Ok(()) diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs index e30d7258a..2a257da86 100644 --- a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs @@ -2,6 +2,7 @@ use std::{ error::Error, io::{self, Read, Write}, net::{TcpListener, TcpStream}, + sync::mpsc, thread, time::Duration, }; @@ -16,7 +17,7 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_text_value_observation, }; @@ -57,7 +58,7 @@ fn semantic_observation_proof() -> Result Result> { let mut registry = BrowserAuthorityRegistry::new(); - let browser_session = registry.register_session("webdriver-session")?; + let browser_session = registry.register_session(SESSION_ID)?; let browsing_context = registry.register_context(browser_session, "context-a")?; let origin = Origin::parse("https://app.example").map_err(|error| { io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) @@ -106,10 +107,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { +fn read_masked_client_frame(stream: &mut TcpStream, expected_header: u8) -> io::Result> { let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != expected_header || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, "expected one final masked client text frame", @@ -177,7 +178,7 @@ fn text_value_observation_writes_exact_masked_bidi_frame_and_stays_outstanding() let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let command = read_masked_text_frame(&mut stream)?; + let command = read_masked_client_frame(&mut stream, 0x81)?; if command != expected_json { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -218,3 +219,82 @@ fn text_value_observation_writes_exact_masked_bidi_frame_and_stays_outstanding() .map_err(|_| io::Error::other("text-value observation transport test server panicked"))??; Ok(()) } +#[test] +fn text_value_observation_ambiguous_socket_write_keeps_correlation() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (closed_sender, closed_receiver) = mpsc::channel(); + let (seed_ready_sender, seed_ready_receiver) = mpsc::channel(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let mut first_frame_byte = [0_u8; 1]; + stream.read_exact(&mut first_frame_byte)?; + seed_ready_receiver + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| io::Error::other("seed Pong did not finish before peer closure"))?; + drop(stream); + closed_sender.send(()).map_err(|_| { + io::Error::other("text-value observation close signal receiver disappeared") + }) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))? + .write_pong_frame( + b"seed-frame", + WebDriverBiDiWebSocketMaskKey::new([13, 14, 15, 16]), + Duration::from_millis(500), + )?; + seed_ready_sender.send(())?; + closed_receiver.recv_timeout(Duration::from_secs(1))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let mut established = established; + let mut observed_ambiguous_failure = false; + let (registry, handle, remote) = admitted_text_field_fixture()?; + for attempt in 0_u8..64 { + let command_id = 44 + u64::from(attempt); + match send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + command_id, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([17, 18, 19, attempt]), + Duration::from_millis(500), + ) { + Ok(next) => { + correlation.retire_command_for( + command_id, + WebDriverBiDiCommandKind::TextValueObservation, + )?; + established = next; + } + Err(error) => { + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + observed_ambiguous_failure = true; + break; + } + } + } + assert!(observed_ambiguous_failure); + + server + .join() + .map_err(|_| io::Error::other("ambiguous-write observation test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs index 24383a0d8..f95259eb4 100644 --- a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs @@ -72,7 +72,7 @@ fn semantic_observation_proof() -> Result Result> { let mut registry = BrowserAuthorityRegistry::new(); - let browser_session = registry.register_session("webdriver-session")?; + let browser_session = registry.register_session(SESSION_ID)?; let browsing_context = registry.register_context(browser_session, "context-a")?; let origin = Origin::parse("https://app.example").map_err(|error| { io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) @@ -275,7 +275,10 @@ fn observation_rejects_stale_external_context_before_correlation_or_frame_write( fn observation_rejects_duplicate_correlation_before_frame_write() -> Result<(), Box> { let (established, server) = establish_with_handshake_only_server()?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation.register_command_for(46, originweave_network::WebDriverBiDiCommandKind::TextValueObservation)?; + correlation.register_command_for( + 46, + originweave_network::WebDriverBiDiCommandKind::TextValueObservation, + )?; let (registry, handle, remote) = observation_fixture()?; let error = send_webdriver_bidi_text_value_observation( @@ -310,8 +313,7 @@ fn observation_rejects_duplicate_correlation_before_frame_write() -> Result<(), } #[test] -fn observation_preserves_registration_when_frame_timeout_is_invalid() -> Result<(), Box> -{ +fn observation_rejects_invalid_frame_timeout_before_registration() -> Result<(), Box> { let (established, server) = establish_with_handshake_only_server()?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let (registry, handle, remote) = observation_fixture()?; @@ -339,7 +341,7 @@ fn observation_preserves_registration_when_frame_timeout_is_invalid() -> Result< "WebDriver BiDi text-value observation command frame write failed" ); assert!(error.source().is_some()); - assert_eq!(correlation.outstanding_count(), 1); + assert_eq!(correlation.outstanding_count(), 0); server .join() diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs index e050dbea4..defd77ba8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs @@ -12,12 +12,12 @@ use originweave_core::{ BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiTextValueObservationSendError, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationSendError, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_text_value_observation, }; @@ -163,9 +163,9 @@ fn current_node_text_value_observation_is_rejected_before_writing_to_a_foreign_s WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), Duration::from_millis(500), ); - let command_byte_seen = server - .join() - .map_err(|_| io::Error::other("foreign-session text-value observation server panicked"))??; + let command_byte_seen = server.join().map_err(|_| { + io::Error::other("foreign-session text-value observation server panicked") + })??; let error = send_result.err().ok_or_else(|| { io::Error::other( From 5597f22a82330ef0d4bcf2b2215d9a715d81807c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:01:42 +0900 Subject: [PATCH 11/12] test(network): reject observation mask reuse without pending command --- ...driver_bidi_text_value_observation_send.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs index 2a257da86..620edb2f5 100644 --- a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs @@ -90,6 +90,67 @@ fn admitted_text_field_fixture() -> Result Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let seed = read_masked_client_frame(&mut stream, 0x8a)?; + if seed != b"{}" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected seed frame before reused-key regression", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let established = + established.write_pong_frame(b"{}", repeated_key, Duration::from_millis(500))?; + + let (registry, handle, remote) = admitted_text_field_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + repeated_key, + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("reused masking key unexpectedly sent a text input"))?; + assert_eq!(correlation.outstanding_count(), 0); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation command frame write failed" + ); + + server + .join() + .map_err(|_| io::Error::other("reused-mask-key observation test server panicked"))??; + Ok(()) +} + fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut request = Vec::new(); From 8eda96915dbbe4cc617f834267c7464689c2844d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:03:58 +0900 Subject: [PATCH 12/12] fix(network): bind field observation to its transport session --- CHANGELOG.md | 2 + ...r_bidi_text_value_observation_transport.rs | 50 ++++++++++++++++--- docs/doctoring/browser-agent-protocols.md | 2 + 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b586cff25..036fc72cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Reject field-observation requests on another browser session and preserve pending requests only when a write may have reached the peer. + - Preserve fixed field-observation checks while adopting current input safeguards; constructing a request still does not verify that the field changed. - Label earlier text-reply limitations as historical so they do not contradict the later click safeguards. - Preserve text-reply checks while adopting click-session and reply safeguards. A matched response still does not prove the requested field changed. diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs index 9635e02ad..d803b3819 100644 --- a/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs @@ -29,7 +29,7 @@ pub enum WebDriverBiDiTextValueObservationSendError { /// Exact typed correlation failure. source: WebDriverBiDiCommandCorrelationError, }, - /// Writing the already-registered command frame failed and the transport is not reusable. + /// Deadline validation or writing the command frame failed. FrameWrite { /// Exact typed bounded WebSocket frame-write failure. source: WebDriverBiDiWebSocketFrameError, @@ -83,12 +83,14 @@ impl Error for WebDriverBiDiTextValueObservationSendError { /// session, context, origin, document epoch, registry provenance, and exact admitted wire node /// identifier. Callers cannot supply function source, sandbox, or generic script arguments. /// -/// Registration records [`WebDriverBiDiCommandKind::TextValueObservation`] before the first +/// The registered session must match the established connection's exact session identifier. +/// Deadline validation precedes registration. Registration binds the connection generation and +/// records [`WebDriverBiDiCommandKind::TextValueObservation`] before the first /// possible remote side effect. A later typed response boundary must match both the exact id and /// this command provenance; an unrelated outstanding command id therefore cannot certify a text -/// post-condition. A correlation failure writes nothing. Once registration succeeds, a frame-write -/// failure leaves the identifier outstanding because partial or complete remote execution is -/// ambiguous and the identifier must not be silently reused. +/// post-condition. A correlation failure writes nothing. A proven zero-write malformed-frame +/// rejection retires only this registration; an ambiguous write failure keeps it outstanding. +/// The transport enforces increasing command identifiers across its entire lifetime. /// /// Dispatch is only protocol-level observation transport. This function does not authenticate the /// browser, authorize the preceding text-input action, compare the eventual remote value with the @@ -133,13 +135,45 @@ pub fn send_webdriver_bidi_text_value_observation( ) .map_err(|source| WebDriverBiDiTextValueObservationSendError::Authority { source })?; + registry + .require_registered_session_external_identifier( + handle.browser_session(), + established + .transport_evidence() + .verified_peer() + .session_id(), + ) + .map_err( + |source| WebDriverBiDiTextValueObservationSendError::Authority { + source: WebDriverBiDiTextValueObservationAuthorityError::BrowserAuthority(source), + }, + )?; + crate::webdriver_bidi_websocket_frame::validate_frame_timeout(frame_timeout) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::FrameWrite { source })?; correlation - .register_command_for( + .register_command_for_connection( command.command_id(), WebDriverBiDiCommandKind::TextValueObservation, + established.transport_evidence().connection_generation(), ) .map_err(|source| WebDriverBiDiTextValueObservationSendError::Correlation { source })?; established - .write_text_frame(command.as_json(), masking_key, frame_timeout) - .map_err(|source| WebDriverBiDiTextValueObservationSendError::FrameWrite { source }) + .write_command_frame( + command.command_id(), + command.as_json(), + masking_key, + frame_timeout, + ) + .map_err(|source| { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = correlation.retire_command_for( + command.command_id(), + WebDriverBiDiCommandKind::TextValueObservation, + ); + } + WebDriverBiDiTextValueObservationSendError::FrameWrite { source } + }) } diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index d99276362..72cdda1c7 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -16,6 +16,8 @@ WebDriver BiDi commands may execute concurrently and finish out of order. The 3 OriginWeave also retains the registered command family until correlation completes. A response id registered for typed input, navigation, or semantic observation cannot be consumed by a different family merely because the numeric id matches. +The fixed field-observation sender now requires the admitted node's registered session to match the established transport session before writing. It validates the deadline before registration, binds pending requests to the exact connection generation, and uses the existing lifetime command-ID guard. Proven zero-write rejection retires that request; an ambiguous socket write keeps it pending. These transport checks do not prove the field value or a completed user action. + The same Working Draft defines `ErrorResponse.error` as `ErrorCode`. Its rendered local-end CDDL enumerates 30 values and omits `no such client window`, while §3.5 separately defines `no such client window` and normative client-window algorithms return that error code. OriginWeave therefore admits the finite rendered CDDL vocabulary plus this one separately defined normative error, and still rejects arbitrary error-code text fail closed. This is an explicit interoperability exception for a specification-internal inconsistency, not authority to infer or accept other strings; adding any further code requires fresh primary-source review and regression evidence. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft).