diff --git a/CHANGELOG.md b/CHANGELOG.md index 532d0800e..a6ca50378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- 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. - 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/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 7feedb504..d023318c6 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -9,8 +9,8 @@ //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! binds received fragmented text to one exact verified connection, classifies //! complete local-end JSON envelopes, tracks bounded command-response correlation, -//! transports a narrowly typed pointer click, admits its typed correlated protocol -//! response, sends a context-bound committed-navigation subscription and retains +//! transports narrowly typed pointer-click and node-bound non-secret text-input +//! actions, admits typed correlated protocol responses, sends a context-bound committed-navigation subscription and retains //! its typed bounded correlated identifier, binds navigation-event admission to //! that active command/receipt lifecycle with bounded fail-closed navigation replay //! prevention, explicitly unsubscribes that exact @@ -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_transport; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; @@ -144,6 +145,9 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownAssessmentError, WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, }; +pub use webdriver_bidi_type_text_transport::{ + WebDriverBiDiTypeTextSendError, send_webdriver_bidi_type_text, +}; pub use webdriver_bidi_websocket_frame::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 98326c47a..b8b8d8ce9 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -26,6 +26,8 @@ pub enum WebDriverBiDiCommandKind { SessionEnd, /// WebDriver BiDi `input.performActions` pointer click. PointerClick, + /// WebDriver BiDi `input.performActions` node-bound non-secret text input. + TypeText, /// Context-scoped WebDriver BiDi `session.subscribe` for committed navigation. NavigationCommittedSubscription, /// WebDriver BiDi `session.unsubscribe` for one retained committed-navigation subscription. diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs new file mode 100644 index 000000000..0a2e0cb33 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -0,0 +1,176 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserProtocolCapability, BrowserProtocolKind, + ValidatedBrowserProtocolUse, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTypeTextAuthorityError, WebDriverBiDiTypeTextCommand, +}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +/// Fail-closed errors while transporting one current-authority text-input command. +#[derive(Debug)] +pub enum WebDriverBiDiTypeTextSendError { + /// The supplied protocol-use proof belongs to another browser protocol family. + UnsupportedProtocolKind(BrowserProtocolKind), + /// The supplied protocol-use proof did not validate typed-input capability. + UnsupportedCapability(BrowserProtocolCapability), + /// Text or current node, browser-context, document, or bounded command authority failed revalidation. + Authority { + /// Exact typed immediate-use authority failure. + source: WebDriverBiDiTypeTextAuthorityError, + }, + /// The bounded correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Writing the already-registered command frame failed and the transport is not reusable. + FrameWrite { + /// Exact typed bounded WebSocket frame-write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiTypeTextSendError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::UnsupportedProtocolKind(_) => { + "WebDriver BiDi text-input send requires a WebDriver BiDi proof" + } + Self::UnsupportedCapability(_) => { + "WebDriver BiDi text-input send requires typed-input capability" + } + Self::Authority { .. } => "WebDriver BiDi text-input authority was rejected", + Self::Correlation { .. } => { + "WebDriver BiDi text-input command correlation was rejected" + } + Self::FrameWrite { .. } => "WebDriver BiDi text-input command frame write failed", + }) + } +} + +impl Error for WebDriverBiDiTypeTextSendError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, + Self::Authority { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} + +/// Revalidate, register, and write one node-bound `input.performActions` text-input command. +/// +/// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose protocol family +/// is exactly [`BrowserProtocolKind::WebDriverBiDi`] and whose capability is exactly +/// [`BrowserProtocolCapability::TypedInput`]. The proof is consumed before text/node authority, +/// command correlation, or frame I/O, so semantic-observation, navigation, CDP, or other protocol +/// proofs cannot dispatch text input through this boundary. +/// +/// After protocol validation and immediately before correlation, this boundary reconstructs the +/// bounded command from the exact text, [`AdmittedNodeHandle`], external browsing-context +/// identifier, remote node reference, and live [`BrowserAuthorityRegistry`]. That immediate-use +/// check rejects invalid or over-budget text, stale document epochs, cross-registry handles, +/// changed origins, mismatched external contexts, and unadmitted wire node identifiers before any +/// command identifier is registered or any action frame is written. A previously constructed +/// command therefore cannot outlive its node authority and later bypass revalidation at transport +/// time. The admitted registry session must also match the established transport's verified +/// external session identifier; this read-only check cannot create or replace registry state. +/// +/// Invalid local deadlines fail before registration. Registration occurs before the first possible +/// remote side effect and binds the exact established connection generation for received-response +/// verification. A correlation failure writes +/// nothing. A malformed-frame preflight rejection retires this exact typed identifier because no +/// write began. Other frame-write failures leave the identifier outstanding because a partial or +/// complete remote side effect is ambiguous and the identifier must not be silently reused. +/// +/// The text value is intentionally non-secret and is never retained by this transport's error +/// variants. Secret material must use the separately governed broker/fill boundary. Typed-input +/// and node authority validation are still not policy authorization: a trusted caller must +/// separately establish deterministic policy approval and destination authority, then retain +/// correlated response and observed post-condition evidence afterward. This function does not +/// authenticate the browser, grant destination or secret authority, retry, reconnect, or choose +/// another destination. +#[expect( + clippy::too_many_arguments, + reason = "this immediate-use security boundary keeps text, command identity, live node authority, transport, correlation, masking, and deadline inputs explicit rather than persisting a reusable prevalidated command" +)] +pub fn send_webdriver_bidi_type_text( + validated: ValidatedBrowserProtocolUse, + command_id: u64, + browsing_context: &str, + text: &str, + handle: &AdmittedNodeHandle, + node: &WebDriverBiDiRemoteNodeReference, + registry: &BrowserAuthorityRegistry, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, +) -> Result { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err(WebDriverBiDiTypeTextSendError::UnsupportedProtocolKind( + validated.kind(), + )); + } + if validated.capability() != BrowserProtocolCapability::TypedInput { + return Err(WebDriverBiDiTypeTextSendError::UnsupportedCapability( + validated.capability(), + )); + } + let _consumed_typed_input_proof = validated; + + let command = WebDriverBiDiTypeTextCommand::new_for_current_node( + command_id, + browsing_context, + text, + handle, + node, + registry, + ) + .map_err(|source| WebDriverBiDiTypeTextSendError::Authority { source })?; + + registry + .require_registered_session_external_identifier( + handle.browser_session(), + established + .transport_evidence() + .verified_peer() + .session_id(), + ) + .map_err(|source| WebDriverBiDiTypeTextSendError::Authority { + source: WebDriverBiDiTypeTextAuthorityError::BrowserAuthority(source), + })?; + crate::webdriver_bidi_websocket_frame::validate_frame_timeout(frame_timeout) + .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source })?; + correlation + .register_command_for_connection( + command.command_id(), + WebDriverBiDiCommandKind::TypeText, + established.transport_evidence().connection_generation(), + ) + .map_err(|source| WebDriverBiDiTypeTextSendError::Correlation { source })?; + established + .write_command_frame( + command.command_id(), + command.as_json(), + masking_key, + frame_timeout, + ) + .map_err(|source| { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = correlation + .retire_command_for(command.command_id(), WebDriverBiDiCommandKind::TypeText); + } + WebDriverBiDiTypeTextSendError::FrameWrite { source } + }) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs new file mode 100644 index 000000000..4ddf8cf84 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs @@ -0,0 +1,395 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + sync::mpsc, + 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, WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, 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 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 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_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> { + read_masked_client_frame(stream, 0x81) +} + +fn read_masked_client_frame(stream: &mut TcpStream, opcode: u8) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != opcode || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + let length = usize::from(u16::from_be_bytes(extended)); + if length <= 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used non-minimal 16-bit length encoding", + )); + } + length + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + if length <= u64::from(u16::MAX) || length > usize::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used invalid 64-bit length encoding", + )); + } + length as usize + } + _ => unreachable!(), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +#[test] +fn type_text_command_writes_exact_masked_bidi_frame_and_stays_outstanding() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (registry, handle, remote) = admitted_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", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let _established = send_webdriver_bidi_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); + + assert!(matches!( + correlation.retire_command_for(42, WebDriverBiDiCommandKind::SessionStatus), + Err( + originweave_network::WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::SessionStatus, + actual: WebDriverBiDiCommandKind::TypeText, + } + ) + )); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("text-input transport test server panicked"))??; + Ok(()) +} +#[test] +fn type_text_reused_mask_key_rejection_retires_correlation() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let seed = read_masked_client_frame(&mut stream, 0x8a)?; + if seed != b"{}" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected seed frame before reused-key regression", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let established = + established.write_pong_frame(b"{}", repeated_key, Duration::from_millis(500))?; + + let (registry, handle, remote) = admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let error = send_webdriver_bidi_type_text( + typed_input_proof()?, + 43, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + repeated_key, + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("reused masking key unexpectedly sent a text input"))?; + assert_eq!(correlation.outstanding_count(), 0); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input command frame write failed" + ); + + server + .join() + .map_err(|_| io::Error::other("reused-mask-key pointer test server panicked"))??; + Ok(()) +} + +#[test] +fn type_text_ambiguous_socket_write_keeps_correlation() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (closed_sender, closed_receiver) = mpsc::channel(); + let (seed_ready_sender, seed_ready_receiver) = mpsc::channel(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let mut first_frame_byte = [0_u8; 1]; + stream.read_exact(&mut first_frame_byte)?; + seed_ready_receiver + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| io::Error::other("seed Pong did not finish before peer closure"))?; + drop(stream); + closed_sender + .send(()) + .map_err(|_| io::Error::other("text-input close signal receiver disappeared")) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))? + .write_pong_frame( + b"seed-frame", + WebDriverBiDiWebSocketMaskKey::new([13, 14, 15, 16]), + Duration::from_millis(500), + )?; + seed_ready_sender.send(())?; + closed_receiver.recv_timeout(Duration::from_secs(1))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let mut established = established; + let mut observed_ambiguous_failure = false; + let (registry, handle, remote) = admitted_type_text_fixture()?; + for attempt in 0_u8..64 { + let command_id = 44 + u64::from(attempt); + match send_webdriver_bidi_type_text( + typed_input_proof()?, + command_id, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([17, 18, 19, attempt]), + Duration::from_millis(500), + ) { + Ok(next) => { + correlation.retire_command_for(command_id, WebDriverBiDiCommandKind::TypeText)?; + established = next; + } + Err(error) => { + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + observed_ambiguous_failure = true; + break; + } + } + } + assert!(observed_ambiguous_failure); + + server + .join() + .map_err(|_| io::Error::other("ambiguous-write pointer test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_send_authority.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_send_authority.rs new file mode 100644 index 000000000..6d5f6317b --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_authority.rs @@ -0,0 +1,213 @@ +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, BrowsingContextId, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTypeTextAuthorityError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTypeTextSendError, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, 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 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 StaleNodeFixture = ( + BrowserAuthorityRegistry, + BrowsingContextId, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); +type RejectingPostHandshakeServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn semantic_observation_proof() -> Result> { + protocol_proof(BrowserProtocolCapability::SemanticObservation) +} + +fn typed_input_proof() -> Result> { + protocol_proof(BrowserProtocolCapability::TypedInput) +} + +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 stale_node_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"))?; + + registry.advance_document(browsing_context)?; + registry.bind_context_origin(browser_session, browsing_context, &origin)?; + + Ok((registry, browsing_context, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn establish_rejecting_post_handshake_bytes() -> Result> +{ + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "stale text-input authority wrote bytes after the WebSocket handshake", + )), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + Err(io::Error::new( + io::ErrorKind::TimedOut, + "stale text-input authority kept the transport open instead of failing closed", + )) + } + Err(error) => Err(error), + } + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn stale_admitted_node_is_rejected_at_type_text_send_before_correlation_or_wire_io() +-> Result<(), Box> { + let (registry, _browsing_context, handle, remote) = stale_node_fixture()?; + let (established, server) = establish_rejecting_post_handshake_bytes()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + + let error = 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), + ) + .err() + .ok_or_else(|| io::Error::other("stale admitted node unexpectedly reached text-input I/O"))?; + + assert!(matches!( + error, + WebDriverBiDiTypeTextSendError::Authority { + source: WebDriverBiDiTypeTextAuthorityError::NodeHandle(_) + } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input authority was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("stale-authority text server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs new file mode 100644 index 000000000..abd8c905b --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -0,0 +1,405 @@ +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, WebDriverBiDiTypeTextAuthorityError, + WebDriverBiDiTypeTextCommandError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTypeTextSendError, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, 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 ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type HandshakeOnlyServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); +type TypeTextFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn protocol_proof( + kind: BrowserProtocolKind, + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + kind, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + kind, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn semantic_observation_proof() -> Result> { + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::SemanticObservation, + ) +} + +fn typed_input_proof() -> Result> { + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::TypedInput, + ) +} + +fn type_text_fixture(session_id: &str) -> 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 establish_with_handshake_only_server() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted + ) => + { + Ok(()) + } + Ok(_) => Err(io::Error::other("rejected text command emitted wire bytes")), + Err(error) => Err(error), + } + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn type_text_rejects_non_typed_input_proof_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; + + let error = send_webdriver_bidi_type_text( + semantic_observation_proof()?, + 5, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("semantic-observation proof unexpectedly sent text input"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextSendError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input send requires typed-input capability" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("typed-input capability rejection server panicked"))??; + Ok(()) +} + +#[test] +fn type_text_rejects_non_webdriver_bidi_proof_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; + + let error = send_webdriver_bidi_type_text( + protocol_proof( + BrowserProtocolKind::ChromeDevToolsProtocol, + BrowserProtocolCapability::TypedInput, + )?, + 6, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("CDP typed-input proof unexpectedly sent text input"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextSendError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input send requires a WebDriver BiDi proof" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("WebDriver BiDi proof rejection server panicked"))??; + Ok(()) +} + +#[test] +fn type_text_rejects_invalid_text_before_correlation_or_frame_write() -> Result<(), Box> +{ + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; + + let error = send_webdriver_bidi_type_text( + typed_input_proof()?, + 7, + "context-a", + "buyer-private\ntext", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("invalid text unexpectedly reached WebSocket I/O"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextSendError::Authority { + source: WebDriverBiDiTypeTextAuthorityError::Command( + WebDriverBiDiTypeTextCommandError::InvalidText + ) + } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input authority was rejected" + ); + assert!(error.source().is_some()); + assert!(!format!("{error:?}").contains("buyer-private")); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("invalid-text rejection server panicked"))??; + Ok(()) +} + +#[test] +fn type_text_rejects_duplicate_correlation_before_frame_write() -> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(8, originweave_network::WebDriverBiDiCommandKind::TypeText)?; + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; + + let error = send_webdriver_bidi_type_text( + typed_input_proof()?, + 8, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("duplicate correlation unexpectedly sent text input"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextSendError::Correlation { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input command correlation was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("duplicate-correlation text server panicked"))??; + Ok(()) +} + +#[test] +fn type_text_rejects_invalid_frame_timeout_without_registration() -> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; + + let error = send_webdriver_bidi_type_text( + typed_input_proof()?, + 11, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::ZERO, + ) + .err() + .ok_or_else(|| io::Error::other("zero frame timeout unexpectedly sent text input"))?; + assert!(matches!( + error, + WebDriverBiDiTypeTextSendError::FrameWrite { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input command frame write failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("invalid-timeout text server panicked"))??; + Ok(()) +} + +#[test] +fn type_text_rejects_foreign_transport_session_without_correlation_or_wire_io() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = type_text_fixture("foreign-session")?; + let result = send_webdriver_bidi_type_text( + typed_input_proof()?, + 12, + "context-a", + "Quarterly review", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + assert!(matches!( + result, + Err(WebDriverBiDiTypeTextSendError::Authority { + source: WebDriverBiDiTypeTextAuthorityError::BrowserAuthority( + originweave_core::BrowserRegistryError::SessionExternalIdentifierMismatch + ) + }) + )); + assert_eq!(correlation.outstanding_count(), 0); + server + .join() + .map_err(|_| io::Error::other("foreign-session rejection server panicked"))??; + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index 9de4e3554..0d419b87c 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -4,6 +4,17 @@ This document records external evidence that changes OriginWeave architecture, t ## Decision trace +### Pointer safeguards retained by the text sender + +The #267 real-socket replay at `4e020e16` failed all three inherited pointer +regressions before ordinary adoption of #266 `e3885f69` in `7e4bd76d`. +Both foreign-session dispatch and replacement success/error consumption were +observable behavioral failures, not fixture setup or compilation failures. +The parent repair supplies canonical session validation and sealed reply +provenance without changing the text sender's existing connection registration, +current-node checks, deadlines or privacy boundary. This is local implementation +evidence for the existing policy, not a new standards claim or browser acceptance. + ### Text-command descendant preservation At `009f9a41`, the #266 text-command descendant reproduced all three inherited diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index df0d982b4..9b4e08f5d 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,6 +1,64 @@ # Action Post-Condition Evidence Traceability -## Pointer session and reply adoption — 2026-09-07 +## 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 +entire core crate. Actual RED `4e020e16` reproduced three inherited failures: +foreign-session click dispatch and replacement success/error replies consuming +the original request. The parent repair rejects the foreign session before any +request is reserved or sent, and rejects replacement replies without consuming +either pending command; the original reply still completes only its own request. + +Text dispatch already revalidates current node and session authority, registers +the sending connection, validates deadlines before registration, and distinguishes +proven no-write rejection from ambiguous writes. Those boundaries are unchanged. +The inherited click consumer uses sealed connection evidence; this does not finish +the separate text-response consumer owned by #268. Field-value post-conditions, +browser authentication, action policy and causal browser acceptance remain open. +Full combined-head checks and actual visual inspection must be recorded separately; +older checkpoints below are historical, not current hosted or protected-main proof. + +## Text sender receipt-provenance prerequisite — 2026-09-06 + +Child #268 regression `4632f2df` opens two connections to the same listener and +session, sends text input on the first and observes a successful acknowledgment +from the second being accepted. Sealed-consumer candidate `d6889c80` rejects both +connections because the sender has not retained connection provenance. This owner +repair reuses `register_command_for_connection` before frame I/O and records the +established transport's private generation. It preserves session/current-node +validation, deadlines, no-write retirement and ambiguous-write retention. + +This prerequisite alone does not make generic consumers connection-sensitive. +#268 must adopt it and finish sealed-reader migration, foreign success/error +rejection, original-connection recovery and complete verification. The regression +and consumer commits are local integration evidence until their publication is +verified. Browser authentication, policy approval and observed text-value success +remain unproven. Earlier checkpoint evidence below remains revision-specific. + +## Text-transport integration checkpoint — 2026-09-06 + +#267 adopts #266 `eb6c236ff2f4a58b807a2f2c914bd1ddb6079fb3` through ordinary merge +`d903cf6b`, preserving original transport `46a05d7f`. Text dispatch reconstructs the +command from current node authority and now uses the parent's sealed typed-write lane +with a distinct `TypeText` correlation family. The shared frame-deadline validator +runs before registration. Actual RED `2b960002` exposed a zero deadline retaining an +unsent command; RED `e2b49e68` exposed the same retention after reused-mask preflight. +The repair retires only malformed-frame rejections that prove no write began and +retains correlation after ambiguous socket failure. Independent review then exposed a +pre-existing cross-session gap: RED `10131eb7` showed that a node admitted for session A +could be sent on session B's transport. Dispatch now uses the parent's canonical +read-only registry-to-transport session check before correlation or action bytes. +The socket regression requires the exact typed mismatch, zero pending commands and +wire silence; valid fixtures name the same session at both boundaries. Complete +exact-head acceptance is still pending. + +The transport is not policy approval, browser authentication, a typed response consumer, +an observed text-value post-condition, protected-main delivery, or release evidence. +Those boundaries remain separate #28 work. The predecessor dossier below is historical; +its counts, source heads and hosted results are not current combined-head acceptance. + +## Historical parent pointer session and reply adoption — 2026-09-07 Ordinary merge `d9503b30` adopts #265 `e94a2372` without changing this child's text-command source, exports or eight existing text/privacy tests. Three real socket @@ -18,7 +76,7 @@ this change does not duplicate it. Full combined-head verification, hosted check and actual visual inspection are separate gates, and none of the historical heads below supplies protected-main, policy, browser-authentication or release acceptance. -## Parent-adoption checkpoint — 2026-09-06 +## Historical parent-adoption checkpoint — 2026-09-06 Ordinary integration `5a722867` preserves the text-input and diagnostic-privacy delta from `cc9980c0` while adopting pointer parent `7147893c96ca95c9b5b275d8011c5bfe99aab065`.