diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ca50378..c1ff524f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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. - 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. - 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/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d023318c6..43fc22576 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -49,6 +49,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; @@ -145,6 +146,9 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownAssessmentError, 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..69d51f9bf --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_type_text_response.rs @@ -0,0 +1,143 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiReceivedTextMessage, +}; + +/// 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. Both success and error replies must match the registered + /// 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: &WebDriverBiDiReceivedTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()) + .map_err(|source| WebDriverBiDiTypeTextResponseError::Envelope { source })?; + let completed = correlation + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::TypeText, + message.connection_generation(), + ) + .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.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs new file mode 100644 index 000000000..32fd8e3e7 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response.rs @@ -0,0 +1,363 @@ +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, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTypeTextCommand, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, + WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, + 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 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"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type AdmittedTypeTextFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + 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> { + 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()), + } +} + +#[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> { + 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> { + 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()?; + 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, 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 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 text = read_response_text(established)?; + 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 + .join() + .map_err(|_| io::Error::other("text-input response test server panicked"))??; + Ok(()) +} 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..24d5af394 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_response_failures.rs @@ -0,0 +1,199 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiTypeTextResponseError, + WebDriverBiDiTypeTextResult, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageReader, +}; + +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}"#; + +#[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(); + 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 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:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("text-input response fixture server panicked"))??; + Ok(text) +} + +#[test] +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)?; + + 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::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 42, + }, + } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input response correlation failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + 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_for(42, WebDriverBiDiCommandKind::TypeText)?; + + 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_for(42, WebDriverBiDiCommandKind::TypeText)?; + + 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(()) +} 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/doctoring/webdriver-bidi-received-response-connection-provenance.md b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md index 3dab7f151..9ef9dde67 100644 --- a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md +++ b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md @@ -42,6 +42,22 @@ Fresh integration verification executes 13 focused received-message, response an ## Evidence and remaining risk +### 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. +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. At predecessor `35cb1197`, pointer and status consumers had not adopted +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 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 9b4e08f5d..01661c071 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,6 +1,64 @@ # Action Post-Condition Evidence Traceability -## Text sender adopts pointer safeguards — 2026-09-07 +## 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 +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, +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. + +## Historical parent: text sender adopts pointer safeguards — 2026-09-07 Ordinary merge `7e4bd76d` adopts #266 `e3885f69` while preserving #267 `3346d8ec`, including its unchanged text sender, public exports, text tests and 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 = {