diff --git a/CHANGELOG.md b/CHANGELOG.md index 425580bc0..2c5fb4322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Keep current-node and browser-session click checks when rejecting replies from replacement connections; the original request remains recoverable without consuming unrelated work. +- Recheck that a click still targets the admitted node in the current document before sending it. Invalid deadlines send nothing and reserve no pending request; uncertain writes remain pending instead of being treated as safe to retry. - Reject replacement-connection click replies while retaining increasing request numbers, original subscription ownership and same-connection shutdown checks. - Reject a navigation subscription aimed at a different browser session before sending it, without creating or replacing browser state. @@ -79,6 +81,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. - Same-call QueryNodes admission that transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before an untrusted `locateNodes` result can become current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only proofs cannot mint observation handles. +- Registry-issued admitted node handles and authority-bound WebDriver BiDi pointer-click construction that revalidate the exact session, context, canonical origin, document epoch, registry provenance, and retained `sharedId` before serializing `input.performActions`; caller-constructed node tuples or arbitrary wire identifiers cannot become typed-input authority, and the command itself grants no policy or Agent authority. - Fail-closed rejection of reviewed Unicode format and bidirectional-override characters in accessibility roles, accessible names, BiDi `sharedId` values, and registry external identifiers, while ordinary spaces in accessible names remain valid. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index 309a011c8..ee17dc586 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; +use std::ops::Deref; use std::sync::Arc; use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; @@ -6,17 +8,38 @@ use crate::{ Origin, }; +/// A registry-issued node handle that carries opaque provenance in addition to descriptive node state. +/// +/// The contained [`ObservedNodeHandle`] remains readable through [`Deref`], but only +/// [`BrowserAuthorityRegistry`] can construct this wrapper. Typed actions therefore can require +/// proof that a node came from the same live registry instance instead of trusting a publicly +/// reproducible session/context/origin/epoch/node tuple. +#[derive(Debug)] +pub struct AdmittedNodeHandle { + observed: ObservedNodeHandle, + registry_instance: Arc<()>, +} + +impl Deref for AdmittedNodeHandle { + type Target = ObservedNodeHandle; + + fn deref(&self) -> &Self::Target { + &self.observed + } +} + /// Public browser-authority registry with raw node minting kept inside the crate. /// /// Browser-session, browsing-context, document-epoch, and canonical-origin lifecycle operations are /// public because trusted adapters need them to maintain current authority. Converting untrusted -/// browser-protocol node identifiers into [`ObservedNodeHandle`] values is deliberately +/// browser-protocol node identifiers into descriptive [`ObservedNodeHandle`] values is deliberately /// crate-private: external callers must use a reviewed semantic-observation admission boundary such -/// as [`crate::WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which consumes the required -/// protocol-use proof, validates the complete batch, and revalidates the exact current document -/// before atomically minting handles. +/// as [`crate::WebDriverBiDiAccessibilityQuery::bind_current_nodes`]. Action-capable paths use the +/// stricter registry-issued [`AdmittedNodeHandle`] wrapper so a copied descriptive tuple cannot +/// become typed-input authority. pub struct BrowserAuthorityRegistry { inner: RawBrowserAuthorityRegistry, + admitted_node_external_identifiers: BTreeMap<(u64, u64, u64, u64), String>, registry_identity: Arc<()>, } @@ -34,6 +57,7 @@ impl BrowserAuthorityRegistry { pub fn new() -> Self { Self { inner: RawBrowserAuthorityRegistry::new(), + admitted_node_external_identifiers: BTreeMap::new(), registry_identity: Arc::new(()), } } @@ -46,6 +70,7 @@ impl BrowserAuthorityRegistry { pub fn with_identifier_limit(maximum_identifier: u64) -> Self { Self { inner: RawBrowserAuthorityRegistry::with_identifier_limit(maximum_identifier), + admitted_node_external_identifiers: BTreeMap::new(), registry_identity: Arc::new(()), } } @@ -110,7 +135,12 @@ impl BrowserAuthorityRegistry { &mut self, browsing_context: BrowsingContextId, ) -> Result<(), BrowserRegistryError> { - self.inner.remove_context(browsing_context) + self.inner.remove_context(browsing_context)?; + let browsing_context_value = browsing_context.value(); + self.admitted_node_external_identifiers.retain( + |(_session, context, _epoch, _node), _external| *context != browsing_context_value, + ); + Ok(()) } /// Retire one browser session and every registered context and node binding beneath it. @@ -118,7 +148,12 @@ impl BrowserAuthorityRegistry { &mut self, browser_session: BrowserSessionId, ) -> Result<(), BrowserRegistryError> { - self.inner.remove_session(browser_session) + self.inner.remove_session(browser_session)?; + let browser_session_value = browser_session.value(); + self.admitted_node_external_identifiers.retain( + |(session, _context, _epoch, _node), _external| *session != browser_session_value, + ); + Ok(()) } /// Return the currently active document epoch for a known browsing context. @@ -180,16 +215,21 @@ impl BrowserAuthorityRegistry { &mut self, browsing_context: BrowsingContextId, ) -> Result { - self.inner.advance_document(browsing_context) + let next_epoch = self.inner.advance_document(browsing_context)?; + let browsing_context_value = browsing_context.value(); + self.admitted_node_external_identifiers.retain( + |(_session, context, _epoch, _node), _external| *context != browsing_context_value, + ); + Ok(next_epoch) } - /// Bind one admitted batch of adapter-local node identifiers to current browser authority. + /// Bind one admitted batch of adapter-local identifiers as descriptive current-node evidence. /// /// This operation is intentionally crate-private. The raw registry commits the batch only when /// every identifier can be bound; a later failure rolls back node identifiers and any origin - /// binding created by the batch before the error is returned. Production callers outside this - /// crate therefore cannot bypass semantic admission or observe partial authority from a failed - /// `locateNodes` result. + /// binding created by the batch before the error is returned. The exact external identifier is + /// retained for later action admission, but the returned [`ObservedNodeHandle`] values remain + /// descriptive and publicly reproducible rather than typed-input authority. pub(crate) fn bind_nodes( &mut self, browser_session: BrowserSessionId, @@ -197,12 +237,58 @@ impl BrowserAuthorityRegistry { origin: &Origin, external_identifiers: &[&str], ) -> Result, BrowserRegistryError> { - self.inner.bind_nodes( + let handles = self.inner.bind_nodes( + browser_session, + browsing_context, + origin, + external_identifiers, + )?; + for (handle, external_identifier) in handles.iter().zip(external_identifiers) { + self.admitted_node_external_identifiers.insert( + node_authority_key(handle), + (*external_identifier).to_owned(), + ); + } + Ok(handles) + } + + /// Bind one admitted batch and attach opaque registry-instance provenance for typed actions. + pub(crate) fn bind_admitted_nodes( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + self.bind_nodes( browser_session, browsing_context, origin, external_identifiers, ) + .map(|handles| { + handles + .into_iter() + .map(|observed| AdmittedNodeHandle { + observed, + registry_instance: Arc::clone(&self.registry_identity), + }) + .collect() + }) + } + + /// Return whether this registry issued the handle under the exact supplied wire identifier. + pub(crate) fn node_external_identifier_matches( + &self, + handle: &AdmittedNodeHandle, + external_identifier: &str, + ) -> bool { + if !Arc::ptr_eq(&self.registry_identity, &handle.registry_instance) { + return false; + } + self.admitted_node_external_identifiers + .get(&node_authority_key(&handle.observed)) + .is_some_and(|admitted| admitted == external_identifier) } } @@ -211,3 +297,12 @@ impl Default for BrowserAuthorityRegistry { Self::new() } } + +fn node_authority_key(handle: &ObservedNodeHandle) -> (u64, u64, u64, u64) { + ( + handle.browser_session().value(), + handle.browsing_context().value(), + handle.document_epoch().value(), + handle.node_id(), + ) +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 211b75348..d9333a776 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -19,6 +19,19 @@ //! let _handle = registry.bind_node(session, context, &origin, "backend-node-17")?; //! # Ok::<(), Box>(()) //! ``` +//! +//! Raw pointer-command serialization is likewise not a public escape hatch. External callers must +//! bind the exact registry-issued admitted node and current browser authority through the reviewed +//! current-node constructor instead of selecting an arbitrary WebDriver BiDi `sharedId` or +//! recreating authority from a descriptive [`ObservedNodeHandle`] tuple: +//! +//! ```compile_fail +//! use originweave_core::{WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference}; +//! +//! if let Ok(node) = WebDriverBiDiRemoteNodeReference::new("node", Some("caller-selected-node")) { +//! let _command = WebDriverBiDiPointerClickCommand::new(1, "context-a", &node); +//! } +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -34,6 +47,7 @@ mod browser_registry_external_context; mod contracts; mod webdriver_bidi_command; mod webdriver_bidi_error_code; +mod webdriver_bidi_pointer_click_authority; mod webdriver_bidi_response_document; mod webdriver_bidi_response_document_correlation; mod webdriver_bidi_response_envelope; @@ -41,7 +55,9 @@ mod webdriver_bidi_result; mod webdriver_bidi_websocket_connect_target; mod webdriver_bidi_websocket_endpoint; -pub use browser_authority_registry::{BrowserAuthorityRegistry, BrowserRegistryIdentity}; +pub use browser_authority_registry::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserRegistryIdentity, +}; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, @@ -79,6 +95,7 @@ pub use webdriver_bidi_command::{ WebDriverBiDiPointerClickCommandError, }; pub use webdriver_bidi_error_code::WebDriverBiDiErrorCode; +pub use webdriver_bidi_pointer_click_authority::WebDriverBiDiPointerClickAuthorityError; pub use webdriver_bidi_response_document::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiResponseDocumentAdmissionError, diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index 074472348..f6f9ff43d 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -38,9 +38,9 @@ impl Error for WebDriverBiDiPointerClickCommandError {} /// Deterministic command for one primary-button click on an admitted remote node. /// /// The fixed mouse action sequence moves to the element origin, presses button zero, and releases -/// button zero. Construction accepts an already admitted remote node reference and does not grant -/// browser-session, context, origin, document-epoch, policy, approval, or Agent authority. A trusted -/// adapter must bind this inert command to current authority before transport. +/// button zero. Public construction is authority-bound through +/// [`Self::new_for_current_node`]; raw wire serialization remains crate-private so external callers +/// cannot choose an arbitrary WebDriver BiDi node identifier while bypassing current node authority. #[derive(Debug, PartialEq, Eq)] pub struct WebDriverBiDiPointerClickCommand { command_id: u64, @@ -49,8 +49,9 @@ pub struct WebDriverBiDiPointerClickCommand { } impl WebDriverBiDiPointerClickCommand { - /// Validate and serialize one bounded `input.performActions` pointer click command. - pub fn new( + /// Serialize one bounded `input.performActions` pointer click command for an already + /// authority-validated browsing context. + pub(crate) fn new( command_id: u64, browsing_context: &str, node: &crate::WebDriverBiDiRemoteNodeReference, @@ -58,12 +59,6 @@ impl WebDriverBiDiPointerClickCommand { if command_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { return Err(WebDriverBiDiPointerClickCommandError::InvalidCommandId); } - if browsing_context.is_empty() - || browsing_context.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - || contains_disallowed_protocol_text(browsing_context, false) - { - return Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext); - } let mut json = String::from("{\"id\":"); json.push_str(&command_id.to_string()); diff --git a/crates/originweave-core/src/webdriver_bidi_pointer_click_authority.rs b/crates/originweave-core/src/webdriver_bidi_pointer_click_authority.rs new file mode 100644 index 000000000..1913bc6e0 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_pointer_click_authority.rs @@ -0,0 +1,107 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserRegistryError, NodeHandleError, + WebDriverBiDiPointerClickCommand, WebDriverBiDiPointerClickCommandError, + WebDriverBiDiRemoteNodeReference, +}; + +/// Fail-closed authority errors while binding one pointer click to an admitted current node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiPointerClickAuthorityError { + /// The final deterministic pointer command failed its bounded serialization contract. + Command(WebDriverBiDiPointerClickCommandError), + /// Current browser session, context, or origin authority could not be revalidated. + BrowserAuthority(BrowserRegistryError), + /// The observed node belongs to a stale or otherwise mismatched browser document lifetime. + NodeHandle(NodeHandleError), + /// The supplied wire node identifier is not the identifier admitted for this exact node handle. + NodeExternalIdentifierMismatch, +} + +impl Display for WebDriverBiDiPointerClickAuthorityError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Command(error) => { + write!(formatter, "pointer click command rejected input: {error}") + } + Self::BrowserAuthority(error) => { + write!( + formatter, + "pointer click browser authority rejected input: {error}" + ) + } + Self::NodeHandle(error) => { + write!( + formatter, + "pointer click node authority rejected input: {error}" + ) + } + Self::NodeExternalIdentifierMismatch => formatter.write_str( + "pointer click wire node identifier does not match the admitted current node", + ), + } + } +} + +impl Error for WebDriverBiDiPointerClickAuthorityError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Command(error) => Some(error), + Self::BrowserAuthority(error) => Some(error), + Self::NodeHandle(error) => Some(error), + Self::NodeExternalIdentifierMismatch => None, + } + } +} + +impl WebDriverBiDiPointerClickCommand { + /// Bind one pointer click to the exact current semantic node admitted by this authority registry. + /// + /// The external browsing-context identifier must still name the handle's registered context, + /// the handle must still belong to the registry's current document epoch and canonical origin, + /// and the remote `sharedId` must be the exact wire identifier retained during semantic-node + /// admission. The handle also carries opaque registry-instance provenance, so copying the same + /// public session/context/origin/epoch/node tuple cannot recreate action authority. These checks + /// are immediate-use validation only: they do not authenticate the browser process, grant policy + /// or Agent authority, authorize a destination, or perform I/O. + pub fn new_for_current_node( + command_id: u64, + browsing_context: &str, + handle: &AdmittedNodeHandle, + node: &WebDriverBiDiRemoteNodeReference, + registry: &BrowserAuthorityRegistry, + ) -> Result { + registry + .require_context_external_identifier( + handle.browser_session(), + handle.browsing_context(), + browsing_context, + ) + .map_err(WebDriverBiDiPointerClickAuthorityError::BrowserAuthority)?; + + let current_epoch = registry + .require_context_origin( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + ) + .map_err(WebDriverBiDiPointerClickAuthorityError::BrowserAuthority)?; + handle + .validate_current( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + current_epoch, + ) + .map_err(WebDriverBiDiPointerClickAuthorityError::NodeHandle)?; + + if !registry.node_external_identifier_matches(handle, node.shared_id()) { + return Err(WebDriverBiDiPointerClickAuthorityError::NodeExternalIdentifierMismatch); + } + + Self::new(command_id, browsing_context, node) + .map_err(WebDriverBiDiPointerClickAuthorityError::Command) + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs index 068bdced5..dee974e73 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -13,7 +13,7 @@ use crate::webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; use crate::{ - BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ValidatedBrowserProtocolUse, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesAdmissionError, }; @@ -210,16 +210,16 @@ impl WebDriverBiDiLocateNodesCommand { /// `SemanticObservation` proof and exact current session/context/origin/document epoch are /// revalidated by [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. /// - /// Success mints only [`ObservedNodeHandle`] values. It still does not authenticate Chromium, - /// ChromeDriver, WebSocket/TLS provenance, or the adapter process; authorize policy or typed - /// input; execute browser I/O; or prove an action post-condition. + /// Success mints only registry-issued [`AdmittedNodeHandle`] values. It still does not + /// authenticate Chromium, ChromeDriver, WebSocket/TLS provenance, or the adapter process; + /// authorize policy or typed input; execute browser I/O; or prove an action post-condition. pub fn bind_response_document_nodes( self, document: BoundedWebDriverBiDiResponseDocument, validated: ValidatedBrowserProtocolUse, authority_registry: &mut BrowserAuthorityRegistry, target: BrowserContextOriginEpochDispatchTarget<'_>, - ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { self.admit_response_document_nodes(document)? .bind_current_nodes(validated, authority_registry, target) .map_err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding) diff --git a/crates/originweave-core/src/webdriver_bidi_result.rs b/crates/originweave-core/src/webdriver_bidi_result.rs index ba3288269..df17f9020 100644 --- a/crates/originweave-core/src/webdriver_bidi_result.rs +++ b/crates/originweave-core/src/webdriver_bidi_result.rs @@ -2,8 +2,8 @@ use std::error::Error; use std::fmt::{Display, Formatter}; use crate::{ - BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserProtocolCapability, - BrowserProtocolKind, ObservedNodeHandle, ValidatedBrowserProtocolUse, + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolCapability, BrowserProtocolKind, ValidatedBrowserProtocolUse, ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, @@ -52,7 +52,7 @@ impl Error for WebDriverBiDiLocateNodesResultAdmissionError { /// /// This is still transport evidence, not OriginWeave node authority. It does not parse raw JSON, /// authenticate Chromium or its adapter, prove current session/context/origin/document authority, -/// mint [`crate::ObservedNodeHandle`] values, authorize policy or typed input, or establish an Agent +/// mint [`crate::AdmittedNodeHandle`] values, authorize policy or typed input, or establish an Agent /// action. A later reviewed current-authority boundary must consume these normalized references. #[derive(Debug, PartialEq, Eq)] pub struct ValidatedWebDriverBiDiLocateNodesResult { @@ -93,15 +93,16 @@ impl ValidatedWebDriverBiDiLocateNodesResult { /// different context. The registry then revalidates the exact session, canonical origin, and /// document epoch before all normalized `sharedId` values are bound transactionally. /// - /// Success mints only [`ObservedNodeHandle`] values. It does not authenticate Chromium or an - /// adapter process, perform browser I/O, authorize policy or typed input, or turn descriptive - /// protocol evidence into an Agent capability. + /// Success mints only [`AdmittedNodeHandle`] values carrying opaque provenance for this exact + /// registry instance. It does not authenticate Chromium or an adapter process, perform browser + /// I/O, authorize policy or typed input, or turn descriptive protocol evidence into an Agent + /// capability. pub fn bind_current_nodes( self, validated: ValidatedBrowserProtocolUse, authority_registry: &mut BrowserAuthorityRegistry, target: BrowserContextOriginEpochDispatchTarget<'_>, - ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { if validated.kind() != BrowserProtocolKind::WebDriverBiDi { return Err( WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), @@ -146,7 +147,7 @@ impl ValidatedWebDriverBiDiLocateNodesResult { .map(WebDriverBiDiRemoteNodeReference::shared_id) .collect::>(); authority_registry - .bind_nodes( + .bind_admitted_nodes( context.browser_session(), context.browsing_context(), context_origin.expected_origin(), diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs index 5c70cdc7a..08c116807 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs @@ -169,15 +169,17 @@ fn correlated_result_rejects_cross_context_rebinding() -> Result<(), Box Result<(), Box Result<(), Box< let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; assert_eq!( - result.bind_current_nodes( - protocol_proof( - BrowserProtocolKind::WebDriverBiDi, - BrowserProtocolCapability::TypedInput, - )?, - &mut registry, - target, - ), - Err( + result + .bind_current_nodes( + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::TypedInput, + )?, + &mut registry, + target, + ) + .err(), + Some( WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( BrowserProtocolCapability::TypedInput, ) @@ -242,8 +248,10 @@ fn correlated_result_rejects_missing_current_origin_binding() -> Result<(), Box< let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; assert_eq!( - result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), - Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + result + .bind_current_nodes(semantic_observation_proof()?, &mut registry, target) + .err(), + Some(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( BrowserRegistryError::ContextOriginNotBound, )) ); @@ -265,8 +273,10 @@ fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; assert_eq!( - result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), - Err( + result + .bind_current_nodes(semantic_observation_proof()?, &mut registry, target) + .err(), + Some( WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { expected: target.expected_epoch(), current: current_epoch, @@ -288,8 +298,10 @@ fn correlated_result_keeps_node_binding_transactional_on_identifier_exhaustion() ])?; assert_eq!( - result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), - Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + result + .bind_current_nodes(semantic_observation_proof()?, &mut registry, target) + .err(), + Some(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( BrowserRegistryError::IdentifierSpaceExhausted, )) ); diff --git a/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs b/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs index dd32ce80c..127f5f031 100644 --- a/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs +++ b/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs @@ -1,16 +1,110 @@ -use std::error::Error; +use std::{error::Error, io}; use originweave_core::{ - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_COMMAND_ID, - UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, - WebDriverBiDiPointerClickCommand, WebDriverBiDiPointerClickCommandError, - WebDriverBiDiRemoteNodeReference, + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_COMMAND_ID, Origin, + OriginWeaveProtocolVersion, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + ValidatedBrowserProtocolUse, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiPointerClickAuthorityError, WebDriverBiDiPointerClickCommand, + WebDriverBiDiPointerClickCommandError, WebDriverBiDiRemoteNodeReference, }; +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 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 json_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + _ => escaped.push(character), + } + } + escaped +} + +fn admitted_fixture( + browsing_context: &str, + shared_id: &str, +) -> Result< + ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, + ), + Box, +> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session("webdriver-session")?; + let context = registry.register_context(browser_session, browsing_context)?; + 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, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, browsing_context, &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new(&format!( + r#"{{"type":"success","id":41,"result":{{"nodes":[{{"type":"node","sharedId":"{}"}}]}}}}"#, + json_escape(shared_id) + ))?; + 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_id))?; + Ok((registry, handle, remote)) +} + #[test] fn pointer_click_command_serializes_exact_bidi_envelope() -> Result<(), Box> { - let node = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; - let command = WebDriverBiDiPointerClickCommand::new(42, "context-a", &node)?; + let (registry, handle, node) = admitted_fixture("context-a", "shared-node-42")?; + let command = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &handle, + &node, + ®istry, + )?; assert_eq!(command.command_id(), 42); assert_eq!(command.method(), WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD); @@ -24,34 +118,50 @@ fn pointer_click_command_serializes_exact_bidi_envelope() -> Result<(), Box Result<(), Box> { - let node = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + let (registry, handle, node) = admitted_fixture("context-a", "shared-node-42")?; assert_eq!( - WebDriverBiDiPointerClickCommand::new( + WebDriverBiDiPointerClickCommand::new_for_current_node( MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, "context-a", + &handle, &node, + ®istry, ), - Err(WebDriverBiDiPointerClickCommandError::InvalidCommandId) + Err(WebDriverBiDiPointerClickAuthorityError::Command( + WebDriverBiDiPointerClickCommandError::InvalidCommandId + )) ); for invalid in ["", "context with space", "context\nline"] { assert_eq!( - WebDriverBiDiPointerClickCommand::new(1, invalid, &node), - Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext) + WebDriverBiDiPointerClickCommand::new_for_current_node( + 1, invalid, &handle, &node, ®istry, + ), + Err(WebDriverBiDiPointerClickAuthorityError::BrowserAuthority( + BrowserRegistryError::InvalidExternalIdentifier, + )) ); } let overlong = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); assert_eq!( - WebDriverBiDiPointerClickCommand::new(1, &overlong, &node), - Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext) + WebDriverBiDiPointerClickCommand::new_for_current_node( + 1, &overlong, &handle, &node, ®istry, + ), + Err(WebDriverBiDiPointerClickAuthorityError::BrowserAuthority( + BrowserRegistryError::InvalidExternalIdentifier, + )) ); for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { let context = format!("context{character}"); assert_eq!( - WebDriverBiDiPointerClickCommand::new(1, &context, &node), - Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext) + WebDriverBiDiPointerClickCommand::new_for_current_node( + 1, &context, &handle, &node, ®istry, + ), + Err(WebDriverBiDiPointerClickAuthorityError::BrowserAuthority( + BrowserRegistryError::InvalidExternalIdentifier, + )) ); } Ok(()) @@ -61,9 +171,15 @@ fn pointer_click_command_rejects_invalid_command_and_context() -> Result<(), Box fn pointer_click_command_accepts_maximum_context_and_escaped_shared_id() -> Result<(), Box> { let context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); - let node = WebDriverBiDiRemoteNodeReference::new("node", Some(r#"node-"quoted"\path"#))?; - let command = - WebDriverBiDiPointerClickCommand::new(MAX_WEBDRIVER_BIDI_COMMAND_ID, &context, &node)?; + let shared_id = r#"node-"quoted"\path"#; + let (registry, handle, node) = admitted_fixture(&context, shared_id)?; + let command = WebDriverBiDiPointerClickCommand::new_for_current_node( + MAX_WEBDRIVER_BIDI_COMMAND_ID, + &context, + &handle, + &node, + ®istry, + )?; assert!(command.as_json().contains(&context)); assert!(command.as_json().contains(r#"node-\"quoted\"\\path"#)); diff --git a/crates/originweave-core/tests/webdriver_bidi_pointer_click_node_authority.rs b/crates/originweave-core/tests/webdriver_bidi_pointer_click_node_authority.rs new file mode 100644 index 000000000..eac1b04ec --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_pointer_click_node_authority.rs @@ -0,0 +1,319 @@ +use std::error::Error; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, MAX_WEBDRIVER_BIDI_COMMAND_ID, NodeHandleError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiPointerClickAuthorityError, + WebDriverBiDiPointerClickCommand, WebDriverBiDiPointerClickCommandError, + WebDriverBiDiRemoteNodeReference, +}; + +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"; + +struct AdmittedNodeFixture { + registry: BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + handle: AdmittedNodeHandle, + remote: 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_node() -> 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| { + std::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 command = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handles = command.bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )?; + let handle = handles + .into_iter() + .next() + .ok_or("locateNodes fixture did not bind its node")?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok(AdmittedNodeFixture { + registry, + browser_session, + browsing_context, + handle, + remote, + }) +} + +#[test] +fn pointer_click_serialization_requires_the_exact_current_admitted_wire_node() +-> Result<(), Box> { + let fixture = admitted_node()?; + let command = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &fixture.handle, + &fixture.remote, + &fixture.registry, + )?; + + assert_eq!(command.command_id(), 42); + assert!(command.as_json().contains(r#""sharedId":"shared-node-42""#)); + Ok(()) +} + +#[test] +fn pointer_click_rejects_a_caller_selected_unadmitted_shared_id() -> Result<(), Box> { + let fixture = admitted_node()?; + let forged = WebDriverBiDiRemoteNodeReference::new("node", Some("caller-selected-node"))?; + + let error = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &fixture.handle, + &forged, + &fixture.registry, + ) + .err() + .ok_or("expected unadmitted sharedId rejection")?; + assert_eq!( + error, + WebDriverBiDiPointerClickAuthorityError::NodeExternalIdentifierMismatch + ); + assert!(error.source().is_none()); + assert!(error.to_string().contains("wire node identifier")); + Ok(()) +} + +#[test] +fn pointer_click_rejects_the_right_node_under_the_wrong_external_context() +-> Result<(), Box> { + let fixture = admitted_node()?; + + let error = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-b", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected external context rejection")?; + assert_eq!( + error, + WebDriverBiDiPointerClickAuthorityError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + ) + ); + assert!(error.source().is_some()); + assert!(error.to_string().contains("browser authority")); + Ok(()) +} + +#[test] +fn pointer_click_rejects_a_pre_navigation_node_before_new_origin_binding() +-> Result<(), Box> { + let mut fixture = admitted_node()?; + fixture + .registry + .advance_document(fixture.browsing_context)?; + + let error = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected missing current origin rejection")?; + assert_eq!( + error, + WebDriverBiDiPointerClickAuthorityError::BrowserAuthority( + BrowserRegistryError::ContextOriginNotBound, + ) + ); + assert!(error.source().is_some()); + assert!(error.to_string().contains("browser authority")); + Ok(()) +} + +#[test] +fn pointer_click_rejects_a_stale_node_after_new_origin_is_rebound() -> Result<(), Box> { + let mut fixture = admitted_node()?; + let observed = fixture.handle.document_epoch(); + let current = fixture + .registry + .advance_document(fixture.browsing_context)?; + let origin = fixture.handle.origin().clone(); + fixture.registry.bind_context_origin( + fixture.browser_session, + fixture.browsing_context, + &origin, + )?; + + let error = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected stale document rejection")?; + assert_eq!( + error, + WebDriverBiDiPointerClickAuthorityError::NodeHandle(NodeHandleError::StaleDocumentEpoch { + observed, + current, + }) + ); + assert!(error.source().is_some()); + assert!(error.to_string().contains("node authority")); + Ok(()) +} + +#[test] +fn pointer_click_rejects_an_admitted_node_from_another_registry_even_when_public_fields_match() +-> Result<(), Box> { + let fixture = admitted_node()?; + let foreign = admitted_node()?; + + assert_eq!(fixture.browser_session, foreign.handle.browser_session()); + assert_eq!(fixture.browsing_context, foreign.handle.browsing_context()); + assert_eq!(fixture.handle.origin(), foreign.handle.origin()); + assert_eq!( + fixture.handle.document_epoch(), + foreign.handle.document_epoch() + ); + assert_eq!(fixture.handle.node_id(), foreign.handle.node_id()); + assert_eq!(fixture.remote.shared_id(), foreign.remote.shared_id()); + + assert_eq!( + WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &foreign.handle, + &fixture.remote, + &fixture.registry, + ), + Err(WebDriverBiDiPointerClickAuthorityError::NodeExternalIdentifierMismatch) + ); + Ok(()) +} + +#[test] +fn pointer_click_rejects_a_matching_context_bound_to_a_different_origin() +-> Result<(), Box> { + let fixture = admitted_node()?; + let mut foreign_registry = BrowserAuthorityRegistry::new(); + let session = foreign_registry.register_session("webdriver-session")?; + let context = foreign_registry.register_context(session, "context-a")?; + let other_origin = Origin::parse("https://other.example").map_err(|error| { + std::io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + foreign_registry.bind_context_origin(session, context, &other_origin)?; + + let error = WebDriverBiDiPointerClickCommand::new_for_current_node( + 42, + "context-a", + &fixture.handle, + &fixture.remote, + &foreign_registry, + ) + .err() + .ok_or("expected origin mismatch rejection")?; + assert_eq!( + error, + WebDriverBiDiPointerClickAuthorityError::BrowserAuthority( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + ) + ); + assert!(error.source().is_some()); + Ok(()) +} + +#[test] +fn pointer_click_reports_bounded_command_serialization_failure() -> Result<(), Box> { + let fixture = admitted_node()?; + + let error = WebDriverBiDiPointerClickCommand::new_for_current_node( + MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, + "context-a", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected command identifier rejection")?; + assert_eq!( + error, + WebDriverBiDiPointerClickAuthorityError::Command( + WebDriverBiDiPointerClickCommandError::InvalidCommandId, + ) + ); + assert!(error.source().is_some()); + assert!(error.to_string().contains("command rejected input")); + Ok(()) +} + +#[test] +fn authority_registry_rejects_document_advance_for_a_foreign_context() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("local-session")?; + let _local_context = registry.register_context(session, "local-context")?; + + let mut foreign_registry = BrowserAuthorityRegistry::new(); + let foreign_session = foreign_registry.register_session("foreign-session")?; + let _first_foreign_context = + foreign_registry.register_context(foreign_session, "foreign-context-a")?; + let second_foreign_context = + foreign_registry.register_context(foreign_session, "foreign-context-b")?; + + assert_eq!( + registry.advance_document(second_foreign_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs index 70802e89c..df82eb084 100644 --- a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs +++ b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs @@ -112,8 +112,8 @@ fn wire_response_binding_preserves_wire_correlation_failure_before_authority() ); assert_eq!( - error, - Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + error.err(), + Some(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { expected: 42, @@ -139,17 +139,17 @@ fn wire_response_binding_preserves_current_context_authority_failure() -> Result target, ); + let error = error + .err() + .ok_or("expected exact current context failure")?; assert_eq!( error, - Err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( BrowserRegistryError::ContextExternalIdentifierMismatch, ), - )) + ) ); - let error = error - .err() - .ok_or("expected exact current context failure")?; assert!(error.source().is_some()); assert!(!error.to_string().is_empty()); Ok(()) diff --git a/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs b/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs index dcdb4fc43..f673f8041 100644 --- a/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs @@ -1,6 +1,10 @@ use std::{error::Error, fmt, time::Duration}; -use originweave_core::WebDriverBiDiPointerClickCommand; +use originweave_core::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserProtocolCapability, BrowserProtocolKind, + ValidatedBrowserProtocolUse, WebDriverBiDiPointerClickAuthorityError, + WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, +}; use crate::{ MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, @@ -9,9 +13,18 @@ use crate::{ WebDriverBiDiWebSocketMaskKey, }; -/// Fail-closed errors while transporting one already validated pointer-click command. +/// Fail-closed errors while transporting one current-authority pointer click. #[derive(Debug)] pub enum WebDriverBiDiPointerClickSendError { + /// 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), + /// The node, browser-context, document, or bounded command authority failed immediate revalidation. + Authority { + /// Exact typed immediate-use authority failure. + source: WebDriverBiDiPointerClickAuthorityError, + }, /// The bounded correlation registry rejected the command before network I/O. Correlation { /// Exact typed correlation failure. @@ -27,6 +40,13 @@ pub enum WebDriverBiDiPointerClickSendError { impl fmt::Display for WebDriverBiDiPointerClickSendError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { + Self::UnsupportedProtocolKind(_) => { + "WebDriver BiDi pointer-click send requires a WebDriver BiDi proof" + } + Self::UnsupportedCapability(_) => { + "WebDriver BiDi pointer-click send requires typed-input capability" + } + Self::Authority { .. } => "WebDriver BiDi pointer-click node authority was rejected", Self::Correlation { .. } => { "WebDriver BiDi pointer-click command correlation was rejected" } @@ -38,38 +58,96 @@ impl fmt::Display for WebDriverBiDiPointerClickSendError { impl Error for WebDriverBiDiPointerClickSendError { 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), } } } -/// Register and write one already validated `input.performActions` pointer-click command. +/// Revalidate, register, and write one `input.performActions` pointer click. +/// +/// 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 node authority, +/// command correlation, or frame I/O, so semantic-observation, navigation, CDP, or other protocol +/// proofs cannot dispatch a pointer click through this transport boundary. +/// +/// After protocol validation and immediately before correlation, this boundary reconstructs the +/// bounded pointer command from the exact [`AdmittedNodeHandle`], external browsing-context +/// identifier, remote node reference, and live [`BrowserAuthorityRegistry`]. That immediate-use +/// check rejects stale document epochs, cross-registry handles, changed origins, mismatched external +/// contexts, and unadmitted wire node identifiers before any command identifier is registered or +/// any action frame is written. A previously constructed command therefore cannot outlive its node +/// authority and later bypass revalidation at transport time. The established transport's verified +/// protocol session must also match the registry's canonical external session mapping before +/// correlation or I/O; this comparison does not authenticate the browser process. /// /// Invalid local frame deadlines fail before registration. Correlation then occurs before the first /// possible remote side effect and retains the exact connection's private generation for later /// connection-bound response admission. A frame preflight rejection that proves no write began retires the /// exact id; a partial or complete remote side effect remains ambiguous and leaves it outstanding. /// -/// This boundary accepts only [`WebDriverBiDiPointerClickCommand`], not arbitrary JSON or method -/// names. It does not authenticate the browser, grant session/context/origin/document-epoch -/// authority, authorize policy or TypedInput capability, admit nodes, correlate a response, prove an -/// observed post-condition, retry, reconnect, or choose another destination. A trusted caller must -/// establish those independent authorities before transport and retain response/post-condition -/// evidence afterward. +/// 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 command identity, live node authority, transport, correlation, masking, and deadline inputs explicit rather than persisting a reusable prevalidated command" +)] pub fn send_webdriver_bidi_pointer_click( - command: &WebDriverBiDiPointerClickCommand, + 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(WebDriverBiDiPointerClickSendError::UnsupportedProtocolKind( + validated.kind(), + )); + } + if validated.capability() != BrowserProtocolCapability::TypedInput { + return Err(WebDriverBiDiPointerClickSendError::UnsupportedCapability( + validated.capability(), + )); + } + let _consumed_typed_input_proof = validated; + + let command = WebDriverBiDiPointerClickCommand::new_for_current_node( + command_id, + browsing_context, + handle, + node, + registry, + ) + .map_err(|source| WebDriverBiDiPointerClickSendError::Authority { source })?; + if frame_timeout.is_zero() { return Err(invalid_frame_timeout(frame_timeout)); } if frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { return Err(invalid_frame_timeout(frame_timeout)); } + registry + .require_registered_session_external_identifier( + handle.browser_session(), + established + .transport_evidence() + .verified_peer() + .session_id(), + ) + .map_err(|source| WebDriverBiDiPointerClickSendError::Authority { + source: WebDriverBiDiPointerClickAuthorityError::BrowserAuthority(source), + })?; match correlation.register_command_for_connection( command.command_id(), WebDriverBiDiCommandKind::PointerClick, diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs index 52f1412b3..7079ae2b2 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs @@ -7,9 +7,13 @@ use std::{ }; use originweave_core::{ - BrowserAuthorityRegistry, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, - WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, - WebDriverBiDiWebSocketEndpoint, + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiPointerClickCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, @@ -28,6 +32,99 @@ const EXPECTED_URL: &str = "https://example.test/after"; 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":{}}"#; const NAVIGATION_COMMITTED_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":"nav-42","timestamp":1234,"url":"https://example.test/after","vendorExtension":{"ignored":true}}}"#; +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 = ( + WebDriverBiDiPointerClickCommand, + 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_command( + command_id: u64, +) -> 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://example.test").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"))?; + let command = WebDriverBiDiPointerClickCommand::new_for_current_node( + command_id, + "context-a", + &handle, + &remote, + ®istry, + )?; + Ok((command, registry, handle, remote)) +} fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -127,12 +224,9 @@ fn click_then_observe_navigation_with_event( > { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; - let expected = WebDriverBiDiPointerClickCommand::new( - 42, - "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; - let expected_json = expected.as_json().as_bytes().to_vec(); + let (command, pointer_registry, pointer_handle, pointer_remote) = + admitted_pointer_click_command(42)?; + let expected_json = command.as_json().as_bytes().to_vec(); let event_payload = event_payload.to_vec(); let server = thread::spawn(move || -> io::Result<()> { @@ -161,14 +255,14 @@ fn click_then_observe_navigation_with_event( .write_opening_request(Duration::from_millis(500))? .read_opening_response(Duration::from_millis(500))?; - let command = WebDriverBiDiPointerClickCommand::new( - 42, - "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let established = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + 42, + "context-a", + &pointer_handle, + &pointer_remote, + &pointer_registry, established, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_response.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_response.rs index dce677dd6..0108c4f8d 100644 --- a/crates/originweave-network/tests/webdriver_bidi_pointer_click_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_response.rs @@ -7,6 +7,11 @@ use std::{ }; use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, }; @@ -29,6 +34,89 @@ const CLICK_REMOTE_ERROR_RESPONSE: &[u8] = const CLICK_UNKNOWN_ID_RESPONSE: &[u8] = br#"{"type":"success","id":43,"result":{"vendorExtension":true}}"#; const CLICK_MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":42}"#; +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)))?; @@ -108,12 +196,17 @@ fn send_click_and_read_response( > { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; - let expected = WebDriverBiDiPointerClickCommand::new( + let (registry, handle, remote) = admitted_pointer_click_fixture()?; + let expected_json = WebDriverBiDiPointerClickCommand::new_for_current_node( 42, "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; - let expected_json = expected.as_json().as_bytes().to_vec(); + &handle, + &remote, + ®istry, + )? + .as_json() + .as_bytes() + .to_vec(); let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; @@ -141,14 +234,14 @@ fn send_click_and_read_response( .write_opening_request(Duration::from_millis(500))? .read_opening_response(Duration::from_millis(500))?; - let command = WebDriverBiDiPointerClickCommand::new( - 42, - "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let established = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + 42, + "context-a", + &handle, + &remote, + ®istry, established, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), 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 index ac5a2db8d..6ed3d41a2 100644 --- 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 @@ -7,6 +7,11 @@ use std::{ }; use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, }; @@ -28,6 +33,89 @@ const CLICK_SUCCESS_RESPONSE: &[u8] = 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(); @@ -120,10 +208,13 @@ fn read_response( 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 expected = WebDriverBiDiPointerClickCommand::new( + let (registry, handle, remote) = admitted_pointer_click_fixture()?; + let expected = WebDriverBiDiPointerClickCommand::new_for_current_node( 42, "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, + &handle, + &remote, + ®istry, )?; let expected_json = expected.as_json().as_bytes().to_vec(); let original_server = thread::spawn(move || -> io::Result<()> { @@ -147,15 +238,15 @@ fn assert_replacement_rejected(foreign_response: &'static [u8]) -> Result<(), Bo }); let original = establish(original_addr)?; - let command = WebDriverBiDiPointerClickCommand::new( - 42, - "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; let original = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + 42, + "context-a", + &handle, + &remote, + ®istry, original, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs index 6f66e1e2c..cd9e6f1ca 100644 --- a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs @@ -8,6 +8,11 @@ use std::{ }; use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, }; @@ -20,6 +25,90 @@ use originweave_network::{ 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 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)))?; @@ -93,12 +182,17 @@ fn pointer_click_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 expected = WebDriverBiDiPointerClickCommand::new( + let (registry, handle, remote) = admitted_pointer_click_fixture()?; + let expected_json = WebDriverBiDiPointerClickCommand::new_for_current_node( 42, "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; - let expected_json = expected.as_json().as_bytes().to_vec(); + &handle, + &remote, + ®istry, + )? + .as_json() + .as_bytes() + .to_vec(); let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; @@ -125,14 +219,14 @@ fn pointer_click_command_writes_exact_masked_bidi_frame_and_stays_outstanding() .write_opening_request(Duration::from_millis(500))? .read_opening_response(Duration::from_millis(500))?; - let command = WebDriverBiDiPointerClickCommand::new( - 42, - "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, - )?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let _established = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + 42, + "context-a", + &handle, + &remote, + ®istry, established, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), @@ -178,14 +272,15 @@ fn pointer_click_reused_mask_key_rejection_retires_correlation() -> Result<(), B let established = established.write_pong_frame(b"{}", repeated_key, Duration::from_millis(500))?; - let command = WebDriverBiDiPointerClickCommand::new( - 43, - "context-a", - &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-43"))?, - )?; + let (registry, handle, remote) = admitted_pointer_click_fixture()?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let error = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, established, &mut correlation, repeated_key, @@ -247,15 +342,16 @@ fn pointer_click_ambiguous_socket_write_keeps_correlation() -> Result<(), Box>, +); + +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("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"))?; + + 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 pointer-click 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 pointer-click 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_send_time_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_pointer_click( + typed_input_proof()?, + 42, + "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("stale admitted node unexpectedly reached pointer-click I/O") + })?; + + assert!(matches!( + error, + WebDriverBiDiPointerClickSendError::Authority { + source: WebDriverBiDiPointerClickAuthorityError::NodeHandle(_) + } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi pointer-click node authority was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("stale-authority pointer server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs index 700f0917a..7de7396fb 100644 --- a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs @@ -7,8 +7,12 @@ use std::{ }; use originweave_core::{ - WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, - WebDriverBiDiWebSocketEndpoint, + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, @@ -21,10 +25,91 @@ use originweave_network::{ 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 PointerClickFixture = ( + 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 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)))?; @@ -49,7 +134,23 @@ 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 pointer command emitted wire bytes", + )), + Err(error) => Err(error), + } }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -65,13 +166,89 @@ fn establish_with_handshake_only_server() -> Result Result> { - let node = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; - Ok(WebDriverBiDiPointerClickCommand::new( - command_id, +#[test] +fn pointer_click_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) = pointer_click_fixture()?; + + let error = send_webdriver_bidi_pointer_click( + semantic_observation_proof()?, + 5, "context-a", - &node, - )?) + &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 a pointer click") + })?; + assert!(matches!( + error, + WebDriverBiDiPointerClickSendError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi pointer-click 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 pointer_click_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) = pointer_click_fixture()?; + + let error = send_webdriver_bidi_pointer_click( + protocol_proof( + BrowserProtocolKind::ChromeDevToolsProtocol, + BrowserProtocolCapability::TypedInput, + )?, + 6, + "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 typed-input proof unexpectedly sent a pointer click"))?; + assert!(matches!( + error, + WebDriverBiDiPointerClickSendError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol + ) + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi pointer-click 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] @@ -79,10 +256,15 @@ fn pointer_click_rejects_duplicate_correlation_before_frame_write() -> Result<() let (established, server) = establish_with_handshake_only_server()?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation.register_command_for(7, WebDriverBiDiCommandKind::PointerClick)?; - let command = pointer_click(7)?; + let (registry, handle, remote) = pointer_click_fixture()?; let error = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + 7, + "context-a", + &handle, + &remote, + ®istry, established, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), @@ -107,6 +289,43 @@ fn pointer_click_rejects_duplicate_correlation_before_frame_write() -> Result<() Ok(()) } +#[test] +fn pointer_click_invalid_timeout_leaves_no_correlation_or_wire_bytes() -> Result<(), Box> +{ + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (registry, handle, remote) = pointer_click_fixture()?; + + let error = send_webdriver_bidi_pointer_click( + typed_input_proof()?, + 11, + "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 a pointer click"))?; + assert!(matches!( + error, + WebDriverBiDiPointerClickSendError::FrameWrite { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi pointer-click command frame write failed" + ); + assert!(error.source().is_some()); + server + .join() + .map_err(|_| io::Error::other("invalid-timeout pointer server panicked"))??; + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + #[test] fn pointer_click_rejects_invalid_frame_timeout_before_correlation_registration() -> Result<(), Box> { @@ -116,10 +335,15 @@ fn pointer_click_rejects_invalid_frame_timeout_before_correlation_registration() ] { let (established, server) = establish_with_handshake_only_server()?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - let command = pointer_click(command_id)?; + let (registry, handle, remote) = pointer_click_fixture()?; let error = send_webdriver_bidi_pointer_click( - &command, + typed_input_proof()?, + command_id, + "context-a", + &handle, + &remote, + ®istry, established, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), 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(()) +} diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index be20513a1..70bdeb62e 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -36,6 +36,27 @@ MCP version negotiation is independent of the OriginWeave Protocol version. As o ## Consequences +### Proposed refinement: immediate pointer authority with shared dispatch (2026-09-06) + +In the context of integrating admitted-node clicks with the navigation-subscription prerequisite, +facing stale node authority and an invalid deadline leaving an unsent command pending, we decided +for immediate node revalidation followed by the existing typed dispatch boundary, and against +reusable prevalidated commands or a second registry-identity allocation, to preserve both node +provenance and exact pending-command behavior, accepting that callers must supply live registry +state and a fresh typed-input proof for every attempt. + +The node handle shares the core registry's existing identity allocation; its exact external-node +mapping and lifecycle invalidation remain private. Zero and excessive deadlines fail before +registration, proven no-write frame failures retire only the new PointerClick entry, and ambiguous +writes retain it. This does not authenticate the browser, authorize the action, bind pointer replies +to a connection, or prove that the click caused navigation. + +The socket regression at `3f9cdc1a` observed one outstanding command after a zero deadline. Its +successor at integration commit `7535d8af` observes none and requires no command bytes on the peer. +The integration also restores four parent navigation-postcondition regressions and retains both +mask-reuse rejection and ambiguous-write socket tests. Status remains Proposed; hosted checks, +protected-main delivery and fresh visual inspection are separate gates. + ### Proposed refinement: registry-to-transport session provenance (2026-09-06) In the context of dispatching a registry-bound subscription on an established transport, facing diff --git a/docs/doctoring.md b/docs/doctoring.md index 42018a593..120ea4041 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -4,6 +4,28 @@ This document records external evidence that changes OriginWeave architecture, t ## Decision trace +### Pointer outbound authority and received-reply integration + +Child #265 at `ddce7248` already checked the admitted node and canonical registry session +before pointer dispatch. Regression `e7fb1527` nevertheless reproduced both replacement +success and error replies consuming its original pending request (0/2 passing), using +the same listener and external session. The original server was joined before the +decisive mismatch assertion; unrelated pending work and genuine-reply recovery remain +part of each case. + +Ordinary parent adoption `d847b530` retains that child authority guard and typed monotonic +dispatch while adding #264 `43395711` connection registration and sealed reply consumption. +It reuses the existing connection identity and message reader, with no new dependency, +raw-response fallback or duplicate authority owner. The 14 focused pointer, outbound +session and navigation-postcondition tests pass. Full combined-head verification remains +required; predecessor coverage and screenshots are not transferable. + +This implements the existing reply-provenance invariant, not a new accepted architectural +decision. Node/session matching and a correctly correlated reply still do not authenticate +Chromium, authorize policy, prove that an observed navigation was caused by the click, +or establish protected-main and release acceptance. Parent-only historical deferrals below +do not remove the stronger safeguards already present in this child. + ### Subscription registry-to-transport session provenance The real-loopback test at `b4702cd5`, executed locally before repair, dispatched a subscription diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 061f77680..42ab72f7d 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -152,3 +152,20 @@ This dossier does **not** close issue #28. Material remaining work includes: ## 7. Documentation fitness consequence The ADR/PRD/TRD/Architecture/UML/ERD graph remains **DESIGN-SUFFICIENT / PROTECTED-MAIN-PARTIAL**. PR #64 narrows a typed evidence gap already governed by existing provenance/action-success decisions, while PR #65 supplies controlled test infrastructure for the eventual real-browser proof. Neither introduces a new trust domain, deployed component, persistence owner, database schema, or independent architecture decision, so a new ADR or physical ERD entity would overstate the implementation. Detailed real-Chromium dispatch/post-condition sequence diagrams should be reconciled when the executable adapter chain stabilizes rather than manufacturing as-built detail before that runtime exists. + +## 8. Pointer descendant reply integration + +The #265 integration at `d847b530` preserves current-node and outbound-session checks +from `ddce7248`, then adopts parent #264 `43395711` reply provenance. The real socket +regression `e7fb1527` first reproduced replacement success and error consuming the +original click request. Both now reject the foreign connection while retaining the +original request and unrelated work; the genuine original reply still completes. + +All 14 focused tests pass, including stale-node rejection, foreign-session rejection +before pending state or command bytes, and the preserved navigation-postcondition +cases. Full exact-head local/hosted gates and visual inspection remain independently +required. No inherited checkpoint establishes acceptance for this combined tree. + +A matching response is still only protocol acknowledgment. Policy approval, browser +authentication, trusted event provenance and causal page effects remain separate. +This active Draft does not close issue #28 or establish protected-main delivery.