From 9128711b714e55fc17ace673a183f1738a03ad3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:03:22 +0900 Subject: [PATCH 01/16] test(network): require node-bound BiDi text transport --- .../tests/webdriver_bidi_type_text_send.rs | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_type_text_send.rs 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..014942e07 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs @@ -0,0 +1,241 @@ +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, + 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("webdriver-session")?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + let length = usize::from(u16::from_be_bytes(extended)); + if length <= 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used non-minimal 16-bit length encoding", + )); + } + length + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + if length <= u64::from(u16::MAX) || length > usize::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used invalid 64-bit length encoding", + )); + } + length as usize + } + _ => unreachable!(), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +#[test] +fn 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); + + server + .join() + .map_err(|_| io::Error::other("text-input transport test server panicked"))??; + Ok(()) +} From aa1a024eda9636274fa7c6e393d900a07985813e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:07:42 +0900 Subject: [PATCH 02/16] feat(network): transport node-bound BiDi text input --- crates/originweave-network/src/lib.rs | 35 +- .../src/webdriver_bidi_type_text_transport.rs | 142 +++++++ ...webdriver_bidi_type_text_send_authority.rs | 213 +++++++++++ .../webdriver_bidi_type_text_send_failures.rs | 356 ++++++++++++++++++ 4 files changed, 731 insertions(+), 15 deletions(-) create mode 100644 crates/originweave-network/src/webdriver_bidi_type_text_transport.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_type_text_send_authority.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 9517f6592..e53646aaa 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -8,21 +8,22 @@ //! the RFC 6455 opening exchange, provides bounded masked client writes and //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! classifies complete local-end JSON envelopes, tracks bounded command-response -//! correlation, transports a narrowly typed pointer click, admits its typed -//! correlated protocol acknowledgment, sends a context-bound subscription for -//! committed-navigation events, retains its typed bounded correlated subscription -//! identifier, binds navigation-event admission to that exact active command/receipt -//! lifecycle with bounded fail-closed navigation replay prevention, explicitly tears -//! down that exact subscription by identifier, admits its typed correlated unsubscribe -//! acknowledgment, admits a bounded navigation-committed post-condition observation -//! for one exact registered context and URL, rotates the matched context's document -//! epoch only from an exact caller-captured pre-action epoch, derives and binds the -//! committed HTTP(S) URL's canonical origin to that newly advanced document, sends -//! narrowly typed `session.status` and `session.end` commands, admits typed correlated -//! status and end responses, observes bounded peer Close or clean-EOF transport -//! cessation, and keeps protocol/transport evidence separate from explicit operational -//! teardown observations without exposing generic JSON bodies or granting browser, -//! TLS, policy, secret, process, profile, or Agent authority. +//! correlation, transports narrowly typed pointer-click and node-bound non-secret +//! text-input actions, admits typed correlated protocol acknowledgments, sends a +//! context-bound subscription for committed-navigation events, retains its typed +//! bounded correlated subscription identifier, binds navigation-event admission to +//! that exact active command/receipt lifecycle with bounded fail-closed navigation +//! replay prevention, explicitly tears down that exact subscription by identifier, +//! admits its typed correlated unsubscribe acknowledgment, admits a bounded +//! navigation-committed post-condition observation for one exact registered context +//! and URL, rotates the matched context's document epoch only from an exact +//! caller-captured pre-action epoch, derives and binds the committed HTTP(S) URL's +//! canonical origin to that newly advanced document, sends narrowly typed +//! `session.status` and `session.end` commands, admits typed correlated status and +//! end responses, observes bounded peer Close or clean-EOF transport cessation, and +//! keeps protocol/transport evidence separate from explicit operational teardown +//! observations without exposing generic JSON bodies or granting browser, TLS, +//! policy, secret, process, profile, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -46,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_transport; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; @@ -136,6 +138,9 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, 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_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs new file mode 100644 index 000000000..3e46ef7aa --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -0,0 +1,142 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserProtocolCapability, BrowserProtocolKind, + ValidatedBrowserProtocolUse, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTypeTextAuthorityError, WebDriverBiDiTypeTextCommand, +}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + 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. +/// +/// Registration occurs before the first possible remote side effect. A correlation failure writes +/// nothing. Once registration succeeds, a frame-write failure leaves the identifier outstanding +/// because 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 })?; + + correlation + .register_command(command.command_id()) + .map_err(|source| WebDriverBiDiTypeTextSendError::Correlation { source })?; + established + .write_text_frame(command.as_json(), masking_key, frame_timeout) + .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source }) +} 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..0809547b1 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -0,0 +1,356 @@ +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() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session("webdriver-session")?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn establish_with_handshake_only_server() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn 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()?; + + 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()?; + + 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()?; + + 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(8)?; + let (registry, handle, remote) = type_text_fixture()?; + + 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_preserves_registration_when_frame_timeout_is_invalid() -> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = type_text_fixture()?; + + 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(), 1); + + server + .join() + .map_err(|_| io::Error::other("invalid-timeout text server panicked"))??; + Ok(()) +} From 46a05d7f8a1219803cafb38fc33deb263a1c14cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:12:02 +0900 Subject: [PATCH 03/16] style(network): apply canonical rustfmt diagnostics --- .../tests/webdriver_bidi_type_text_send_failures.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 0809547b1..b25f68b56 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -236,7 +236,8 @@ fn type_text_rejects_non_webdriver_bidi_proof_before_correlation_or_frame_write( } #[test] -fn type_text_rejects_invalid_text_before_correlation_or_frame_write() -> Result<(), Box> { +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()?; From 2b9600024200d5961e4114e335ba51aad854759e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:01:06 +0900 Subject: [PATCH 04/16] test(network): reject text deadlines before correlation Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../tests/webdriver_bidi_type_text_send_failures.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b25f68b56..60b820c6b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -319,7 +319,7 @@ fn type_text_rejects_duplicate_correlation_before_frame_write() -> Result<(), Bo } #[test] -fn type_text_preserves_registration_when_frame_timeout_is_invalid() -> Result<(), Box> { +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()?; @@ -348,7 +348,7 @@ fn type_text_preserves_registration_when_frame_timeout_is_invalid() -> Result<() "WebDriver BiDi text-input command frame write failed" ); assert!(error.source().is_some()); - assert_eq!(correlation.outstanding_count(), 1); + assert_eq!(correlation.outstanding_count(), 0); server .join() From 2d8f7c095f537d85b457104e1a3743cb24fa3176 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:02:42 +0900 Subject: [PATCH 05/16] fix(network): validate text frame deadline before correlation Reuse the canonical frame timeout validator; preserve ambiguous write retention. Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_type_text_transport.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs index 3e46ef7aa..280e1e1ca 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -82,7 +82,8 @@ impl Error for WebDriverBiDiTypeTextSendError { /// command therefore cannot outlive its node authority and later bypass revalidation at transport /// time. /// -/// Registration occurs before the first possible remote side effect. A correlation failure writes +/// Invalid local deadlines fail before registration. Registration occurs before the first possible +/// remote side effect. A correlation failure writes /// nothing. Once registration succeeds, a frame-write failure leaves the identifier outstanding /// because a partial or complete remote side effect is ambiguous and the identifier must not be /// silently reused. @@ -133,6 +134,8 @@ pub fn send_webdriver_bidi_type_text( ) .map_err(|source| WebDriverBiDiTypeTextSendError::Authority { source })?; + crate::webdriver_bidi_websocket_frame::validate_frame_timeout(frame_timeout) + .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source })?; correlation .register_command(command.command_id()) .map_err(|source| WebDriverBiDiTypeTextSendError::Correlation { source })?; From 447e8aa2a60d49482878fb420f21ecba98f5636d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:03:27 +0900 Subject: [PATCH 06/16] fix(network): adopt typed text correlation and sealed dispatch Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_command_correlation.rs | 2 ++ .../src/webdriver_bidi_type_text_transport.rs | 11 ++++++++--- .../tests/webdriver_bidi_type_text_send_failures.rs | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) 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 index 280e1e1ca..635e25f8c 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -8,7 +8,7 @@ use originweave_core::{ use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiCommandKind, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; @@ -137,9 +137,14 @@ pub fn send_webdriver_bidi_type_text( crate::webdriver_bidi_websocket_frame::validate_frame_timeout(frame_timeout) .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source })?; correlation - .register_command(command.command_id()) + .register_command_for(command.command_id(), WebDriverBiDiCommandKind::TypeText) .map_err(|source| WebDriverBiDiTypeTextSendError::Correlation { source })?; established - .write_text_frame(command.as_json(), masking_key, frame_timeout) + .write_command_frame( + command.command_id(), + command.as_json(), + masking_key, + frame_timeout, + ) .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source }) } 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 index 60b820c6b..9f9535d61 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -283,7 +283,7 @@ fn type_text_rejects_invalid_text_before_correlation_or_frame_write() -> Result< 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(8)?; + correlation.register_command_for(8, originweave_network::WebDriverBiDiCommandKind::TypeText)?; let (registry, handle, remote) = type_text_fixture()?; let error = send_webdriver_bidi_type_text( From e2b49e681e2f6aa3d5ddf12f56c0ed31371e2b5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:04:14 +0900 Subject: [PATCH 07/16] test(network): cover text preflight retirement and ambiguous writes Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../tests/webdriver_bidi_type_text_send.rs | 147 +++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs index 014942e07..7cfced048 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs @@ -2,6 +2,7 @@ use std::{ error::Error, io::{self, Read, Write}, net::{TcpListener, TcpStream}, + sync::mpsc, thread, time::Duration, }; @@ -15,7 +16,7 @@ use originweave_core::{ WebDriverBiDiRemoteNodeReference, WebDriverBiDiTypeTextCommand, WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_type_text, }; @@ -126,9 +127,13 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { } 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] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != opcode || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, "expected one final masked client text frame", @@ -239,3 +244,141 @@ fn type_text_command_writes_exact_masked_bidi_frame_and_stays_outstanding() .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(()) +} From 3056b89207dd57bc3b636e9787d5a4d5eb3cb80a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:04:39 +0900 Subject: [PATCH 08/16] fix(network): retire text correlation only after no-write preflight rejection Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_type_text_transport.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs index 635e25f8c..76429a4c7 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -84,9 +84,9 @@ impl Error for WebDriverBiDiTypeTextSendError { /// /// Invalid local deadlines fail before registration. Registration occurs before the first possible /// remote side effect. A correlation failure writes -/// nothing. Once registration succeeds, a frame-write failure leaves the identifier outstanding -/// because a partial or complete remote side effect is ambiguous and the identifier must not be -/// silently reused. +/// 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 @@ -146,5 +146,14 @@ pub fn send_webdriver_bidi_type_text( masking_key, frame_timeout, ) - .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source }) + .map_err(|source| { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = correlation + .retire_command_for(command.command_id(), WebDriverBiDiCommandKind::TypeText); + } + WebDriverBiDiTypeTextSendError::FrameWrite { source } + }) } From 4cffe5aceba91bb5d26eaf907a581ac90b2ccf60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:05:47 +0900 Subject: [PATCH 09/16] docs: record text transport adoption and bounded failure evidence Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + .../action-postcondition-evidence.md | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92f72b1b..38a3a3457 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 rejects 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 0e046eb94..bd3cba670 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,6 +1,23 @@ # Action Post-Condition Evidence Traceability -## Parent-adoption checkpoint — 2026-09-06 +## 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. Nine focused transport tests pass +at source `3056b892`; 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-adoption checkpoint — 2026-09-06 Ordinary integration `5a722867` preserves the text-input and diagnostic-privacy delta from `cc9980c0` while adopting pointer parent `7147893c96ca95c9b5b275d8011c5bfe99aab065`. From 04143c63efb5c6f6c99ed3dd2259daaedfa724c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:06:51 +0900 Subject: [PATCH 10/16] test(network): assert text family and rejection wire silence Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../tests/webdriver_bidi_type_text_send.rs | 11 +++++++++++ .../webdriver_bidi_type_text_send_failures.rs | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs index 7cfced048..9e3b3ecc2 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs @@ -239,6 +239,17 @@ fn type_text_command_writes_exact_masked_bidi_frame_and_stays_outstanding() )?; 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"))??; 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 index 9f9535d61..bc6ecc1e1 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -134,7 +134,21 @@ fn establish_with_handshake_only_server() -> Result io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; - stream.write_all(OPENING_RESPONSE) + 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}"); From 10131eb70fd88a19d65558ee00365c4cfd19031f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:07:32 +0900 Subject: [PATCH 11/16] test(network): reject text dispatch on a foreign transport session Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../tests/webdriver_bidi_type_text_send.rs | 2 +- .../webdriver_bidi_type_text_send_failures.rs | 48 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs index 9e3b3ecc2..4ddf8cf84 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send.rs @@ -76,7 +76,7 @@ fn typed_input_proof() -> Result> { fn admitted_type_text_fixture() -> Result> { let mut registry = BrowserAuthorityRegistry::new(); - let browser_session = registry.register_session("webdriver-session")?; + let browser_session = registry.register_session(SESSION_ID)?; let browsing_context = registry.register_context(browser_session, "context-a")?; let origin = Origin::parse("https://app.example").map_err(|error| { io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) 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 index bc6ecc1e1..abd8c905b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_send_failures.rs @@ -77,9 +77,9 @@ fn typed_input_proof() -> Result> { ) } -fn type_text_fixture() -> Result> { +fn type_text_fixture(session_id: &str) -> Result> { let mut registry = BrowserAuthorityRegistry::new(); - let browser_session = registry.register_session("webdriver-session")?; + let browser_session = registry.register_session(session_id)?; let browsing_context = registry.register_context(browser_session, "context-a")?; let origin = Origin::parse("https://app.example").map_err(|error| { io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) @@ -169,7 +169,7 @@ 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()?; + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; let error = send_webdriver_bidi_type_text( semantic_observation_proof()?, @@ -210,7 +210,7 @@ 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()?; + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; let error = send_webdriver_bidi_type_text( protocol_proof( @@ -254,7 +254,7 @@ fn type_text_rejects_invalid_text_before_correlation_or_frame_write() -> Result< { let (established, server) = establish_with_handshake_only_server()?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - let (registry, handle, remote) = type_text_fixture()?; + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; let error = send_webdriver_bidi_type_text( typed_input_proof()?, @@ -298,7 +298,7 @@ fn type_text_rejects_duplicate_correlation_before_frame_write() -> Result<(), Bo 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()?; + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; let error = send_webdriver_bidi_type_text( typed_input_proof()?, @@ -336,7 +336,7 @@ fn type_text_rejects_duplicate_correlation_before_frame_write() -> Result<(), Bo 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()?; + let (registry, handle, remote) = type_text_fixture(SESSION_ID)?; let error = send_webdriver_bidi_type_text( typed_input_proof()?, @@ -369,3 +369,37 @@ fn type_text_rejects_invalid_frame_timeout_without_registration() -> Result<(), .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(()) +} From c3dc8e769e06755c6b9a46f9cce8451f8e74013b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:08:03 +0900 Subject: [PATCH 12/16] fix(network): bind admitted text node to verified transport session Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_type_text_transport.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs index 76429a4c7..8bbf05888 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -134,6 +134,17 @@ pub fn send_webdriver_bidi_type_text( ) .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 From 4435ce5f561ca069c1844a1a5bd9b603505e25f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:08:33 +0900 Subject: [PATCH 13/16] docs: record text session binding regression and authority boundary Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 2 +- .../src/webdriver_bidi_type_text_transport.rs | 3 ++- docs/traceability/action-postcondition-evidence.md | 9 +++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a3a3457..3dbfab24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -- Text entry rejects 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. +- 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/webdriver_bidi_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs index 8bbf05888..5558cb38a 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -80,7 +80,8 @@ impl Error for WebDriverBiDiTypeTextSendError { /// 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. +/// 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. A correlation failure writes diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index bd3cba670..9db30dece 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -9,8 +9,13 @@ with a distinct `TypeText` correlation family. The shared frame-deadline validat 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. Nine focused transport tests pass -at source `3056b892`; complete exact-head acceptance is still pending. +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. From 3346d8ecc72932b98ec495d9cc52d6e5727c3064 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:42:25 +0900 Subject: [PATCH 14/16] fix(network): retain text sender connection provenance Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/webdriver_bidi_type_text_transport.rs | 9 +++++++-- .../action-postcondition-evidence.md | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbfab24d..799f3310c 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] +- 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/webdriver_bidi_type_text_transport.rs b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs index 5558cb38a..0a2e0cb33 100644 --- a/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_type_text_transport.rs @@ -84,7 +84,8 @@ impl Error for WebDriverBiDiTypeTextSendError { /// 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. A correlation failure writes +/// 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. @@ -149,7 +150,11 @@ pub fn send_webdriver_bidi_type_text( crate::webdriver_bidi_websocket_frame::validate_frame_timeout(frame_timeout) .map_err(|source| WebDriverBiDiTypeTextSendError::FrameWrite { source })?; correlation - .register_command_for(command.command_id(), WebDriverBiDiCommandKind::TypeText) + .register_command_for_connection( + command.command_id(), + WebDriverBiDiCommandKind::TypeText, + established.transport_evidence().connection_generation(), + ) .map_err(|source| WebDriverBiDiTypeTextSendError::Correlation { source })?; established .write_command_frame( diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 9db30dece..957c461c0 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,5 +1,22 @@ # Action Post-Condition Evidence Traceability +## 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 From 4e020e1628f801d1b77fe5b118cdcdf14dead733 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:17:55 +0900 Subject: [PATCH 15/16] test(network): replay pointer authority regressions on text sender --- ...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 ebd507ae56c3064e3cae5566502f539c20618a8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:19:40 +0900 Subject: [PATCH 16/16] docs: record text sender pointer safeguard adoption --- CHANGELOG.md | 1 + docs/doctoring.md | 11 +++++++++++ .../action-postcondition-evidence.md | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a840574e8..a6ca50378 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-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. 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 21d0ae657..9b4e08f5d 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,5 +1,24 @@ # Action Post-Condition Evidence Traceability +## 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