From 01c82b406cebcead98b276a89d8bdbeb82fe4b42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:17:37 +0900 Subject: [PATCH 01/16] test(network): require typed BiDi text-input response admission --- .../webdriver_bidi_type_text_response.rs | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_type_text_response.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs new file mode 100644 index 000000000..f25d2e316 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs @@ -0,0 +1,256 @@ +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, WebDriverBiDiTypeTextCommand, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, send_webdriver_bidi_type_text, +}; + +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 TYPE_TEXT_SUCCESS_RESPONSE: &[u8] = + br#"{"type":"success","id":42,"result":{"vendorExtension":{"observed":false}}}"#; +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 AdmittedTypeTextFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +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 semantic_observation_proof() -> Result> { + protocol_proof(BrowserProtocolCapability::SemanticObservation) +} + +fn typed_input_proof() -> Result> { + protocol_proof(BrowserProtocolCapability::TypedInput) +} + +fn admitted_type_text_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + 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:?}")) + })?; + 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)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "frame length exceeds 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) +} + +fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +#[test] +fn type_text_protocol_success_consumes_exact_outstanding_command() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (registry, handle, remote) = admitted_type_text_fixture()?; + let expected_json = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + "context-a", + "Quarterly review", + &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 input.performActions text-input command", + )); + } + write_text_frame(&mut stream, TYPE_TEXT_SUCCESS_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 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 established = send_webdriver_bidi_type_text( + typed_input_proof()?, + 42, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + 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!( + "text-input response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let result = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation)?; + assert_eq!(result.command_id(), 42); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("text-input response test server panicked"))??; + Ok(()) +} From c16e96cac958563cb15a6c23ec23bae305fc85d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:21:53 +0900 Subject: [PATCH 02/16] feat(network): admit typed BiDi text-input results --- crates/originweave-network/src/lib.rs | 4 + .../src/webdriver_bidi_type_text_response.rs | 137 +++++++++++++++ ...driver_bidi_type_text_response_failures.rs | 157 ++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_type_text_response.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index e53646aaa..71a6aa299 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -47,6 +47,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_type_text_response; mod webdriver_bidi_type_text_transport; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; @@ -138,6 +139,9 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, }; +pub use webdriver_bidi_type_text_response::{ + WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, +}; pub use webdriver_bidi_type_text_transport::{ WebDriverBiDiTypeTextSendError, send_webdriver_bidi_type_text, }; diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs new file mode 100644 index 000000000..53b40b744 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs @@ -0,0 +1,137 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, +}; + +/// Typed protocol acknowledgment for one correlated WebDriver BiDi `input.performActions` +/// node-bound text-input command. +/// +/// The WebDriver BiDi `input.performActions` command returns the extensible `EmptyResult` shape. +/// The common local-end envelope parser already validates the complete JSON document and requires a +/// success `result` object, so this command-specific boundary intentionally retains no generic +/// result body and accepts extension members. This value proves only that the remote end returned a +/// correlated protocol success; it does not prove that the target received text, that a DOM or +/// accessibility state changed, or that any other OriginWeave post-condition was observed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiTypeTextResult { + command_id: u64, +} + +impl WebDriverBiDiTypeTextResult { + /// Parse one bounded local-end message and consume its exact outstanding command on response. + /// + /// Complete JSON and common WebDriver BiDi envelope validation occur before correlation state + /// can be consumed. Successful responses retain only the matched command id. A correlatable + /// protocol-error response consumes its matching id and returns a typed remote failure, while + /// events, null-id errors, malformed envelopes, and unknown ids fail closed without consuming + /// unrelated outstanding state. + pub fn parse_and_correlate( + message: &WebDriverBiDiWebSocketTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message) + .map_err(|source| WebDriverBiDiTypeTextResponseError::Envelope { source })?; + let completed = correlation + .correlate_response(&envelope) + .map_err(|source| WebDriverBiDiTypeTextResponseError::Correlation { source })?; + + match completed.outcome() { + WebDriverBiDiCorrelatedResponseOutcome::Success => Ok(Self { + command_id: completed.command_id(), + }), + WebDriverBiDiCorrelatedResponseOutcome::Error => Err( + WebDriverBiDiTypeTextResponseError::RemoteProtocolError { + command_id: completed.command_id(), + }, + ), + } + } + + /// Return the exact local command identifier consumed by this protocol acknowledgment. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } +} + +/// Fail-closed failures while admitting one typed WebDriver BiDi text-input response. +#[derive(Debug)] +pub enum WebDriverBiDiTypeTextResponseError { + /// Common local-end JSON envelope validation failed before correlation state was touched. + Envelope { + /// Exact common-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// Exact command-response correlation failed without consuming unrelated state. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// The remote end returned a correlatable WebDriver BiDi protocol error for this command. + RemoteProtocolError { + /// Exact local command identifier consumed by the protocol-error response. + command_id: u64, + }, +} + +impl fmt::Display for WebDriverBiDiTypeTextResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Envelope { .. } => { + formatter.write_str("WebDriver BiDi text-input envelope is invalid") + } + Self::Correlation { .. } => { + formatter.write_str("WebDriver BiDi text-input response correlation failed") + } + Self::RemoteProtocolError { .. } => { + formatter.write_str("WebDriver BiDi text-input returned a protocol error") + } + } + } +} + +impl Error for WebDriverBiDiTypeTextResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::RemoteProtocolError { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_errors_have_stable_messages_and_typed_sources() { + let envelope = WebDriverBiDiTypeTextResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }; + assert_eq!( + envelope.to_string(), + "WebDriver BiDi text-input envelope is invalid" + ); + assert!(envelope.source().is_some()); + + let correlation = WebDriverBiDiTypeTextResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi text-input response correlation failed" + ); + assert!(correlation.source().is_some()); + + let remote = WebDriverBiDiTypeTextResponseError::RemoteProtocolError { command_id: 42 }; + assert_eq!( + remote.to_string(), + "WebDriver BiDi text-input returned a protocol error" + ); + assert!(remote.source().is_none()); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs new file mode 100644 index 000000000..a5b4c1b48 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs @@ -0,0 +1,157 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketTextMessage, +}; + +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 REMOTE_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":42,"error":"invalid argument","message":"rejected"}"#; +const UNKNOWN_ID_RESPONSE: &[u8] = + br#"{"type":"success","id":43,"result":{"vendorExtension":true}}"#; +const MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":42}"#; + +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 write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn receive_response(payload: &'static [u8]) -> 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)?; + write_text_frame(&mut stream, payload) + }); + + 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 established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_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!( + "text-input response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("text-input response fixture server panicked"))??; + Ok(text) +} + +#[test] +fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result<(), Box> { + let text = receive_response(REMOTE_ERROR_RESPONSE)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(42)?; + + let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) + .err() + .ok_or_else(|| io::Error::other("remote protocol error was accepted as text-input success"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextResponseError::RemoteProtocolError { command_id: 42 } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input returned a protocol error" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn malformed_text_input_envelope_fails_before_consuming_correlation() -> Result<(), Box> { + let text = receive_response(MALFORMED_RESPONSE)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(42)?; + + let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) + .err() + .ok_or_else(|| io::Error::other("malformed text-input response was accepted"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextResponseError::Envelope { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn unknown_text_input_response_id_does_not_consume_outstanding_command() +-> Result<(), Box> { + let text = receive_response(UNKNOWN_ID_RESPONSE)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(42)?; + + let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) + .err() + .ok_or_else(|| io::Error::other("unknown text-input response id was accepted"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextResponseError::Correlation { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} From d72aff15251789bcadc6ae847e0142d5086b8049 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:05:43 +0900 Subject: [PATCH 03/16] style(network): apply canonical rustfmt to text response --- .../src/webdriver_bidi_type_text_response.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs index 53b40b744..b746c2b4e 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs @@ -42,11 +42,11 @@ impl WebDriverBiDiTypeTextResult { WebDriverBiDiCorrelatedResponseOutcome::Success => Ok(Self { command_id: completed.command_id(), }), - WebDriverBiDiCorrelatedResponseOutcome::Error => Err( - WebDriverBiDiTypeTextResponseError::RemoteProtocolError { + WebDriverBiDiCorrelatedResponseOutcome::Error => { + Err(WebDriverBiDiTypeTextResponseError::RemoteProtocolError { command_id: completed.command_id(), - }, - ), + }) + } } } From 8d4027e40b790d28d866051ba741db12927ec22c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:06:11 +0900 Subject: [PATCH 04/16] style(network): apply canonical rustfmt to text response tests --- .../webdriver_bidi_type_text_response_failures.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs index a5b4c1b48..f35b457da 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs @@ -59,7 +59,9 @@ fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { stream.write_all(payload) } -fn receive_response(payload: &'static [u8]) -> Result> { +fn receive_response( + payload: &'static [u8], +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -100,14 +102,17 @@ fn receive_response(payload: &'static [u8]) -> Result Result<(), Box> { +fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result<(), Box> +{ let text = receive_response(REMOTE_ERROR_RESPONSE)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation.register_command(42)?; let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) .err() - .ok_or_else(|| io::Error::other("remote protocol error was accepted as text-input success"))?; + .ok_or_else(|| { + io::Error::other("remote protocol error was accepted as text-input success") + })?; assert!(matches!( error, WebDriverBiDiTypeTextResponseError::RemoteProtocolError { command_id: 42 } @@ -122,7 +127,8 @@ fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result< } #[test] -fn malformed_text_input_envelope_fails_before_consuming_correlation() -> Result<(), Box> { +fn malformed_text_input_envelope_fails_before_consuming_correlation() -> Result<(), Box> +{ let text = receive_response(MALFORMED_RESPONSE)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation.register_command(42)?; From a9446fbc66c6e09d79f8c8d9c7ac046724636681 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:27:58 +0900 Subject: [PATCH 05/16] test(network): require text response family isolation Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- ...driver_bidi_type_text_response_failures.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs index f35b457da..2fb8a83fc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs @@ -8,6 +8,7 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, @@ -24,6 +25,38 @@ const UNKNOWN_ID_RESPONSE: &[u8] = br#"{"type":"success","id":43,"result":{"vendorExtension":true}}"#; const MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":42}"#; +#[test] +fn text_response_rejects_other_command_families_without_consuming_them() +-> Result<(), Box> { + for payload in [ + br#"{"type":"success","id":42,"result":{}}"#.as_slice(), + REMOTE_ERROR_RESPONSE, + ] { + for kind in [ + WebDriverBiDiCommandKind::SessionStatus, + WebDriverBiDiCommandKind::SessionEnd, + WebDriverBiDiCommandKind::PointerClick, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + ] { + let text = receive_response(payload)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(42, kind)?; + assert!(matches!( + WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation), + Err(WebDriverBiDiTypeTextResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::TypeText, + actual, + }, + }) if actual == kind + )); + assert_eq!(correlation.outstanding_count(), 1); + } + } + 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 cd93d9fc5b99048804dbfc5dcc7492642c72ed64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:28:59 +0900 Subject: [PATCH 06/16] fix(network): correlate text responses with their command family Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_type_text_response.rs | 7 ++++--- ...ebdriver_bidi_type_text_response_failures.rs | 17 ++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs index b746c2b4e..791832442 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs @@ -2,7 +2,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, }; @@ -27,7 +27,8 @@ impl WebDriverBiDiTypeTextResult { /// can be consumed. Successful responses retain only the matched command id. A correlatable /// protocol-error response consumes its matching id and returns a typed remote failure, while /// events, null-id errors, malformed envelopes, and unknown ids fail closed without consuming - /// unrelated outstanding state. + /// unrelated outstanding state. Both success and error replies must match the registered + /// text-input command family. This boundary does not prove received-connection provenance. pub fn parse_and_correlate( message: &WebDriverBiDiWebSocketTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, @@ -35,7 +36,7 @@ impl WebDriverBiDiTypeTextResult { let envelope = WebDriverBiDiJsonEnvelope::parse(message) .map_err(|source| WebDriverBiDiTypeTextResponseError::Envelope { source })?; let completed = correlation - .correlate_response(&envelope) + .correlate_response_for(&envelope, WebDriverBiDiCommandKind::TypeText) .map_err(|source| WebDriverBiDiTypeTextResponseError::Correlation { source })?; match completed.outcome() { diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs index 2fb8a83fc..9f04e4ebb 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs @@ -8,12 +8,11 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, - WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, + WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -139,7 +138,7 @@ fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result< { let text = receive_response(REMOTE_ERROR_RESPONSE)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation.register_command(42)?; + correlation.register_command_for(42, WebDriverBiDiCommandKind::TypeText)?; let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) .err() @@ -164,7 +163,7 @@ fn malformed_text_input_envelope_fails_before_consuming_correlation() -> Result< { let text = receive_response(MALFORMED_RESPONSE)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation.register_command(42)?; + correlation.register_command_for(42, WebDriverBiDiCommandKind::TypeText)?; let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) .err() @@ -182,7 +181,7 @@ fn unknown_text_input_response_id_does_not_consume_outstanding_command() -> Result<(), Box> { let text = receive_response(UNKNOWN_ID_RESPONSE)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation.register_command(42)?; + correlation.register_command_for(42, WebDriverBiDiCommandKind::TypeText)?; let error = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation) .err() From ba43d61cc7617b855b7ce775845fef20162d2c6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:29:40 +0900 Subject: [PATCH 07/16] docs: bound text response integration evidence Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + .../action-postcondition-evidence.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbfab24d..a6c87dffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Text-entry replies cannot complete another kind of pending browser request. Malformed and unrelated replies preserve pending work; acknowledgment alone still does not prove the field changed or authenticate the reply's connection. - Text entry rejects a connection for a different browser session and invalid deadlines before reserving a pending request. Rejected writes that provably sent nothing release that request; uncertain writes remain pending and are not silently retried. Real-browser outcome verification remains unfinished. - Retained text-input privacy and validation while adopting the latest click and subscription safeguards; text dispatch and browser outcome verification remain unfinished. - Integrated the current navigation-subscription safeguards while preserving active-subscription admission, replay rejection and stale-document checks. A response from a replacement connection still cannot complete an earlier session shutdown; this source integration is not real-browser or release acceptance. diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 9db30dece..570fb4c1b 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,5 +1,23 @@ # Action Post-Condition Evidence Traceability +## Text-response integration checkpoint — 2026-09-06 + +#268 adopts #267 `4435ce5f561ca069c1844a1a5bd9b603505e25f7` by ordinary merge, +preserving the original response validation and transport safeguards. Test-first +commit `a9446fbc` requires success and error replies to reject every other registered +command family without consuming its pending request. Parent adoption exposed the +removed generic correlation call; `cd93d9fc` reuses the existing `TypeText` family +guard. Five focused response tests pass, including ten wrong-family cases, the real +send/receive round trip, extensible success results, typed remote failure, malformed +envelopes and unknown identifiers. Complete combined-head verification remains pending. + +The consumer still receives an assembled message without authenticated receipt +provenance. Family isolation does not prove that the reply arrived on the command's +connection, that text appeared in the field, or that an action was authorized. +Connection-bound receipt admission remains a separate required repair before runtime +acceptance. Earlier checkpoint evidence below is revision-specific, not current-head +CI, protected-main delivery or release acceptance. + ## Text-transport integration checkpoint — 2026-09-06 #267 adopts #266 `eb6c236ff2f4a58b807a2f2c914bd1ddb6079fb3` through ordinary merge From 4632f2df786cd7745480395342429ac222bc37cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:39:42 +0900 Subject: [PATCH 08/16] test(network): reject text replies from replacement sockets Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../webdriver_bidi_type_text_response.rs | 99 ++++++++++++++++++- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs index f25d2e316..20ec55563 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs @@ -1,7 +1,7 @@ use std::{ error::Error, io::{self, Read, Write}, - net::{TcpListener, TcpStream}, + net::{SocketAddr, TcpListener, TcpStream}, thread, time::Duration, }; @@ -15,10 +15,13 @@ use originweave_core::{ WebDriverBiDiRemoteNodeReference, WebDriverBiDiTypeTextCommand, WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResult, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, + WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, send_webdriver_bidi_type_text, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + send_webdriver_bidi_type_text, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -26,6 +29,8 @@ 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 TYPE_TEXT_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":42,"result":{"vendorExtension":{"observed":false}}}"#; +const TYPE_TEXT_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":42,"error":"invalid argument","message":"rejected"}"#; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = OriginWeaveProtocolVersion::new(0, 1); const ADAPTER_VERSION: &str = "originweave-bidi-v1"; @@ -38,6 +43,92 @@ type AdmittedTypeTextFixture = ( WebDriverBiDiRemoteNodeReference, ); +fn establish_response_connection( + local_addr: SocketAddr, +) -> Result> { + 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()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn read_response_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result> { + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + match WebDriverBiDiWebSocketMessageAssembler::new().push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => Ok(text), + _ => Err(io::Error::other("fixture expected a complete text response").into()), + } +} + +#[test] +fn replacement_socket_cannot_complete_text_input() -> Result<(), Box> { + for payload in [TYPE_TEXT_SUCCESS_RESPONSE, TYPE_TEXT_ERROR_RESPONSE] { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut first, _) = listener.accept()?; + read_opening_request(&mut first)?; + first.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut first)?; + assert!(command.starts_with(br#"{"id":42,"method":"input.performActions""#)); + let (mut second, _) = listener.accept()?; + read_opening_request(&mut second)?; + second.write_all(OPENING_RESPONSE)?; + write_text_frame(&mut second, payload)?; + write_text_frame(&mut first, TYPE_TEXT_SUCCESS_RESPONSE) + }); + let (registry, handle, remote) = admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; + let first = send_webdriver_bidi_type_text( + typed_input_proof()?, + 42, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + establish_response_connection(local_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 2); + let foreign = read_response_text(establish_response_connection(local_addr)?)?; + let rejected = WebDriverBiDiTypeTextResult::parse_and_correlate(&foreign, &mut correlation); + let original = read_response_text(first)?; + server + .join() + .map_err(|_| io::Error::other("response server panicked"))??; + assert!( + matches!( + rejected, + Err(WebDriverBiDiTypeTextResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 42, + }, + }) + ), + "replacement socket must not acknowledge the original request: {rejected:?}" + ); + assert_eq!(correlation.outstanding_count(), 2); + let result = WebDriverBiDiTypeTextResult::parse_and_correlate(&original, &mut correlation)?; + assert_eq!(result.command_id(), 42); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} + fn protocol_proof( capability: BrowserProtocolCapability, ) -> Result> { From d6889c800bb3b5ed9b5bd181e66145a6b88fe0a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:41:23 +0900 Subject: [PATCH 09/16] fix(network): require sealed receipt provenance for text replies Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_type_text_response.rs | 15 +++++++---- .../webdriver_bidi_type_text_response.rs | 27 +++++++------------ 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs index 791832442..69d51f9bf 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, - WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiReceivedTextMessage, }; /// Typed protocol acknowledgment for one correlated WebDriver BiDi `input.performActions` @@ -28,15 +28,20 @@ impl WebDriverBiDiTypeTextResult { /// protocol-error response consumes its matching id and returns a typed remote failure, while /// events, null-id errors, malformed envelopes, and unknown ids fail closed without consuming /// unrelated outstanding state. Both success and error replies must match the registered - /// text-input command family. This boundary does not prove received-connection provenance. + /// text-input command family and the sender's exact connection generation. Missing or + /// mismatched receipt provenance leaves the pending command untouched. pub fn parse_and_correlate( - message: &WebDriverBiDiWebSocketTextMessage, + message: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message) + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()) .map_err(|source| WebDriverBiDiTypeTextResponseError::Envelope { source })?; let completed = correlation - .correlate_response_for(&envelope, WebDriverBiDiCommandKind::TypeText) + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::TypeText, + message.connection_generation(), + ) .map_err(|source| WebDriverBiDiTypeTextResponseError::Correlation { source })?; match completed.outcome() { diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs index 20ec55563..c07d7e186 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs @@ -16,11 +16,11 @@ use originweave_core::{ }; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, send_webdriver_bidi_type_text, }; @@ -62,10 +62,11 @@ fn establish_response_connection( fn read_response_text( established: WebDriverBiDiWebSocketEstablished, -) -> Result> { - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - match WebDriverBiDiWebSocketMessageAssembler::new().push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => Ok(text), +) -> Result> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => Ok(message), _ => Err(io::Error::other("fixture expected a complete text response").into()), } } @@ -325,17 +326,7 @@ fn type_text_protocol_success_consumes_exact_outstanding_command() -> Result<(), )?; assert_eq!(correlation.outstanding_count(), 1); - 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!( - "text-input response produced unexpected assembly state: {other:?}" - )) - .into()); - } - }; + let text = read_response_text(established)?; let result = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation)?; assert_eq!(result.command_id(), 42); assert_eq!(correlation.outstanding_count(), 0); From 35cb11979a29d1380043ed37ee1a9eebae77ca6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:44:21 +0900 Subject: [PATCH 10/16] test(network): retain text error semantics on sealed receipts Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../webdriver_bidi_type_text_response.rs | 31 ++++++++++++++++-- ...driver_bidi_type_text_response_failures.rs | 32 +++++++++++-------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs index c07d7e186..32fd8e3e7 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs @@ -268,6 +268,16 @@ fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { #[test] fn type_text_protocol_success_consumes_exact_outstanding_command() -> Result<(), Box> { + assert_sent_response(TYPE_TEXT_SUCCESS_RESPONSE) +} + +#[test] +fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result<(), Box> +{ + assert_sent_response(TYPE_TEXT_ERROR_RESPONSE) +} + +fn assert_sent_response(payload: &'static [u8]) -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let (registry, handle, remote) = admitted_type_text_fixture()?; @@ -294,7 +304,7 @@ fn type_text_protocol_success_consumes_exact_outstanding_command() -> Result<(), "unexpected input.performActions text-input command", )); } - write_text_frame(&mut stream, TYPE_TEXT_SUCCESS_RESPONSE) + write_text_frame(&mut stream, payload) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -327,8 +337,23 @@ fn type_text_protocol_success_consumes_exact_outstanding_command() -> Result<(), assert_eq!(correlation.outstanding_count(), 1); let text = read_response_text(established)?; - let result = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation)?; - assert_eq!(result.command_id(), 42); + let result = WebDriverBiDiTypeTextResult::parse_and_correlate(&text, &mut correlation); + if payload == TYPE_TEXT_SUCCESS_RESPONSE { + assert_eq!(result?.command_id(), 42); + } else { + let error = result + .err() + .ok_or_else(|| io::Error::other("remote error accepted as success"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextResponseError::RemoteProtocolError { command_id: 42 } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input returned a protocol error" + ); + assert!(error.source().is_none()); + } assert_eq!(correlation.outstanding_count(), 0); server diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs index 9f04e4ebb..24d5af394 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs @@ -9,10 +9,10 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -93,7 +93,7 @@ fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { fn receive_response( payload: &'static [u8], -) -> Result> { +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -115,10 +115,10 @@ fn receive_response( )? .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, + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "text-input response produced unexpected assembly state: {other:?}" @@ -134,8 +134,8 @@ fn receive_response( } #[test] -fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result<(), Box> -{ +fn remote_protocol_error_cannot_consume_a_command_without_sender_provenance() +-> Result<(), Box> { let text = receive_response(REMOTE_ERROR_RESPONSE)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation.register_command_for(42, WebDriverBiDiCommandKind::TypeText)?; @@ -147,14 +147,18 @@ fn remote_protocol_error_consumes_only_the_exact_text_input_command() -> Result< })?; assert!(matches!( error, - WebDriverBiDiTypeTextResponseError::RemoteProtocolError { command_id: 42 } + WebDriverBiDiTypeTextResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 42, + }, + } )); assert_eq!( error.to_string(), - "WebDriver BiDi text-input returned a protocol error" + "WebDriver BiDi text-input response correlation failed" ); - assert!(error.source().is_none()); - assert_eq!(correlation.outstanding_count(), 0); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); Ok(()) } From e567af9e678fd4791776df795e89ed666975e6c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:45:33 +0900 Subject: [PATCH 11/16] docs: trace text receipt provenance repair and remaining boundaries Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + ...received-response-connection-provenance.md | 14 +++++++++++++ .../action-postcondition-evidence.md | 20 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e65d302cb..dd4e00f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Reject text-entry replies received on a replacement connection without losing the original pending request. The original connection can still complete it; a reply alone does not prove the field changed. - Text-entry replies cannot complete another kind of pending browser request. Malformed and unrelated replies preserve pending work; acknowledgment alone still does not prove the field changed or authenticate the reply's connection. - Retain each pending text-entry request's original connection so a connection-aware response consumer can reject replies from a replacement socket. Consumer integration and observed field-value verification remain separate requirements. - Text entry rejects a connection for a different browser session and invalid deadlines before reserving a pending request. Rejected writes that provably sent nothing release that request; uncertain writes remain pending and are not silently retried. Real-browser outcome verification remains unfinished. diff --git a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md index 330b6e38f..7a27aedbd 100644 --- a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md +++ b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md @@ -42,6 +42,20 @@ Fresh integration verification executes 13 focused received-message, response an ## Evidence and remaining risk +### Text-input receipt integration + +PR #268 applies the existing selected boundary to text input. Actual RED `4632f2df` +accepted a success reply from a replacement socket on the same listener/session. +Consumer `d6889c80` requires the sealed received-message type; sender owner #267 +`3346d8ec` retains its private connection generation before I/O. Ordinary merge +`e1188c86` combines them. The real socket regression rejects foreign success and +error replies without consuming either pending entry, then accepts the original +connection's reply and leaves unrelated work pending. Fixture migration `35cb1197` +preserves real matched remote-error consumption, extensible success, malformed and +unknown-envelope rejection, family isolation and missing-provenance rejection. +These are local source/test observations, not hosted acceptance or observed field +mutation. Pointer and status consumers have not adopted this boundary at this head. + ### Pointer-click child integration PR #256 predecessor `9f2e6f29be46371762e3031a97c1cac04720694f` lacked the current connection-provenance implementation and collected zero command-correlation release tests under native discovery. The expected-one assertion failed before ordinary integration of parent `3e7057443d7c9532ff526acb5eefe8cd4778c767`; the inherited contract then collected and passed. Its core command implementation, exports and four pointer-click tests are byte-identical to the predecessor. The sole child delta in the parent's correlation module remains the documented `PointerClick` command kind. Serialization does not prove a browser click, authorize input, or establish an observed page post-condition. diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 37a33cbad..a80515a1c 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,5 +1,25 @@ # Action Post-Condition Evidence Traceability +## Text receipt-provenance repair — 2026-09-06 + +Actual socket RED `4632f2df` accepted a reply from a replacement connection sharing +the original listener and session. Sealed consumer `d6889c80` then exposed missing +sender provenance. Ordinary merge `e1188c86` adopts canonical #267 sender fix +`3346d8ec`; it retains the transport's private generation before sending. +The consumer now requires the existing sealed received-message type and compares +the receiving connection before consuming correlation. No caller-supplied generation, +raw-message fallback or new parser is introduced. + +Seven focused tests pass at `35cb1197`: foreign success/error rejection with original +connection recovery and unrelated pending-state retention, real success and remote +error completion, missing sender provenance, wrong command family, malformed envelope +and unknown id. Extensible results and payload-free error chains remain intact. +Complete current-head verification and publication are still pending. This repair +does not prove browser-process ownership, policy approval or observed field values. +Pointer and status receipt consumers remain separate required owner repairs. + +The following integration checkpoints are historical evidence, not current semantics. + ## Text-response integration checkpoint — 2026-09-06 #268 adopts #267 `4435ce5f561ca069c1844a1a5bd9b603505e25f7` by ordinary merge, From 716fd84294320d16dd554a638f1805175109e778 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:51:04 +0900 Subject: [PATCH 12/16] test(network): replay pointer authority on text response stack --- ...er_click_response_connection_provenance.rs | 295 ++++++++++++++++++ ...nter_click_transport_session_provenance.rs | 191 ++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_pointer_click_transport_session_provenance.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs new file mode 100644 index 000000000..976f2f170 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs @@ -0,0 +1,295 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiPointerClickResponseError, + WebDriverBiDiPointerClickResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketTextMessage, send_webdriver_bidi_pointer_click, +}; + +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 CLICK_SUCCESS_RESPONSE: &[u8] = + br#"{"type":"success","id":42,"result":{"vendorExtension":{"observed":false}}}"#; +const CLICK_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":42,"error":"invalid argument","message":"blocked","stacktrace":"remote"}"#; + +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 AdmittedPointerClickFixture = ( + 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 typed_input_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::TypedInput], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::TypedInput, + )?) +} + +fn admitted_pointer_click_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + 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:?}")) + })?; + 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, + 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)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + usize::try_from(length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "pointer frame length exceeds 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) +} + +fn establish(local_addr: SocketAddr) -> Result> { + 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()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn read_response( + established: WebDriverBiDiWebSocketEstablished, +) -> Result> { + 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!( + "replacement pointer connection produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + Ok(text) +} + +fn assert_replacement_rejected(foreign_response: &'static [u8]) -> Result<(), Box> { + let original_listener = TcpListener::bind(("127.0.0.1", 0))?; + let original_addr = original_listener.local_addr()?; + let (registry, handle, remote) = admitted_pointer_click_fixture()?; + let expected = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &handle, + &remote, + ®istry, + )?; + let expected_json = expected.as_json().as_bytes().to_vec(); + let original_server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = original_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 pointer command on original connection", + )); + } + let (mut replacement, _) = original_listener.accept()?; + read_opening_request(&mut replacement)?; + replacement.write_all(OPENING_RESPONSE)?; + replacement.write_all(&[0x81, foreign_response.len() as u8])?; + replacement.write_all(foreign_response)?; + stream.write_all(&[0x81, CLICK_SUCCESS_RESPONSE.len() as u8])?; + stream.write_all(CLICK_SUCCESS_RESPONSE) + }); + + let original = establish(original_addr)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; + let original = send_webdriver_bidi_pointer_click( + typed_input_proof()?, + 42, + "context-a", + &handle, + &remote, + ®istry, + original, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 2); + + let replacement_response = read_response(establish(original_addr)?)?; + let parsed = WebDriverBiDiPointerClickResult::parse_and_correlate( + &replacement_response, + &mut correlation, + ); + + let original_response = read_response(original)?; + original_server + .join() + .map_err(|_| io::Error::other("original pointer server panicked"))??; + assert!( + matches!( + parsed, + Err(WebDriverBiDiPointerClickResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 42 + } + }) + ), + "replacement response must fail for exact connection mismatch: {parsed:?}" + ); + assert_eq!(correlation.outstanding_count(), 2); + let accepted = + WebDriverBiDiPointerClickResult::parse_and_correlate(&original_response, &mut correlation)?; + assert_eq!(accepted.command_id(), 42); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn replacement_success_cannot_consume_original_pointer_command() -> Result<(), Box> { + assert_replacement_rejected(CLICK_SUCCESS_RESPONSE) +} + +#[test] +fn replacement_error_cannot_consume_original_pointer_command() -> Result<(), Box> { + assert_replacement_rejected(CLICK_ERROR_RESPONSE) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_transport_session_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_transport_session_provenance.rs new file mode 100644 index 000000000..192c9a1d5 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_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, + WebDriverBiDiPointerClickAuthorityError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiPointerClickSendError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + send_webdriver_bidi_pointer_click, +}; + +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_pointer_click_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_pointer_click( + protocol_proof(BrowserProtocolCapability::TypedInput)?, + 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 pointer server panicked"))??; + + let error = send_result.err().ok_or_else(|| { + io::Error::other( + "registry session A unexpectedly dispatched pointer input on transport session B", + ) + })?; + assert!(matches!( + error, + WebDriverBiDiPointerClickSendError::Authority { + source: WebDriverBiDiPointerClickAuthorityError::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 pointer command-frame byte" + ); + Ok(()) +} From fcd49b5378a06755374d33b8da1cc1cf178495c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:53:24 +0900 Subject: [PATCH 13/16] docs: record preserved text response and pointer evidence --- CHANGELOG.md | 1 + docs/doctoring.md | 10 ++++++++++ .../action-postcondition-evidence.md | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efa3374f7..299f6e3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Preserve text-reply checks while adopting click-session and reply safeguards. A matched response still does not prove the requested field changed. - Reject text-entry replies received on a replacement connection without losing the original pending request. The original connection can still complete it; a reply alone does not prove the field changed. - Text-entry replies cannot complete another kind of pending browser request. Malformed and unrelated replies preserve pending work; acknowledgment alone still does not prove the field changed or authenticate the reply's connection. - Preserve text-entry safeguards while rejecting clicks sent to another browser session and click replies from replacement connections. These checks do not yet verify that the browser changed the requested field. diff --git a/docs/doctoring.md b/docs/doctoring.md index 0d419b87c..77b61acac 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -4,6 +4,16 @@ This document records external evidence that changes OriginWeave architecture, t ## Decision trace +### Text-response stack retains pointer session and receipt safeguards + +Real-socket replay `716fd842` reproduced all three inherited pointer failures on +#268 before ordinary adoption of #267 `ebd507ae` in `34e537b1`. The repair retains +the already sealed text-response consumer and its existing tests unchanged while +adopting the parent's click-session rejection and exact-connection replies. This +is implementation evidence for existing local policy, not a new standards claim. +A matched text acknowledgment remains separate from observing the requested field +value, browser authentication and authorized action completion. + ### Pointer safeguards retained by the text sender The #267 real-socket replay at `4e020e16` failed all three inherited pointer diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 9da4743ba..01661c071 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,5 +1,25 @@ # Action Post-Condition Evidence Traceability +## Text responses retain pointer safeguards — 2026-09-07 + +Ordinary merge `34e537b1` adopts published #267 `ebd507ae` while preserving +#268 `e567af9e` and its complete sealed text-response consumer. The text sender, +consumer, exports, all text tests and entire core crate are unchanged. Actual +RED `716fd842` first reproduced foreign-session click dispatch and replacement +success/error consumption: all three pointer regressions failed before adoption. +The inherited canonical sender and sealed click consumer now reject those cases +without consuming the original or unrelated pending work. + +Text replies already require the sending connection and exact command family; +missing provenance, malformed envelopes and unknown identifiers preserve pending +work, while matched remote errors consume only their own request. No generic +response fallback or duplicated parser is introduced. The earlier text repair's +hosted success belongs to `e567af9e`, not this combined head, which requires fresh +full verification and actual visual inspection. Observed field values, browser +authentication, policy approval and causal action success remain unproven. +Status receipts and protected-foundation integration remain separate owner work. +The dated checkpoints below preserve predecessor evidence, not current acceptance. + ## Text receipt-provenance repair — 2026-09-06 Actual socket RED `4632f2df` accepted a reply from a replacement connection sharing From 49d18f5fb1ad19bc702f2da2163ff05d33601c1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:07:39 +0900 Subject: [PATCH 14/16] test: distinguish historical text receipt limitations --- tests/test_product_documentation_contract.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 1313189ea..1a8676e2d 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,6 +11,16 @@ class ProductDocumentationContractTests(unittest.TestCase): """Keep product requirements, technical design, diagrams, and traceability discoverable.""" + def test_text_receipt_checkpoint_is_explicitly_historical(self) -> None: + """An earlier pointer limitation must not contradict its later adoption.""" + note = (ROOT / "docs/doctoring/webdriver-bidi-received-response-connection-provenance.md").read_text(encoding="utf-8") + checkpoint = note.split("## Evidence and remaining risk", 1)[1].split( + "### Pointer-click child integration", 1 + )[0] + self.assertIn("### Historical text-input receipt integration", checkpoint) + self.assertIn("At predecessor `35cb1197`", checkpoint) + self.assertNotIn("at this head", checkpoint) + def test_authoritative_product_documentation_graph_exists(self) -> None: """Major product decisions must not require reconstructing chat or PR history.""" required_paths = { From ba22e9ade0cef635112f08d98890f4e96708f3b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:08:41 +0900 Subject: [PATCH 15/16] docs: anchor earlier text receipt limitations to predecessor --- CHANGELOG.md | 1 + ...ebdriver-bidi-received-response-connection-provenance.md | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299f6e3f1..c1ff524f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- 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. - Reject text-entry replies received on a replacement connection without losing the original pending request. The original connection can still complete it; a reply alone does not prove the field changed. - Text-entry replies cannot complete another kind of pending browser request. Malformed and unrelated replies preserve pending work; acknowledgment alone still does not prove the field changed or authenticate the reply's connection. diff --git a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md index bc4e93fbc..ada26a404 100644 --- a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md +++ b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md @@ -42,7 +42,7 @@ Fresh integration verification executes 13 focused received-message, response an ## Evidence and remaining risk -### Text-input receipt integration +### Historical text-input receipt integration PR #268 applies the existing selected boundary to text input. Actual RED `4632f2df` accepted a success reply from a replacement socket on the same listener/session. @@ -54,7 +54,9 @@ connection's reply and leaves unrelated work pending. Fixture migration `35cb119 preserves real matched remote-error consumption, extensible success, malformed and unknown-envelope rejection, family isolation and missing-provenance rejection. These are local source/test observations, not hosted acceptance or observed field -mutation. Pointer and status consumers have not adopted this boundary at this head. +mutation. At predecessor `35cb1197`, pointer and status consumers had not adopted +this boundary. The later parent adoption recorded above includes the pointer +consumer; status response adoption remains separate work. ### Pointer-click child integration From ff27220cb5eb4d11ca1dc5614a4181e1a397a3f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:10:40 +0900 Subject: [PATCH 16/16] docs: link later pointer adoption evidence directly --- .../webdriver-bidi-received-response-connection-provenance.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md index ada26a404..9ef9dde67 100644 --- a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md +++ b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md @@ -55,8 +55,8 @@ preserves real matched remote-error consumption, extensible success, malformed a unknown-envelope rejection, family isolation and missing-provenance rejection. These are local source/test observations, not hosted acceptance or observed field mutation. At predecessor `35cb1197`, pointer and status consumers had not adopted -this boundary. The later parent adoption recorded above includes the pointer -consumer; status response adoption remains separate work. +this boundary. The later [parent adoption](../traceability/action-postcondition-evidence.md#text-responses-retain-pointer-safeguards--2026-09-07) +includes the pointer consumer; status response adoption remains separate work. ### Pointer-click child integration