diff --git a/CHANGELOG.md b/CHANGELOG.md index 65954029c..a33cbe014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Preserve current-node action safeguards while adopting connection-bound field-value replies; a matching reply still does not authorize an action. + - Reject field-value replies received on a replacement connection, even when their request identifier and text match. - Reject field-observation requests on another browser session and preserve pending requests only when a write may have reached the peer. @@ -59,6 +61,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Complete typed semantic-node action retention at the public binding boundary, including fail-closed coverage of registry-authority corruption and deterministic typed error sources. - Typed WebDriver BiDi command-family correlation for text-value post-condition observations, so a matching numeric response id cannot consume an outstanding command from another operation family; successful envelopes reuse the parser-proven non-null id invariant without an unreachable fallback branch. - Deterministic WebDriver BiDi primary-button click serialization for an already admitted remote node: it emits one fixed `input.performActions` mouse sequence from bounded command/context/node identifiers and remains inert until a trusted adapter binds it to current session, origin, document, policy, and approval authority. - Typed outbound WebDriver BiDi primary-button click transport over the bounded client WebSocket stream: it rejects invalid frame deadlines before correlation registration, retires only the just-registered id when local frame preflight proves no command bytes were emitted, preserves correlation across ambiguous writes, and does not treat frame-write success as proof that the browser performed the click. diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..8d9a4e4c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ homepage = "https://github.com/ContextualWisdomLab/OriginWeave" [workspace.lints.rust] unsafe_code = "forbid" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } [workspace.lints.clippy] dbg_macro = "deny" diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index ee17dc586..887265b64 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -1,11 +1,13 @@ use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::sync::Arc; use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; use crate::{ - BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, - Origin, + BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, + ObservedNodeHandle, Origin, }; /// A registry-issued node handle that carries opaque provenance in addition to descriptive node state. @@ -28,6 +30,52 @@ impl Deref for AdmittedNodeHandle { } } +/// A fail-closed error while revalidating opaque registry-issued node authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdmittedNodeAuthorityError { + /// The supplied handle was issued by a different registry instance. + ForeignRegistry, + /// The registry no longer retains this exact admitted node binding. + NotAdmitted, + /// Current session, context, or canonical-origin authority no longer matches. + BrowserAuthority(BrowserRegistryError), + /// The handle no longer matches the registry's current document lifetime. + NodeHandle(NodeHandleError), +} + +impl Display for AdmittedNodeAuthorityError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ForeignRegistry => formatter + .write_str("admitted node was issued by a different browser authority registry"), + Self::NotAdmitted => formatter + .write_str("admitted node authority is no longer retained by this registry"), + Self::BrowserAuthority(error) => { + write!( + formatter, + "admitted node browser authority rejected input: {error}" + ) + } + Self::NodeHandle(error) => { + write!( + formatter, + "admitted node document authority rejected input: {error}" + ) + } + } + } +} + +impl Error for AdmittedNodeAuthorityError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::BrowserAuthority(error) => Some(error), + Self::NodeHandle(error) => Some(error), + Self::ForeignRegistry | Self::NotAdmitted => None, + } + } +} + /// Public browser-authority registry with raw node minting kept inside the crate. /// /// Browser-session, browsing-context, document-epoch, and canonical-origin lifecycle operations are @@ -210,6 +258,44 @@ impl BrowserAuthorityRegistry { .require_context_origin(browser_session, browsing_context, origin) } + /// Revalidate one exact registry-issued admitted node before later typed dispatch. + /// + /// This check preserves opaque registry-instance provenance and verifies that this registry still + /// retains the exact admitted node key under the current session, context, origin, and document + /// epoch. It deliberately does not validate the adapter-local wire identifier; the final typed + /// adapter constructor must still bind the exact `sharedId` immediately before browser I/O. + pub fn validate_admitted_node_handle( + &self, + handle: &AdmittedNodeHandle, + ) -> Result<(), AdmittedNodeAuthorityError> { + if !Arc::ptr_eq(&self.registry_identity, &handle.registry_instance) { + return Err(AdmittedNodeAuthorityError::ForeignRegistry); + } + if !self + .admitted_node_external_identifiers + .contains_key(&node_authority_key(&handle.observed)) + { + return Err(AdmittedNodeAuthorityError::NotAdmitted); + } + + self.require_context_origin( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + ) + .map_err(AdmittedNodeAuthorityError::BrowserAuthority) + .and_then(|current_epoch| { + handle + .validate_current( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + current_epoch, + ) + .map_err(AdmittedNodeAuthorityError::NodeHandle) + }) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. pub fn advance_document( &mut self, @@ -306,3 +392,37 @@ fn node_authority_key(handle: &ObservedNodeHandle) -> (u64, u64, u64, u64) { handle.node_id(), ) } + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::{AdmittedNodeAuthorityError, BrowserAuthorityRegistry}; + use crate::{BrowserRegistryError, Origin}; + + #[test] + #[cfg_attr(coverage, coverage(off))] + fn admitted_node_revalidation_preserves_broken_registry_authority() -> Result<(), Box> + { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("broken-authority-session")?; + let context = registry.register_context(session, "broken-authority-context")?; + let origin = Origin::parse("https://example.com").map_err(|error| { + std::io::Error::other(format!("fixture origin rejected: {error:?}")) + })?; + let handle = registry + .bind_admitted_nodes(session, context, &origin, &["node"])? + .pop() + .ok_or("fixture did not bind its node")?; + registry.validate_admitted_node_handle(&handle)?; + registry.inner.remove_context(context)?; + + assert_eq!( + registry.validate_admitted_node_handle(&handle), + Err(AdmittedNodeAuthorityError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext + )) + ); + Ok(()) + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 857b73fcd..1ed2392e5 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(coverage, feature(coverage_attribute))] //! Shared security and governance contracts for OriginWeave. //! //! This crate keeps the long-lived value contracts in `contracts` and the @@ -45,6 +46,7 @@ mod browser_registry; mod browser_registry_coverage; mod browser_registry_external_context; mod contracts; +mod semantic_action_binding; mod webdriver_bidi_command; mod webdriver_bidi_error_code; mod webdriver_bidi_pointer_click_authority; @@ -58,7 +60,8 @@ mod webdriver_bidi_websocket_connect_target; mod webdriver_bidi_websocket_endpoint; pub use browser_authority_registry::{ - AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserRegistryIdentity, + AdmittedNodeAuthorityError, AdmittedNodeHandle, BrowserAuthorityRegistry, + BrowserRegistryIdentity, }; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, @@ -88,6 +91,9 @@ pub use browser_registry::{ UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; +pub use semantic_action_binding::{ + NodeActionKind, SemanticNodeActionBinding, SemanticNodeActionBindingError, +}; pub use webdriver_bidi_command::{ CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs new file mode 100644 index 000000000..07201c048 --- /dev/null +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -0,0 +1,103 @@ +use std::fmt; + +use crate::{ + ActionRequest, AdmittedNodeAuthorityError, AdmittedNodeHandle, BrowserAuthorityRegistry, +}; + +/// A node-local typed action retained by an authorized semantic binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeActionKind { + /// Activate the node using browser-native click semantics. + Click, + /// Insert bounded non-secret text using browser-native input semantics. + TypeText, + /// Select one option using browser-native selection semantics. + SelectOption, + /// Set a checkable control to an explicit checked state. + SetChecked, + /// Scroll the node into the viewport without activating it. + ScrollIntoView, +} + +/// One registry-issued browser node and local node action explicitly paired with the business +/// action request they would serve. +/// +/// The binding prevents a caller from independently validating one current browser node, selecting +/// a different browser-local action at dispatch, and combining that side effect with a separately +/// authorized business intent. It deliberately does not authorize policy, map the node-local action +/// to a business risk class, grant a destination, resolve secrets, or execute browser I/O. The later +/// typed adapter boundary still revalidates registry provenance and the exact admitted wire node +/// immediately before I/O. +#[derive(Debug)] +pub struct SemanticNodeActionBinding { + handle: AdmittedNodeHandle, + node_action: NodeActionKind, + request: ActionRequest, +} + +impl SemanticNodeActionBinding { + /// Bind one registry-issued admitted node and exact node-local action to a business request from + /// the same source origin. + pub fn new( + handle: AdmittedNodeHandle, + node_action: NodeActionKind, + request: ActionRequest, + ) -> Result { + if handle.origin() != request.source_origin() { + return Err(SemanticNodeActionBindingError::SourceOriginMismatch); + } + Ok(Self { + handle, + node_action, + request, + }) + } + + /// Return the exact registry-issued node retained for later immediate-use authority checks. + #[must_use] + pub const fn handle(&self) -> &AdmittedNodeHandle { + &self.handle + } + + /// Return the exact browser-local node action retained with the authorized business intent. + #[must_use] + pub const fn node_action(&self) -> NodeActionKind { + self.node_action + } + + /// Return the independently classified business action request. + #[must_use] + pub const fn request(&self) -> &ActionRequest { + &self.request + } + + /// Revalidate the exact retained admitted-node authority against the trusted current registry. + /// + /// This preserves opaque registry-instance provenance and current session/context/origin/document + /// state. It does not validate the final adapter-local wire identifier or execute browser I/O; + /// the typed adapter command constructor must still perform that immediate-use check. + pub fn validate_current( + &self, + registry: &BrowserAuthorityRegistry, + ) -> Result<(), AdmittedNodeAuthorityError> { + registry.validate_admitted_node_handle(&self.handle) + } +} + +/// A bounded failure to pair admitted browser-node authority with a business action request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeActionBindingError { + /// The request claims a different source document origin than the admitted node. + SourceOriginMismatch, +} + +impl fmt::Display for SemanticNodeActionBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SourceOriginMismatch => formatter + .write_str("admitted node origin does not match action request source origin"), + } + } +} + +impl std::error::Error for SemanticNodeActionBindingError {} diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs new file mode 100644 index 000000000..33b7f45c1 --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -0,0 +1,158 @@ +use std::error::Error; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeHandle, + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + InstructionSource, NodeActionKind, Origin, OriginWeaveProtocolVersion, SecretDelivery, + SemanticNodeActionBinding, SemanticNodeActionBindingError, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, +}; + +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"; +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +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<(BrowserAuthorityRegistry, AdmittedNodeHandle), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("policy-binding-session")?; + let context = registry.register_context(session, "policy-binding-context")?; + let source_origin = Origin::parse("https://app.example") + .map_err(|error| std::io::Error::other(format!("fixture origin rejected: {error:?}")))?; + let epoch = registry.bind_context_origin(session, context, &source_origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &source_origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let command = WebDriverBiDiLocateNodesCommand::new(41, "policy-binding-context", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = command + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or("locateNodes fixture did not bind its node")?; + Ok((registry, handle)) +} + +fn action_request(source: Origin, target: Origin) -> Result> { + let intent = ActionIntentDigest::parse(VALID_INTENT) + .map_err(|error| std::io::Error::other(format!("intent rejected: {error:?}")))?; + Ok(ActionRequest::new( + ActionKind::Draft, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent, + )) +} + +#[test] +fn action_binding_keeps_node_action_and_business_target_separate() -> Result<(), Box> { + let (_registry, handle) = admitted_node()?; + let node_origin = handle.origin().clone(); + let node_id = handle.node_id(); + let business_target = Origin::parse("https://destination.example") + .map_err(|error| std::io::Error::other(format!("target origin rejected: {error:?}")))?; + let request = action_request(node_origin, business_target.clone())?; + + let binding = SemanticNodeActionBinding::new(handle, NodeActionKind::TypeText, request)?; + + assert_eq!(binding.handle().node_id(), node_id); + assert_eq!(binding.node_action(), NodeActionKind::TypeText); + assert_eq!(binding.request().target_origin(), &business_target); + assert_eq!(binding.request().action(), ActionKind::Draft); + Ok(()) +} + +#[test] +fn action_binding_rejects_business_request_from_another_document_origin() +-> Result<(), Box> { + let (_registry, handle) = admitted_node()?; + let other_origin = Origin::parse("https://other.example") + .map_err(|error| std::io::Error::other(format!("other origin rejected: {error:?}")))?; + let target_origin = Origin::parse("https://destination.example") + .map_err(|error| std::io::Error::other(format!("target origin rejected: {error:?}")))?; + let request = action_request(other_origin, target_origin)?; + + let error = SemanticNodeActionBinding::new(handle, NodeActionKind::Click, request) + .err() + .ok_or("mismatched source origin unexpectedly admitted")?; + assert_eq!(error, SemanticNodeActionBindingError::SourceOriginMismatch); + assert_eq!( + error.to_string(), + "admitted node origin does not match action request source origin" + ); + assert!(error.source().is_none()); + Ok(()) +} + +#[test] +fn node_action_kinds_preserve_the_complete_typed_set() { + let mut actions = [ + NodeActionKind::ScrollIntoView, + NodeActionKind::SetChecked, + NodeActionKind::SelectOption, + NodeActionKind::TypeText, + NodeActionKind::Click, + ]; + actions.sort(); + + assert_eq!( + actions, + [ + NodeActionKind::Click, + NodeActionKind::TypeText, + NodeActionKind::SelectOption, + NodeActionKind::SetChecked, + NodeActionKind::ScrollIntoView, + ] + ); + assert_eq!( + NodeActionKind::SelectOption.partial_cmp(&NodeActionKind::SetChecked), + Some(std::cmp::Ordering::Less) + ); + assert_eq!( + format!("{:?}", NodeActionKind::ScrollIntoView), + "ScrollIntoView" + ); + let mut hasher = DefaultHasher::new(); + NodeActionKind::SetChecked.hash(&mut hasher); + assert_ne!(hasher.finish(), 0); +} diff --git a/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs new file mode 100644 index 000000000..37a893b2e --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs @@ -0,0 +1,159 @@ +use std::error::Error; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeAuthorityError, AdmittedNodeHandle, + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserRegistryError, BrowsingContextId, InstructionSource, NodeActionKind, NodeHandleError, + Origin, OriginWeaveProtocolVersion, SecretDelivery, SemanticNodeActionBinding, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, +}; + +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"; +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +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< + ( + BrowserAuthorityRegistry, + BrowsingContextId, + AdmittedNodeHandle, + ), + Box, +> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("binding-current-authority-session")?; + let context = registry.register_context(session, "binding-current-authority-context")?; + let source_origin = Origin::parse("https://app.example") + .map_err(|error| std::io::Error::other(format!("fixture origin rejected: {error:?}")))?; + let epoch = registry.bind_context_origin(session, context, &source_origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &source_origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Continue"), 1)?; + let command = + WebDriverBiDiLocateNodesCommand::new(51, "binding-current-authority-context", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":51,"result":{"nodes":[{"type":"node","sharedId":"binding-current-authority-node"}]}}"#, + )?; + let handle = command + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or("locateNodes fixture did not bind its node")?; + Ok((registry, context, handle)) +} + +fn binding(handle: AdmittedNodeHandle) -> Result> { + let source_origin = handle.origin().clone(); + let target_origin = Origin::parse("https://destination.example") + .map_err(|error| std::io::Error::other(format!("target origin rejected: {error:?}")))?; + let intent = ActionIntentDigest::parse(VALID_INTENT) + .map_err(|error| std::io::Error::other(format!("intent rejected: {error:?}")))?; + let request = ActionRequest::new( + ActionKind::Draft, + source_origin, + target_origin, + InstructionSource::User, + SecretDelivery::None, + intent, + ); + Ok(SemanticNodeActionBinding::new( + handle, + NodeActionKind::Click, + request, + )?) +} + +#[test] +fn action_binding_revalidates_exact_registry_issued_node_authority() -> Result<(), Box> { + let (registry, _context, handle) = admitted_node()?; + let binding = binding(handle)?; + + binding.validate_current(®istry)?; + Ok(()) +} + +#[test] +fn action_binding_rejects_stale_document_authority() -> Result<(), Box> { + let (mut registry, context, handle) = admitted_node()?; + let binding = binding(handle)?; + registry.advance_document(context)?; + + assert_eq!( + binding.validate_current(®istry), + Err(AdmittedNodeAuthorityError::NotAdmitted) + ); + Ok(()) +} + +#[test] +fn action_binding_rejects_foreign_registry_even_for_reproducible_descriptive_tuple() +-> Result<(), Box> { + let (_registry, _context, handle) = admitted_node()?; + let binding = binding(handle)?; + let foreign_registry = BrowserAuthorityRegistry::new(); + + assert_eq!( + binding.validate_current(&foreign_registry), + Err(AdmittedNodeAuthorityError::ForeignRegistry) + ); + Ok(()) +} + +#[test] +fn admitted_node_authority_errors_preserve_typed_sources() { + let errors = [ + AdmittedNodeAuthorityError::ForeignRegistry, + AdmittedNodeAuthorityError::NotAdmitted, + AdmittedNodeAuthorityError::BrowserAuthority(BrowserRegistryError::UnknownBrowserSession), + AdmittedNodeAuthorityError::NodeHandle(NodeHandleError::InvalidNodeId), + ]; + + assert_eq!( + errors.map(|error| error.to_string()), + [ + "admitted node was issued by a different browser authority registry", + "admitted node authority is no longer retained by this registry", + "admitted node browser authority rejected input: browser session is not registered in this authority registry", + "admitted node document authority rejected input: observed node identifier must be nonzero", + ] + ); + assert_eq!( + errors.map(|error| error.source().is_some()), + [false, false, true, true] + ); +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs index 63213c31f..0b62298d0 100644 --- a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs @@ -89,7 +89,6 @@ fn read_response_text( _ => Err(io::Error::other("fixture expected a complete text response").into()), } } - #[test] fn replacement_connection_cannot_complete_text_observation() -> Result<(), Box> { for payload in [ diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 480c0465a..6e2009abb 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -20,6 +20,8 @@ The fixed field-observation sender now requires the admitted node's registered s Field-value response admission consumes only sealed messages received on that sender's exact connection. A replacement connection cannot complete the request with a matching identifier, success value, protocol error, or script exception. Equality remains necessary for positive value evidence; response parsing and dispatch alone still do not prove a completed user action. +Semantic action binding retains its independently classified business request and registry-issued node authority when adopting this response boundary. Immediate-use validation uses the same registry identity as admitted-node minting and typed-command validation; reply provenance remains independently connection-bound. Current node retention, origin and document lifetime still require validation. Neither the binding nor a matching field reply grants policy approval or proves end-to-end action success. + The same Working Draft defines `ErrorResponse.error` as `ErrorCode`. Its rendered local-end CDDL enumerates 30 values and omits `no such client window`, while §3.5 separately defines `no such client window` and normative client-window algorithms return that error code. OriginWeave therefore admits the finite rendered CDDL vocabulary plus this one separately defined normative error, and still rejects arbitrary error-code text fail closed. This is an explicit interoperability exception for a specification-internal inconsistency, not authority to infer or accept other strings; adding any further code requires fresh primary-source review and regression evidence. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). diff --git a/tests/test_webdriver_bidi_command_correlation_documentation_contract.py b/tests/test_webdriver_bidi_command_correlation_documentation_contract.py index dc90c8b7d..1f87f95e3 100644 --- a/tests/test_webdriver_bidi_command_correlation_documentation_contract.py +++ b/tests/test_webdriver_bidi_command_correlation_documentation_contract.py @@ -12,6 +12,14 @@ class CommandCorrelationDocumentationTests(unittest.TestCase): """Enforce the correlation-owned release record without constraining other entries.""" + def test_action_binding_keeps_registry_and_reply_provenance_separate(self) -> None: + """Node ownership must not be described as response-connection provenance.""" + text = (ROOT / "docs/doctoring/browser-agent-protocols.md").read_text(encoding="utf-8") + paragraph = next(line for line in text.splitlines() if line.startswith("Semantic action binding retains")) + self.assertIn("same registry identity as admitted-node minting and typed-command validation", paragraph) + self.assertIn("reply provenance remains independently connection-bound", paragraph) + self.assertNotIn("same registry identity as transport provenance", paragraph) + def test_command_correlation_release_record_matches_public_boundary(self) -> None: """The release record must retain its resource, provenance and authority bounds.""" changelog = CHANGELOG.read_text(encoding="utf-8")