diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ae34f31..036fc72cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Reject field-observation requests on another browser session and preserve pending requests only when a write may have reached the peer. + - Preserve fixed field-observation checks while adopting current input safeguards; constructing a request still does not verify that the field changed. - Label earlier text-reply limitations as historical so they do not contradict the later click safeguards. - Preserve text-reply checks while adopting click-session and reply safeguards. A matched response still does not prove the requested field changed. @@ -55,6 +57,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Typed WebDriver BiDi command-family correlation for text-value post-condition observations, so a matching numeric response id cannot consume an outstanding command from another operation family; successful envelopes reuse the parser-proven non-null id invariant without an unreachable fallback branch. - Deterministic WebDriver BiDi primary-button click serialization for an already admitted remote node: it emits one fixed `input.performActions` mouse sequence from bounded command/context/node identifiers and remains inert until a trusted adapter binds it to current session, origin, document, policy, and approval authority. - Typed outbound WebDriver BiDi primary-button click transport over the bounded client WebSocket stream: it rejects invalid frame deadlines before correlation registration, retires only the just-registered id when local frame preflight proves no command bytes were emitted, preserves correlation across ambiguous writes, and does not treat frame-write success as proof that the browser performed the click. - Typed pointer-click response admission that consumes only the exact outstanding command-kind correlation after complete envelope validation, keeps remote protocol errors distinct from success, and does not treat a protocol acknowledgment as proof that the target activated or the document changed. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 43fc22576..9f7de9f4a 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -10,7 +10,7 @@ //! binds received fragmented text to one exact verified connection, classifies //! complete local-end JSON envelopes, tracks bounded command-response correlation, //! transports narrowly typed pointer-click and node-bound non-secret text-input -//! actions, admits typed correlated protocol responses, sends a context-bound committed-navigation subscription and retains +//! actions and fixed sandboxed text-value observations, admits typed correlated protocol responses, sends a context-bound committed-navigation subscription and retains //! its typed bounded correlated identifier, binds navigation-event admission to //! that active command/receipt lifecycle with bounded fail-closed navigation replay //! prevention, explicitly unsubscribes that exact @@ -49,6 +49,7 @@ mod webdriver_bidi_session_end_response; mod webdriver_bidi_session_status_command; mod webdriver_bidi_session_status_response; mod webdriver_bidi_session_teardown; +mod webdriver_bidi_text_value_observation_transport; mod webdriver_bidi_type_text_response; mod webdriver_bidi_type_text_transport; mod webdriver_bidi_websocket_frame; @@ -146,6 +147,9 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownAssessmentError, WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, }; +pub use webdriver_bidi_text_value_observation_transport::{ + WebDriverBiDiTextValueObservationSendError, send_webdriver_bidi_text_value_observation, +}; pub use webdriver_bidi_type_text_response::{ WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, }; diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index b8b8d8ce9..414b2668e 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -20,6 +20,8 @@ pub const MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS: usize = 256; /// the same id. Additional command families are introduced by their owning typed command slices. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebDriverBiDiCommandKind { + /// Fixed product-owned `script.callFunction` text-value observation. + TextValueObservation, /// WebDriver BiDi `session.status`. SessionStatus, /// WebDriver BiDi `session.end`. diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs new file mode 100644 index 000000000..d803b3819 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_text_value_observation_transport.rs @@ -0,0 +1,179 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserProtocolCapability, BrowserProtocolKind, + ValidatedBrowserProtocolUse, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiTextValueObservationCommand, +}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +/// Fail-closed errors while transporting one current-authority text-value observation command. +#[derive(Debug)] +pub enum WebDriverBiDiTextValueObservationSendError { + /// The supplied protocol-use proof belongs to another browser protocol family. + UnsupportedProtocolKind(BrowserProtocolKind), + /// The supplied protocol-use proof did not validate semantic-observation capability. + UnsupportedCapability(BrowserProtocolCapability), + /// Current node, browser-context, document, or bounded command authority failed revalidation. + Authority { + /// Exact typed immediate-use authority failure. + source: WebDriverBiDiTextValueObservationAuthorityError, + }, + /// The bounded correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Deadline validation or writing the command frame failed. + FrameWrite { + /// Exact typed bounded WebSocket frame-write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiTextValueObservationSendError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::UnsupportedProtocolKind(_) => { + "WebDriver BiDi text-value observation send requires a WebDriver BiDi proof" + } + Self::UnsupportedCapability(_) => { + "WebDriver BiDi text-value observation send requires semantic-observation capability" + } + Self::Authority { .. } => { + "WebDriver BiDi text-value observation authority was rejected" + } + Self::Correlation { .. } => { + "WebDriver BiDi text-value observation command correlation was rejected" + } + Self::FrameWrite { .. } => { + "WebDriver BiDi text-value observation command frame write failed" + } + }) + } +} + +impl Error for WebDriverBiDiTextValueObservationSendError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, + Self::Authority { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} + +/// Revalidate, register, and write one fixed text-value `script.callFunction` observation. +/// +/// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose protocol family +/// is exactly [`BrowserProtocolKind::WebDriverBiDi`] and whose capability is exactly +/// [`BrowserProtocolCapability::SemanticObservation`]. The proof is consumed before node +/// authority, command correlation, or frame I/O, so typed-input, navigation, CDP, or other +/// protocol proofs cannot dispatch this observation through this boundary. +/// +/// Immediately before correlation, this boundary reconstructs the fixed product-owned command +/// from the [`AdmittedNodeHandle`], exact external browsing-context identifier, remote node +/// reference, and live [`BrowserAuthorityRegistry`]. The core constructor revalidates current +/// session, context, origin, document epoch, registry provenance, and exact admitted wire node +/// identifier. Callers cannot supply function source, sandbox, or generic script arguments. +/// +/// The registered session must match the established connection's exact session identifier. +/// Deadline validation precedes registration. Registration binds the connection generation and +/// records [`WebDriverBiDiCommandKind::TextValueObservation`] before the first +/// possible remote side effect. A later typed response boundary must match both the exact id and +/// this command provenance; an unrelated outstanding command id therefore cannot certify a text +/// post-condition. A correlation failure writes nothing. A proven zero-write malformed-frame +/// rejection retires only this registration; an ambiguous write failure keeps it outstanding. +/// The transport enforces increasing command identifiers across its entire lifetime. +/// +/// Dispatch is only protocol-level observation transport. This function does not authenticate the +/// browser, authorize the preceding text-input action, compare the eventual remote value with the +/// intended non-secret text, infer post-condition success, retry, reconnect, select another +/// destination, or grant policy, destination, secret, process, profile, or Agent authority. +#[expect( + clippy::too_many_arguments, + reason = "this immediate-use security boundary keeps command identity, live node authority, transport, correlation, masking, and deadline inputs explicit rather than persisting a reusable prevalidated command" +)] +pub fn send_webdriver_bidi_text_value_observation( + validated: ValidatedBrowserProtocolUse, + command_id: u64, + browsing_context: &str, + handle: &AdmittedNodeHandle, + node: &WebDriverBiDiRemoteNodeReference, + registry: &BrowserAuthorityRegistry, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, +) -> Result { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiTextValueObservationSendError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiTextValueObservationSendError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_semantic_observation_proof = validated; + + let command = WebDriverBiDiTextValueObservationCommand::new_for_current_node( + command_id, + browsing_context, + handle, + node, + registry, + ) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::Authority { source })?; + + registry + .require_registered_session_external_identifier( + handle.browser_session(), + established + .transport_evidence() + .verified_peer() + .session_id(), + ) + .map_err( + |source| WebDriverBiDiTextValueObservationSendError::Authority { + source: WebDriverBiDiTextValueObservationAuthorityError::BrowserAuthority(source), + }, + )?; + crate::webdriver_bidi_websocket_frame::validate_frame_timeout(frame_timeout) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::FrameWrite { source })?; + correlation + .register_command_for_connection( + command.command_id(), + WebDriverBiDiCommandKind::TextValueObservation, + established.transport_evidence().connection_generation(), + ) + .map_err(|source| WebDriverBiDiTextValueObservationSendError::Correlation { source })?; + established + .write_command_frame( + command.command_id(), + command.as_json(), + masking_key, + frame_timeout, + ) + .map_err(|source| { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = correlation.retire_command_for( + command.command_id(), + WebDriverBiDiCommandKind::TextValueObservation, + ); + } + WebDriverBiDiTextValueObservationSendError::FrameWrite { source } + }) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs index 2321aa7fe..bc6e6820f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs @@ -76,6 +76,39 @@ fn parse_success_over_loopback() -> Result Result<(), Box> { + let response = parse_success_over_loopback()?; + for sibling in [ + WebDriverBiDiCommandKind::SessionStatus, + WebDriverBiDiCommandKind::SessionEnd, + WebDriverBiDiCommandKind::PointerClick, + WebDriverBiDiCommandKind::TypeText, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + ] { + for (actual, expected) in [ + (WebDriverBiDiCommandKind::TextValueObservation, sibling), + (sibling, WebDriverBiDiCommandKind::TextValueObservation), + ] { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(42, actual)?; + assert_eq!( + correlation.correlate_response_for(&response, expected), + Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { expected, actual }) + ); + assert_eq!(correlation.outstanding_count(), 1); + assert_eq!( + correlation + .correlate_response_for(&response, actual)? + .command_id(), + 42 + ); + } + } + Ok(()) +} + #[test] fn response_cannot_consume_a_different_outstanding_command_kind() -> Result<(), Box> { let mut correlation = WebDriverBiDiCommandCorrelation::new(); diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs new file mode 100644 index 000000000..620edb2f5 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send.rs @@ -0,0 +1,361 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTextValueObservationCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_text_value_observation, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type AdmittedTextFieldFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn admitted_text_field_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +#[test] +fn text_value_observation_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_text_field_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + repeated_key, + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("reused masking key unexpectedly sent a text input"))?; + assert_eq!(correlation.outstanding_count(), 0); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation command frame write failed" + ); + + server + .join() + .map_err(|_| io::Error::other("reused-mask-key observation test server panicked"))??; + Ok(()) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + 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_client_frame(stream: &mut TcpStream, expected_header: u8) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_header || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + let length = usize::from(u16::from_be_bytes(extended)); + if length <= 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used non-minimal 16-bit length encoding", + )); + } + length + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + if length <= u64::from(u16::MAX) || length > usize::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client text frame used invalid 64-bit length encoding", + )); + } + length as usize + } + _ => unreachable!(), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +#[test] +fn text_value_observation_writes_exact_masked_bidi_frame_and_stays_outstanding() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (registry, handle, remote) = admitted_text_field_fixture()?; + let expected_json = WebDriverBiDiTextValueObservationCommand::new_for_current_node( + 43, + "context-a", + &handle, + &remote, + ®istry, + )? + .as_json() + .as_bytes() + .to_vec(); + + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_client_frame(&mut stream, 0x81)?; + if command != expected_json { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected script.callFunction text-value observation command", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let _established = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("text-value observation transport test server panicked"))??; + Ok(()) +} +#[test] +fn text_value_observation_ambiguous_socket_write_keeps_correlation() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (closed_sender, closed_receiver) = mpsc::channel(); + let (seed_ready_sender, seed_ready_receiver) = mpsc::channel(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let mut first_frame_byte = [0_u8; 1]; + stream.read_exact(&mut first_frame_byte)?; + seed_ready_receiver + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| io::Error::other("seed Pong did not finish before peer closure"))?; + drop(stream); + closed_sender.send(()).map_err(|_| { + io::Error::other("text-value observation close signal receiver disappeared") + }) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))? + .write_pong_frame( + b"seed-frame", + WebDriverBiDiWebSocketMaskKey::new([13, 14, 15, 16]), + Duration::from_millis(500), + )?; + seed_ready_sender.send(())?; + closed_receiver.recv_timeout(Duration::from_secs(1))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let mut established = established; + let mut observed_ambiguous_failure = false; + let (registry, handle, remote) = admitted_text_field_fixture()?; + for attempt in 0_u8..64 { + let command_id = 44 + u64::from(attempt); + match send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + command_id, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([17, 18, 19, attempt]), + Duration::from_millis(500), + ) { + Ok(next) => { + correlation.retire_command_for( + command_id, + WebDriverBiDiCommandKind::TextValueObservation, + )?; + established = next; + } + Err(error) => { + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + observed_ambiguous_failure = true; + break; + } + } + } + assert!(observed_ambiguous_failure); + + server + .join() + .map_err(|_| io::Error::other("ambiguous-write observation test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs new file mode 100644 index 000000000..f95259eb4 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_send_failures.rs @@ -0,0 +1,350 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiTextValueObservationAuthorityError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationSendError, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_text_value_observation, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type HandshakeOnlyServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); +type ObservationFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn protocol_proof( + kind: BrowserProtocolKind, + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + kind, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + kind, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn semantic_observation_proof() -> Result> { + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::SemanticObservation, + ) +} + +fn observation_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn establish_with_handshake_only_server() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn observation_rejects_non_semantic_proof_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::TypedInput, + )?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("typed-input proof unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation send requires semantic-observation capability" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("semantic capability rejection server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_non_webdriver_bidi_proof_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + protocol_proof( + BrowserProtocolKind::ChromeDevToolsProtocol, + BrowserProtocolCapability::SemanticObservation, + )?, + 44, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("CDP proof unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation send requires a WebDriver BiDi proof" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("WebDriver BiDi proof rejection server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_stale_external_context_before_correlation_or_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 45, + "context-b", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("wrong context unexpectedly reached WebSocket I/O"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::Authority { + source: WebDriverBiDiTextValueObservationAuthorityError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch + ) + } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation authority was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("authority rejection server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_duplicate_correlation_before_frame_write() -> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for( + 46, + originweave_network::WebDriverBiDiCommandKind::TextValueObservation, + )?; + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 46, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("duplicate correlation unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::Correlation { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation command correlation was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("duplicate-correlation observation server panicked"))??; + Ok(()) +} + +#[test] +fn observation_rejects_invalid_frame_timeout_before_registration() -> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = observation_fixture()?; + + let error = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 47, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::ZERO, + ) + .err() + .ok_or_else(|| io::Error::other("zero frame timeout unexpectedly sent observation"))?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::FrameWrite { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value observation command frame write failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("invalid-timeout observation server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs new file mode 100644 index 000000000..defd77ba8 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_transport_session_provenance.rs @@ -0,0 +1,191 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTextValueObservationAuthorityError, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationSendError, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + send_webdriver_bidi_text_value_observation, +}; + +const REGISTRY_SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const FOREIGN_TRANSPORT_SESSION_ID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn protocol_proof( + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn current_node_fixture() -> Result< + ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, + ), + Box, +> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(REGISTRY_SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example") + .map_err(|error| io::Error::other(format!("fixture origin rejected: {error:?}")))?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + protocol_proof(BrowserProtocolCapability::SemanticObservation)?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +#[test] +fn current_node_text_value_observation_is_rejected_before_writing_to_a_foreign_session_transport() +-> Result<(), Box> { + let (registry, handle, remote) = current_node_fixture()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + let mut first_command_byte = [0_u8; 1]; + match stream.read(&mut first_command_byte) { + Ok(0) => Ok(false), + Ok(_) => Ok(true), + Err(source) + if matches!( + source.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + Ok(false) + } + Err(source) => Err(source), + } + }); + + let endpoint = format!("ws://{local_addr}/session/{FOREIGN_TRANSPORT_SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(FOREIGN_TRANSPORT_SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + + let send_result = send_webdriver_bidi_text_value_observation( + protocol_proof(BrowserProtocolCapability::SemanticObservation)?, + 42, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + let command_byte_seen = server.join().map_err(|_| { + io::Error::other("foreign-session text-value observation server panicked") + })??; + + let error = send_result.err().ok_or_else(|| { + io::Error::other( + "registry session A unexpectedly dispatched text-value observation input on transport session B", + ) + })?; + assert!(matches!( + error, + WebDriverBiDiTextValueObservationSendError::Authority { + source: WebDriverBiDiTextValueObservationAuthorityError::BrowserAuthority(_) + } + )); + assert_eq!( + correlation.outstanding_count(), + 0, + "foreign-session rejection must happen before correlation registration" + ); + assert!( + !command_byte_seen, + "foreign-session rejection must happen before any text-value observation command-frame byte" + ); + Ok(()) +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index c65fd0fcc..72cdda1c7 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -14,6 +14,10 @@ For the bounded `browsingContext.locateNodes` command-serialization boundary, th WebDriver BiDi commands may execute concurrently and finish out of order. The 3 September 2026 Working Draft defines the command id as the local end’s correlation identifier; its local-end `CommandResponse` production requires `id: js-uint`, while `ErrorResponse.id` is `js-uint / null`. OriginWeave therefore represents a validated success response with a structurally present command id, retains nullable ids only for protocol errors, and fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. +OriginWeave also retains the registered command family until correlation completes. A response id registered for typed input, navigation, or semantic observation cannot be consumed by a different family merely because the numeric id matches. + +The fixed field-observation sender now requires the admitted node's registered session to match the established transport session before writing. It validates the deadline before registration, binds pending requests to the exact connection generation, and uses the existing lifetime command-ID guard. Proven zero-write rejection retires that request; an ambiguous socket write keeps it pending. These transport checks do not prove the field value or a completed user action. + The same Working Draft defines `ErrorResponse.error` as `ErrorCode`. Its rendered local-end CDDL enumerates 30 values and omits `no such client window`, while §3.5 separately defines `no such client window` and normative client-window algorithms return that error code. OriginWeave therefore admits the finite rendered CDDL vocabulary plus this one separately defined normative error, and still rejects arbitrary error-code text fail closed. This is an explicit interoperability exception for a specification-internal inconsistency, not authority to infer or accept other strings; adding any further code requires fresh primary-source review and regression evidence. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft).